Files
settled-reach/.pql/changelog/tickets/2026-08.sql
T
jpmschweitzerandClaude Opus 5 6f08cc9156 feat(config): T-1278 — the jobs domain, and typer.Exit is not a SystemExit
reach jobs list / status / log --follow / wait. A domain rather than core/,
because these verbs carry logic and state: they reconcile recorded status
against process liveness, tail a file from an offset, and relay an exit code.

Found a latent bug in already-committed code before building on it. typer.Exit
is a RuntimeError, not a SystemExit, so @handle_errors caught it like any other
unexpected exception: `raise typer.Exit(3)` inside a decorated command printed
"unexpected Exit: 3" and exited 1, silently discarding the requested code.
Nothing hit it because the check router had been converted to ReachError — but
jobs wait needs exactly this and it is what anyone would naturally write. Added
core/errors.ReachExit as the sanctioned control-flow exit, passed straight
through with no verdict. ReachError would have been wrong twice: a failure
verdict for a command that worked, and a demand for a fix= where there is no
remedy.

Reconciliation proved out on a real corpse rather than a simulated one — the
job stranded by the T-1277 bug, status "running" with its process long gone,
now reports as died. DIED is derived, never recorded, because a process killed
outright cannot write its own ending. It relays 137, never 0: a died job has no
exit code of its own and borrowing success points the exit-0 trap straight at
whatever gated on the run.

Second UTC bug of the same family as T-1276's: jobs list reported a job started
minutes earlier as running for 133m, because _parse used mktime on a UTC stamp
and silently added the offset to every duration.

console.render() is public now, so jobs log replays stored events through the
same path a live run prints them — a second renderer would drift, and the
divergence would surface exactly when someone is reading a log to find out what
went wrong.

test_jobs.py closes the gap T-1257 named: D-263 claims services are callable
without a CLI round trip, and nothing had ever demonstrated it, which left the
layering as unverified decoration. Every test here calls the service directly.

Not yet exercised, and said plainly: log --follow against a genuinely
long-running job. Nothing in reach runs long enough to tail yet. The offset
mechanics underneath are tested; the live loop waits for a slow domain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:02:36 +02:00

2290 lines
608 KiB
SQL

INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXF1VDVQDQ8EFGTXX787M90R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas rivers vanish at native resolution — 375 courses arrive, 0 drawn', 'Found 2026-08-06 when the visual capture resolution was raised from 960x540 to the native 3440x1440. On Ferrath (GJ820Bc) Global the wire delivers 375 river courses and the annotation layer draws NONE: ''courses=375 drawn=0'' in the view-transform readout. At 960x540 the SAME build drew them as visible strokes, so this is resolution-dependent, not a river-generation failure -- the courses are present and correct on the wire. Suspect the D-261 cull (drop a course below 15 px of on-screen length, 3x the 5 px stroke) or the water-truncation step: T-1237 computes the clipped/culled polylines ONCE on canvas adoption (set_frame) rather than per draw, so if adoption runs against a pre-layout or stale viewport the screen-length measurement is wrong for every course at once -- which matches an all-or-nothing drawn=0 rather than a partial cull. Note the scale moved the RIGHT way for visibility (46.792 km/gridunit at 960x540 vs 35.267 at 3440x1440 -- more gridunits across the body, so a river spans MORE of them), which makes a legitimate cull an unlikely explanation. Reproduce: tests/run-visual --screenshot atlas_GJ820Bc_Global and read the drawn= count.', 'backlog', 'high', NULL, 'client', 'D-261', '2026-08-06 14:43:24.765', '2026-08-06 14:43:24.765', NULL, '9597ed74aa67fb7ebeef78f60374d89c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TX2W0BA10PRR7NMJ2362M', 'epic', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Rung-0.5 expanded layer — whole-body hydrology + biome-from-orbit base (D-258)', 'D-258: the Atlas cascade gains a single expanded layer (''rung 0.5''), generated once per body from the rung-0 input pair (heightmap.png + reliefmap.png, never displayed), and every zoom tier below it (Region/District/Quarter/Block/Chunk) derives from that layer instead of independently re-deriving from the source files. Hydrology (drainage, course routing, lake fill) resolves ONCE on rung 0.5 and nowhere else -- it is a whole-body computation, not derivable per-window. Global displays rung 0.5 directly (biome-from-orbit, not a photograph). Same-session amendment: lake shorelines run the same shore-morphology code as ocean shorelines (coastline warp applied to both surfaces in the single rung-0.5 pass; shore-morphology gates key on proximity to water, not to ocean; sea-flavored types like TidalFlat/Estuarine-vs-Delta separated by tidal energy, a derived quantity, never by an is-it-the-ocean switch; salinity excluded from morphology entirely). This is a named, principled carve-out from D-227 derive-don''t-store: a whole-body flow solve is not locally computable, so it cannot be re-derived per window at any price -- storage here buys correctness, not convenience. Not yet ticketed prior to this epic (D-258''s own Implementation note). See governance/decisions/architecture.md#d-258.
RE-SCOPE REQUIRED BEFORE ANY CHILD STARTS (2026-07-27, D-258 amendment). The structural rationale for this epic was disproven on evidence the day after it was written. D-258 claimed hydrology ''was not derivable at all'' per-window; in fact layer1.rs::run_layer1_with_moisture already solves drainage AND settled-equilibrium hydrology once per body, folds the filled surface into TerrainAnalysis, and every rung bilinearly samples it -- the code''s own comment calls it ''a coarse continuous primitive computed once, sampled fresh at every rung, never re-solved'' (mechanism B, D-255(f)). Compute-once-sample-everywhere already exists. What actually made Global look flat was serve_step_canvas_request zeroing Global''s wire extent (a pre-extent-inversion sentinel), producing a 2x1 canvas -- fixed 2026-07-27; once sized correctly Global reads as a world with no hydrology work at all. Rivers at Global were measured as negligible: 375 courses present, 458 of 518,400 pixels different vs courses-off, because at ~39.7 km/gridunit most courses are shorter than one gridunit. SURVIVES: biome un-summarisation (reliefmap as plurality), composition-on-descent, the conservation invariant, and the lake-shore amendment. WEAKENED: the stored expanded layer and its D-227 carve-out. The live question is no longer ''what does rung 0.5 cost'' but ''does biome un-summarisation need a stored layer at all, or does it ride the existing sample-fresh-at-every-rung mechanism''. Re-scope this epic and T-1212 against that question first.
---
RE-SCOPED 2026-08-06 (Jeroen''s call) — the epic is unblocked, and its question has changed again.
EVIDENCE. A descent ladder was captured on Ferrath (GJ820Bc) at native 3440x1440,
anchored on land, one shot per rung, no overlays (scenarios atlas_GJ820Bc_land_*
in tests/visual.json). Ferrath''s heightmap is 1024x512 over a 38,089 km
circumference = 37.2 km per source pixel. Against that:
Global 35.267 km/gridunit ~1:1 with the source pixel a real map
Region 0.379 km/gridunit 98x finer flat wash
District 0.0038 km/gridunit 9,800x finer flat wash
Quarter/Block/Chunk finer still flat
The Atlas is legible exactly where it SAMPLES the heightmap and flat everywhere
it must INVENT. Every rung below Global is a single uniform colour field with
dither noise; the courses/settlements readout is 0 at all of them.
WHAT THIS SETTLES. D-258''s amendment (5) framed the live question as "does biome
un-summarisation need a stored layer at all, or does it ride the existing
sample-fresh-at-every-rung mechanism". The ladder answers a PRIOR question:
un-summarisation is not happening in ANY form. There is no expansion to decide
the storage policy for. Storage is therefore a downstream optimisation, not the
decision this epic turns on.
NEW SCOPE. Build the expansion first as a PURE FUNCTION, following the mechanism
that already exists rather than inventing a second one: layer1.rs''s
compute-once-sample-everywhere primitive (D-255(f) mechanism B) is the model, and
the amendment established hydrology already works that way. Measure it. Add a
stored layer ONLY if the measured cost forces it — and if it does, that is when
the D-227 carve-out argument gets made, on numbers rather than on the disproven
"not locally computable" claim.
Order: T-1213 (the un-summarisation generator) is now the first child and is
UNBLOCKED. T-1214 (hydrology onto rung 0.5) stays parked — the amendment showed
that solve already runs once per body and does not need to move. T-1216 (Global
biome-from-orbit palette) and T-1217 (lake shores through the shared shore path)
survive unchanged; both were independent of the storage question.
T-1212 does not gate this any more (see its own note).', 'backlog', 'high', NULL, NULL, 'D-258', '2026-07-26 21:53:56.448', '2026-08-06 15:59:03.757', NULL, '923892406469abdd3fb6ee9041ae1cbf', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TYBD74TQNVKTJMKA8D9KM', 'task', '06FT0TX2W0BA10PRR7NMJ2362M', 'Measure rung-0.5 cost/size BEFORE implementing (compute + disk, whole-body)', 'MUST run and be reviewed before any other T-1211 child starts -- this measurement could reshape the rung-0.5 design, per the pair session''s explicit sequencing note. Measure, at minimum: (1) per-body derive cost for the expanded layer at a resolution sized so a whole body draws at 2 screen px per gridunit on a large display (the D-258 sizing rule) -- both single-body cold-derive time and the full ~271-body population sum; (2) per-body and total disk footprint if the layer is cached/stored (rung 0.5 is a named D-227 carve-out -- storage is deliberate, but its size must be known, not assumed); (3) whole-body hydrology solve cost on this layer (drainage + course routing + lake fill) at the same resolution, since D-258 requires this to run exactly once per body and nowhere else. Reference point: D-255''s own rung-0 always-keep tier estimate went from ~8.85 MB (measured against a stale ~18K-cell/body figure) to an estimated 226 MB (1080p) / ~900 MB (4K) once the extent inversion made Global viewport-sized -- D-255 amendment item 6 explicitly says ''re-measure against rung 0.5, not against this record.'' This ticket is that re-measurement. Report back to the team before T-1211''s other children are started; if the numbers are structurally bad (e.g. rung 0.5 at the sizing D-258 specifies costs an order of magnitude more than the old rung-0 model), that is grounds to revisit the resolution target with Jeroen before writing generator code. See governance/decisions/architecture.md#d-258 (rationale + Implementation note).
SCOPE INVALIDATED 2026-07-27 (see T-1211 and the D-258 amendment). This ticket was written to measure the cost of moving a whole-body hydrology solve onto rung 0.5. That solve does not need to move -- it already runs once per body in layer1.rs and is sampled fresh at every rung. Do NOT run this measurement as written; it would price work that is not required. If a measurement is still wanted after T-1211 is re-scoped, the question is narrower: what does BIOME un-summarisation cost, and does it need storing at all.
---
RETIRED AS A GATE 2026-08-06. This no longer blocks T-1213 or the T-1211 epic.
It was already SCOPE INVALIDATED (2026-07-27) for pricing a hydrology move that
does not need to happen. The 2026-08-06 descent ladder (see T-1211) closes the
remaining reason to keep it as a gate: every rung below Global renders a flat
wash, so biome un-summarisation is not happening at all. There is no artefact
whose cost or disk footprint can be measured, because none is produced.
The measurement question survives, but it is now DOWNSTREAM of T-1213 rather
than upstream of it: once the un-summarisation exists as a pure function, measure
THAT, and only then decide whether a stored layer is warranted. Re-scope this
ticket to that measurement when T-1213 lands, or close it and let T-1213 carry
its own measurement step.', 'backlog', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:06.825', '2026-08-06 15:59:12.825', NULL, 'e0d88ae134ccd57737d8eab2627e2b88', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TZC9MJV6KZTSRVRYQ327M', 'story', '06FT0TX2W0BA10PRR7NMJ2362M', 'Rung-0.5 expanded-layer generator (deterministic un-summarisation of rung 0)', 'Build the deterministic expansion from rung 0 (heightmap.png 1024x512 16-bit elevation + reliefmap.png 1024x512 colour biome) to rung 0.5, sized per D-258''s 2-px-per-gridunit-at-large-display rule. Same body + same seed must produce the same layer every time (byte-identical, per D-227''s determinism discipline extended by this carve-out). The reliefmap is a PLURALITY, not ground truth: each reliefmap cell already voted-and-discarded the dominant biome across ~38 km of ground, so this generator un-summarises it -- it does not upscale/interpolate it. Three binding invariants from D-258: (1) biome edges are gradients, never lines -- transitions blend so no boundary falls on a rung-0 cell edge (the D-243 climate edge-fuzz rule applied to biome); (2) descending the ladder reveals COMPOSITION not sharpness -- a cell reading ''forest'' globally must be able to contain clearings/marsh/rock/scrub the vote suppressed, emerging deterministically as the ladder descends; (3) CONSERVATIVE invention is the binding acceptance gate -- downsampling rung 0.5 must reproduce the rung-0 summary it came from (a forest cell may gain marsh pockets but must still read as forest from orbit). Blocked on T-1212 (cost/size measurement) landing first. Depends on: nothing else in this epic to start scaffolding, but hydrology (sibling ticket) and this generator are tightly coupled -- coordinate sequencing with whoever picks up hydrology. See governance/decisions/architecture.md#d-258.
---
UNBLOCKED 2026-08-06 (T-1211 re-scope, Jeroen''s call). The T-1212 blocker edge is
removed: that measurement priced a hydrology move that is not happening, and the
descent ladder showed there is no expansion artefact to measure yet anyway.
BUILD IT AS A PURE FUNCTION FIRST, not as a stored layer. The stored-layer half of
D-258 was materially weakened by its own 2026-07-27 amendment (the "not locally
computable" argument for the D-227 carve-out does not hold, because the whole-body
solve it cited already runs once per body in layer1.rs and is sampled at every
rung). So follow the mechanism that exists — D-255(f) mechanism B,
compute-once-sample-everywhere — measure it, and only argue for storage on those
numbers. Do NOT open with a cache.
WHAT "FLAT" MEANS CONCRETELY, so the fix has a target. Ferrath''s heightmap is
1024x512 over a 38,089 km circumference: 37.2 km per source pixel. Global draws at
35.267 km/gridunit, roughly 1:1 with the source, and reads as a real map. Region
draws at 0.379 km/gridunit — 98x finer than any stored datum — and is a single
uniform colour with dither. District is 0.0038 km/gridunit, ~9,800x finer, also
uniform. So the acceptance bar is not subtle: at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating composition
that still downsamples back to that pixel''s summary (D-258''s conservation
invariant, the binding gate).
VERIFY BY CAPTURE, NOT BY REASONING. The scenarios exist: atlas_GJ820Bc_land_*
(Region/District/Quarter/Block/Chunk, one land-anchored world point, no overlays)
in tests/visual.json. Captures run offscreen under gamescope at native 3440x1440
via tests/run-visual --screenshot <name> — they do not steal the desktop. Re-shoot
the ladder and look at it; a green unit test proves nothing here.
Note the ladder currently also reports courses=0 at every rung below Global —
rivers vanish on descent. That is tracked separately as T-1239 and is NOT this
ticket''s scope, but it will be visible in the same captures, so do not mistake it
for a failure of the un-summarisation work.', 'backlog', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-06 15:59:39.623', NULL, 'd9bfabaf6b4f1751a9261995f84c0b7b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TZC9MJV6KZTSRVRYQ327M', 'story', '06FT0TX2W0BA10PRR7NMJ2362M', 'Rung-0.5 expanded-layer generator (deterministic un-summarisation of rung 0)', 'Build the deterministic expansion from rung 0 (heightmap.png 1024x512 16-bit elevation + reliefmap.png 1024x512 colour biome) to rung 0.5, sized per D-258''s 2-px-per-gridunit-at-large-display rule. Same body + same seed must produce the same layer every time (byte-identical, per D-227''s determinism discipline extended by this carve-out). The reliefmap is a PLURALITY, not ground truth: each reliefmap cell already voted-and-discarded the dominant biome across ~38 km of ground, so this generator un-summarises it -- it does not upscale/interpolate it. Three binding invariants from D-258: (1) biome edges are gradients, never lines -- transitions blend so no boundary falls on a rung-0 cell edge (the D-243 climate edge-fuzz rule applied to biome); (2) descending the ladder reveals COMPOSITION not sharpness -- a cell reading ''forest'' globally must be able to contain clearings/marsh/rock/scrub the vote suppressed, emerging deterministically as the ladder descends; (3) CONSERVATIVE invention is the binding acceptance gate -- downsampling rung 0.5 must reproduce the rung-0 summary it came from (a forest cell may gain marsh pockets but must still read as forest from orbit). Blocked on T-1212 (cost/size measurement) landing first. Depends on: nothing else in this epic to start scaffolding, but hydrology (sibling ticket) and this generator are tightly coupled -- coordinate sequencing with whoever picks up hydrology. See governance/decisions/architecture.md#d-258.
---
UNBLOCKED 2026-08-06 (T-1211 re-scope, Jeroen''s call). The T-1212 blocker edge is
removed: that measurement priced a hydrology move that is not happening, and the
descent ladder showed there is no expansion artefact to measure yet anyway.
BUILD IT AS A PURE FUNCTION FIRST, not as a stored layer. The stored-layer half of
D-258 was materially weakened by its own 2026-07-27 amendment (the "not locally
computable" argument for the D-227 carve-out does not hold, because the whole-body
solve it cited already runs once per body in layer1.rs and is sampled at every
rung). So follow the mechanism that exists — D-255(f) mechanism B,
compute-once-sample-everywhere — measure it, and only argue for storage on those
numbers. Do NOT open with a cache.
WHAT "FLAT" MEANS CONCRETELY, so the fix has a target. Ferrath''s heightmap is
1024x512 over a 38,089 km circumference: 37.2 km per source pixel. Global draws at
35.267 km/gridunit, roughly 1:1 with the source, and reads as a real map. Region
draws at 0.379 km/gridunit — 98x finer than any stored datum — and is a single
uniform colour with dither. District is 0.0038 km/gridunit, ~9,800x finer, also
uniform. So the acceptance bar is not subtle: at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating composition
that still downsamples back to that pixel''s summary (D-258''s conservation
invariant, the binding gate).
VERIFY BY CAPTURE, NOT BY REASONING. The scenarios exist: atlas_GJ820Bc_land_*
(Region/District/Quarter/Block/Chunk, one land-anchored world point, no overlays)
in tests/visual.json. Captures run offscreen under gamescope at native 3440x1440
via tests/run-visual --screenshot <name> — they do not steal the desktop. Re-shoot
the ladder and look at it; a green unit test proves nothing here.
Note the ladder currently also reports courses=0 at every rung below Global —
rivers vanish on descent. That is tracked separately as T-1239 and is NOT this
ticket''s scope, but it will be visible in the same captures, so do not mistake it
for a failure of the un-summarisation work.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-07 11:36:05.551', NULL, 'a3edd5306633b6247c1cd67bf700db69', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TZC9MJV6KZTSRVRYQ327M', 'story', '06FT0TX2W0BA10PRR7NMJ2362M', 'Rung-0.5 expanded-layer generator (deterministic un-summarisation of rung 0)', 'Build the deterministic expansion from rung 0 (heightmap.png 1024x512 16-bit elevation + reliefmap.png 1024x512 colour biome) to rung 0.5, sized per D-258''s 2-px-per-gridunit-at-large-display rule. Same body + same seed must produce the same layer every time (byte-identical, per D-227''s determinism discipline extended by this carve-out). The reliefmap is a PLURALITY, not ground truth: each reliefmap cell already voted-and-discarded the dominant biome across ~38 km of ground, so this generator un-summarises it -- it does not upscale/interpolate it. Three binding invariants from D-258: (1) biome edges are gradients, never lines -- transitions blend so no boundary falls on a rung-0 cell edge (the D-243 climate edge-fuzz rule applied to biome); (2) descending the ladder reveals COMPOSITION not sharpness -- a cell reading ''forest'' globally must be able to contain clearings/marsh/rock/scrub the vote suppressed, emerging deterministically as the ladder descends; (3) CONSERVATIVE invention is the binding acceptance gate -- downsampling rung 0.5 must reproduce the rung-0 summary it came from (a forest cell may gain marsh pockets but must still read as forest from orbit). Blocked on T-1212 (cost/size measurement) landing first. Depends on: nothing else in this epic to start scaffolding, but hydrology (sibling ticket) and this generator are tightly coupled -- coordinate sequencing with whoever picks up hydrology. See governance/decisions/architecture.md#d-258.
---
UNBLOCKED 2026-08-06 (T-1211 re-scope, Jeroen''s call). The T-1212 blocker edge is
removed: that measurement priced a hydrology move that is not happening, and the
descent ladder showed there is no expansion artefact to measure yet anyway.
BUILD IT AS A PURE FUNCTION FIRST, not as a stored layer. The stored-layer half of
D-258 was materially weakened by its own 2026-07-27 amendment (the "not locally
computable" argument for the D-227 carve-out does not hold, because the whole-body
solve it cited already runs once per body in layer1.rs and is sampled at every
rung). So follow the mechanism that exists — D-255(f) mechanism B,
compute-once-sample-everywhere — measure it, and only argue for storage on those
numbers. Do NOT open with a cache.
WHAT "FLAT" MEANS CONCRETELY, so the fix has a target. Ferrath''s heightmap is
1024x512 over a 38,089 km circumference: 37.2 km per source pixel. Global draws at
35.267 km/gridunit, roughly 1:1 with the source, and reads as a real map. Region
draws at 0.379 km/gridunit — 98x finer than any stored datum — and is a single
uniform colour with dither. District is 0.0038 km/gridunit, ~9,800x finer, also
uniform. So the acceptance bar is not subtle: at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating composition
that still downsamples back to that pixel''s summary (D-258''s conservation
invariant, the binding gate).
VERIFY BY CAPTURE, NOT BY REASONING. The scenarios exist: atlas_GJ820Bc_land_*
(Region/District/Quarter/Block/Chunk, one land-anchored world point, no overlays)
in tests/visual.json. Captures run offscreen under gamescope at native 3440x1440
via tests/run-visual --screenshot <name> — they do not steal the desktop. Re-shoot
the ladder and look at it; a green unit test proves nothing here.
Note the ladder currently also reports courses=0 at every rung below Global —
rivers vanish on descent. That is tracked separately as T-1239 and is NOT this
ticket''s scope, but it will be visible in the same captures, so do not mistake it
for a failure of the un-summarisation work.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-07 11:36:20.464', NULL, '94ccf78431914ea836ba5d59170a3395', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FSJWSX11WV3C1XXZEV88Q3P0', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Biome/relief stipple-density layer on the terrain build (RimWorld technique 4)', 'Follow-up from T-1175''s assess-only item (2026-07-25, stig''s write-up): a stipple/texture-density layer carrying hills/mountains/forest on top of hue, driven by the already-wire-carried vegetation and elev_q L8 planes — presentation-only, D-255(e)-legal (texture-space dithering of already-derived per-cell values, deterministically seeded per cell coordinate + value so it is stable across cache hit/miss; never invents samples between server cells). Would live as a post-process in step_canvas_terrain_layer.gd::rebuild_from_canvas()''s Image.set_pixel build. Design questions to settle at pickup: (a) stipple dots inline in the existing per-cell loop (cheap, same O(wxh) pass) vs a second overlay pass (simpler code, doubles pixel-touch cost); (b) density from vegetation class directly vs a combination with elev_q — relief hachures and forest texture are two different visual grammars in the RimWorld reference, not one slider; (c) own legend toggle (TMP/MST/VEG overlay-bar pattern) vs always-on like the elevation lightness modifier. Reference: docs/design/references/rimworld-world-map-fluency.jpg. Related: T-1175, T-1162 (vegetation patchiness fields), D-255(e).', 'in_progress', 'low', NULL, 'client', NULL, '2026-07-25 13:24:54.152', '2026-08-07 12:44:21.620', NULL, 'c1560b624bd9a9d79fe919f282620f22', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'MIN_WL_BANDS_M octave cutoffs are pre-extent-inversion (stale, currently inert)', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.', 'backlog', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-07 13:26:56.703', NULL, '45cb7553effbe05042fe7f3eb09e6483', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXF1VDVQDQ8EFGTXX787M90R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas rivers vanish at native resolution — 375 courses arrive, 0 drawn', 'Found 2026-08-06 when the visual capture resolution was raised from 960x540 to the native 3440x1440. On Ferrath (GJ820Bc) Global the wire delivers 375 river courses and the annotation layer draws NONE: ''courses=375 drawn=0'' in the view-transform readout. At 960x540 the SAME build drew them as visible strokes, so this is resolution-dependent, not a river-generation failure -- the courses are present and correct on the wire. Suspect the D-261 cull (drop a course below 15 px of on-screen length, 3x the 5 px stroke) or the water-truncation step: T-1237 computes the clipped/culled polylines ONCE on canvas adoption (set_frame) rather than per draw, so if adoption runs against a pre-layout or stale viewport the screen-length measurement is wrong for every course at once -- which matches an all-or-nothing drawn=0 rather than a partial cull. Note the scale moved the RIGHT way for visibility (46.792 km/gridunit at 960x540 vs 35.267 at 3440x1440 -- more gridunits across the body, so a river spans MORE of them), which makes a legitimate cull an unlikely explanation. Reproduce: tests/run-visual --screenshot atlas_GJ820Bc_Global and read the drawn= count.', 'in_progress', 'high', NULL, 'client', 'D-261', '2026-08-06 14:43:24.765', '2026-08-13 22:12:59.623', NULL, 'a52ba3f39408c80765dca1eb58e79ae6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXF1VDVQDQ8EFGTXX787M90R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas rivers vanish at native resolution — 375 courses arrive, 0 drawn', 'Found 2026-08-06 when the visual capture resolution was raised from 960x540 to the native 3440x1440. On Ferrath (GJ820Bc) Global the wire delivers 375 river courses and the annotation layer draws NONE: ''courses=375 drawn=0'' in the view-transform readout. At 960x540 the SAME build drew them as visible strokes, so this is resolution-dependent, not a river-generation failure -- the courses are present and correct on the wire. Suspect the D-261 cull (drop a course below 15 px of on-screen length, 3x the 5 px stroke) or the water-truncation step: T-1237 computes the clipped/culled polylines ONCE on canvas adoption (set_frame) rather than per draw, so if adoption runs against a pre-layout or stale viewport the screen-length measurement is wrong for every course at once -- which matches an all-or-nothing drawn=0 rather than a partial cull. Note the scale moved the RIGHT way for visibility (46.792 km/gridunit at 960x540 vs 35.267 at 3440x1440 -- more gridunits across the body, so a river spans MORE of them), which makes a legitimate cull an unlikely explanation. Reproduce: tests/run-visual --screenshot atlas_GJ820Bc_Global and read the drawn= count.
---
DIAGNOSED 2026-08-14. Not a client rendering bug. Both suspects in the original
report are wrong, and so is the `team: client` label — the defect is a stale
client-side disk cache (T-1183/D-255), invalidated by nothing that changed.
MEASUREMENT. Same build, same scenario, same native 3440x1440, only the cache
differs:
stale cache courses=375 runs=180 longest=6.0px (~106 km) drawn=0
cold cache courses=73 runs=23 longest=93.2px (~1,644 km) drawn=18
Server-side, at the same moment the cold capture ran:
`river_cells=615 paths=123 courses=73 longest_path_cells=27 ta_w=512 ta_h=256`.
The network is exactly as designed. During the STALE capture the server logged
NO course production at all — the canvas never came from it.
WHY IT LOOKED RESOLUTION-DEPENDENT. It isn''t. 960x540 resolves to an 814x407
canvas, a cache key never written before, so it MISSED and re-derived correctly
(73 courses, 1,644 km trunk). 3440x1440 resolves to 1080x540, which HAD a cached
entry from 2026-08-06 — written before T-1237 (4e503c356) replaced one-course-
per-D8-hop with one-course-per-river. Every `.dat` payload for GJ820Bc predates
that fix; the oldest is 2026-07-28. So the "high resolution" capture was
replaying a pre-fix canvas: 375 hop fragments, none clearing D-261''s
read-as-a-line floor, hence drawn=0. The ticket''s own note that the scale "moved
the RIGHT way for visibility" was correct and was the clue — a legitimate cull
could not explain it, because the cull was never the actor.
The 375 / 180 / 0 chain also matches, digit for digit, the pre-fix measurement
already written into `_cull_short`''s doc comment. That number was being re-read
off a cache, not re-measured.
ROOT CAUSE. The disk cache''s only invalidation signal is `project.yaml: version`
(step_canvas_disk_cache.gd `current_schema_version()`). 4e503c356 changed how
canvases are GENERATED but touched only the annotation layer, river_course.rs and
step_canvas.rs — never project.yaml — so every hop-shaped entry stayed "valid".
All 13 stale GJ820Bc entries are stamped 0.4.5, the then-current version. This is
the fourth instance of the same class: project.yaml''s own comments record 0.4.2,
0.4.3, 0.4.4 and 0.4.5 as bumps forced by exactly this failure.
FIX, three parts:
1. project.yaml 0.4.5 -> 0.4.6, forcing every pre-T-1237 entry to miss. This is
what repairs a real player''s Atlas; clearing a local cache is not a fix.
2. tests/run-visual isolates `user://` per capture (XDG_DATA_HOME into
.cache/visual-user-data, recreated each run). The harness was reading the
developer''s persistent cache, so a capture could render a canvas built by a
build that no longer existed — and every golden shot in that window silently
inherited it. A visual test must exercise the tree it is run against.
3. tests/run-visual keeps server stderr (was `2>/dev/null` into an unlinked
mktemp file). No tracing output from a capture was reachable, which is why
"the server produced nothing" was invisible for eight days.
Diagnostic left in place: the capture readout now prints `runs=` and `longest=`
between `courses=` and `drawn=`, so the three stages of "arrived -> survived the
water clip -> survived the length cull" can be told apart from a single capture.
That distinction is what made this solvable, and its absence is what made the
original report guess between two wrong suspects.
FOLLOW-UPS worth their own tickets, not done here:
- `current_schema_version()` reads `res://../project.yaml`, which does not exist
in an exported build — it returns the "?.?.?" fallback, identical for every
build, so a shipped game''s cache would never invalidate on version at all.
- Nothing enforces the generation-change/version-bump pairing. Four occurrences
suggests a check (e.g. a pre-push rule: canvas-generation paths touched =>
project.yaml version must move) rather than a fifth comment.', 'in_progress', 'high', NULL, 'client', 'D-261', '2026-08-06 14:43:24.765', '2026-08-14 21:13:15.779', NULL, 'ee86b57855c05540234c4b0f04ca2a18', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0495WRHF8ADK82VR8CH1J8R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas disk cache never invalidates in an exported build — current_schema_version() falls back to ''?.?.?''', 'step_canvas_disk_cache.gd::current_schema_version() reads ProjectSettings.globalize_path(''res://'') + ''/../project.yaml''. That resolves to the repo-root file in a dev run (res:// = client/), but an exported build has no project.yaml one level above res://, so the function returns its ''?.?.?'' fallback. Every exported build therefore stamps and compares the SAME sentinel version, which means the schema-version invalidation path — the cache''s only invalidation signal — is inert in a shipped game: a canvas cached by one build is served forever by every later build. Found while diagnosing T-1239, where the same mechanism failed in dev for a different reason (the version simply was not bumped). Fix direction: bake the version into the client at export time (a generated const, ProjectSettings application/config/version, or an exported resource) rather than reading a repo file at runtime. Note test_current_schema_version_reads_project_yaml passes in dev and would not catch this — it asserts the non-fallback path, in the only environment where that path works.', 'backlog', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:17.188', '2026-08-14 21:19:17.188', NULL, '7880dfa6b9038360c78843ae9f6ca2e2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G04975H3S7GRVQXHKYCR7BKR', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Enforce the canvas-generation/project.yaml-version pairing — four silent stale-cache regressions and counting', 'project.yaml''s version is the Atlas disk cache''s only invalidation signal, and nothing enforces that a change to canvas GENERATION also moves it. The file''s own comment block now records four bumps forced after the fact by exactly this failure: 0.4.2 (lake_margin_q semantics), 0.4.3 (coast_warp_px at orbital sampling), 0.4.4 (D-255 extent inversion), 0.4.5 (Global sentinel), and now 0.4.6 (T-1237 one-course-per-river, diagnosed as T-1239 eight days after it shipped). The failure is silent and machine-dependent: it reproduces only where a warm cache exists, so the author with a cold checkout sees nothing wrong. Direction: a pre-push check in .config/hooks/pre-push — if the push touches the canvas-generation paths (server/src/atlas/step_canvas.rs, river_course.rs, layer1.rs, district_profile.rs, the client step_canvas layers) and project.yaml''s version line is unchanged in the same range, reject with the reason. Registry-driven like tooling/generator_sources.py rather than a hand-kept path list in the hook. A false positive is cheap (bump the version, entries miss once); a false negative is another week of a wrong map.', 'backlog', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:27.624', '2026-08-14 21:19:27.624', NULL, '5386de8d8c8120e17373cc1c1a59a9cd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXF1VDVQDQ8EFGTXX787M90R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas rivers vanish at native resolution — 375 courses arrive, 0 drawn', 'Found 2026-08-06 when the visual capture resolution was raised from 960x540 to the native 3440x1440. On Ferrath (GJ820Bc) Global the wire delivers 375 river courses and the annotation layer draws NONE: ''courses=375 drawn=0'' in the view-transform readout. At 960x540 the SAME build drew them as visible strokes, so this is resolution-dependent, not a river-generation failure -- the courses are present and correct on the wire. Suspect the D-261 cull (drop a course below 15 px of on-screen length, 3x the 5 px stroke) or the water-truncation step: T-1237 computes the clipped/culled polylines ONCE on canvas adoption (set_frame) rather than per draw, so if adoption runs against a pre-layout or stale viewport the screen-length measurement is wrong for every course at once -- which matches an all-or-nothing drawn=0 rather than a partial cull. Note the scale moved the RIGHT way for visibility (46.792 km/gridunit at 960x540 vs 35.267 at 3440x1440 -- more gridunits across the body, so a river spans MORE of them), which makes a legitimate cull an unlikely explanation. Reproduce: tests/run-visual --screenshot atlas_GJ820Bc_Global and read the drawn= count.
---
DIAGNOSED 2026-08-14. Not a client rendering bug. Both suspects in the original
report are wrong, and so is the `team: client` label — the defect is a stale
client-side disk cache (T-1183/D-255), invalidated by nothing that changed.
MEASUREMENT. Same build, same scenario, same native 3440x1440, only the cache
differs:
stale cache courses=375 runs=180 longest=6.0px (~106 km) drawn=0
cold cache courses=73 runs=23 longest=93.2px (~1,644 km) drawn=18
Server-side, at the same moment the cold capture ran:
`river_cells=615 paths=123 courses=73 longest_path_cells=27 ta_w=512 ta_h=256`.
The network is exactly as designed. During the STALE capture the server logged
NO course production at all — the canvas never came from it.
WHY IT LOOKED RESOLUTION-DEPENDENT. It isn''t. 960x540 resolves to an 814x407
canvas, a cache key never written before, so it MISSED and re-derived correctly
(73 courses, 1,644 km trunk). 3440x1440 resolves to 1080x540, which HAD a cached
entry from 2026-08-06 — written before T-1237 (4e503c356) replaced one-course-
per-D8-hop with one-course-per-river. Every `.dat` payload for GJ820Bc predates
that fix; the oldest is 2026-07-28. So the "high resolution" capture was
replaying a pre-fix canvas: 375 hop fragments, none clearing D-261''s
read-as-a-line floor, hence drawn=0. The ticket''s own note that the scale "moved
the RIGHT way for visibility" was correct and was the clue — a legitimate cull
could not explain it, because the cull was never the actor.
The 375 / 180 / 0 chain also matches, digit for digit, the pre-fix measurement
already written into `_cull_short`''s doc comment. That number was being re-read
off a cache, not re-measured.
ROOT CAUSE. The disk cache''s only invalidation signal is `project.yaml: version`
(step_canvas_disk_cache.gd `current_schema_version()`). 4e503c356 changed how
canvases are GENERATED but touched only the annotation layer, river_course.rs and
step_canvas.rs — never project.yaml — so every hop-shaped entry stayed "valid".
All 13 stale GJ820Bc entries are stamped 0.4.5, the then-current version. This is
the fourth instance of the same class: project.yaml''s own comments record 0.4.2,
0.4.3, 0.4.4 and 0.4.5 as bumps forced by exactly this failure.
FIX, three parts:
1. project.yaml 0.4.5 -> 0.4.6, forcing every pre-T-1237 entry to miss. This is
what repairs a real player''s Atlas; clearing a local cache is not a fix.
2. tests/run-visual isolates `user://` per capture (XDG_DATA_HOME into
.cache/visual-user-data, recreated each run). The harness was reading the
developer''s persistent cache, so a capture could render a canvas built by a
build that no longer existed — and every golden shot in that window silently
inherited it. A visual test must exercise the tree it is run against.
3. tests/run-visual keeps server stderr (was `2>/dev/null` into an unlinked
mktemp file). No tracing output from a capture was reachable, which is why
"the server produced nothing" was invisible for eight days.
Diagnostic left in place: the capture readout now prints `runs=` and `longest=`
between `courses=` and `drawn=`, so the three stages of "arrived -> survived the
water clip -> survived the length cull" can be told apart from a single capture.
That distinction is what made this solvable, and its absence is what made the
original report guess between two wrong suspects.
FOLLOW-UPS worth their own tickets, not done here:
- `current_schema_version()` reads `res://../project.yaml`, which does not exist
in an exported build — it returns the "?.?.?" fallback, identical for every
build, so a shipped game''s cache would never invalidate on version at all.
- Nothing enforces the generation-change/version-bump pairing. Four occurrences
suggests a check (e.g. a pre-push rule: canvas-generation paths touched =>
project.yaml version must move) rather than a fifth comment.', 'done', 'high', NULL, 'client', 'D-261', '2026-08-06 14:43:24.765', '2026-08-14 21:20:27.835', NULL, 'fa2402988b35d9f0db6ffcf7cf7a81c2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0495WRHF8ADK82VR8CH1J8R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas disk cache never invalidates in an exported build — current_schema_version() falls back to ''?.?.?''', 'step_canvas_disk_cache.gd::current_schema_version() reads ProjectSettings.globalize_path(''res://'') + ''/../project.yaml''. That resolves to the repo-root file in a dev run (res:// = client/), but an exported build has no project.yaml one level above res://, so the function returns its ''?.?.?'' fallback. Every exported build therefore stamps and compares the SAME sentinel version, which means the schema-version invalidation path — the cache''s only invalidation signal — is inert in a shipped game: a canvas cached by one build is served forever by every later build. Found while diagnosing T-1239, where the same mechanism failed in dev for a different reason (the version simply was not bumped). Fix direction: bake the version into the client at export time (a generated const, ProjectSettings application/config/version, or an exported resource) rather than reading a repo file at runtime. Note test_current_schema_version_reads_project_yaml passes in dev and would not catch this — it asserts the non-fallback path, in the only environment where that path works.', 'in_progress', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:17.188', '2026-08-14 21:21:35.097', NULL, '0e36862b9cbd420d1f0d8114f7673cc9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0495WRHF8ADK82VR8CH1J8R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas disk cache never invalidates in an exported build — current_schema_version() falls back to ''?.?.?''', 'step_canvas_disk_cache.gd::current_schema_version() reads ProjectSettings.globalize_path(''res://'') + ''/../project.yaml''. That resolves to the repo-root file in a dev run (res:// = client/), but an exported build has no project.yaml one level above res://, so the function returns its ''?.?.?'' fallback. Every exported build therefore stamps and compares the SAME sentinel version, which means the schema-version invalidation path — the cache''s only invalidation signal — is inert in a shipped game: a canvas cached by one build is served forever by every later build. Found while diagnosing T-1239, where the same mechanism failed in dev for a different reason (the version simply was not bumped). Fix direction: bake the version into the client at export time (a generated const, ProjectSettings application/config/version, or an exported resource) rather than reading a repo file at runtime. Note test_current_schema_version_reads_project_yaml passes in dev and would not catch this — it asserts the non-fallback path, in the only environment where that path works.', 'done', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:17.188', '2026-08-14 21:35:35.623', NULL, '3bd4d5eaacf6a4792e00a644a3333535', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G04975H3S7GRVQXHKYCR7BKR', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Enforce the canvas-generation/project.yaml-version pairing — four silent stale-cache regressions and counting', 'project.yaml''s version is the Atlas disk cache''s only invalidation signal, and nothing enforces that a change to canvas GENERATION also moves it. The file''s own comment block now records four bumps forced after the fact by exactly this failure: 0.4.2 (lake_margin_q semantics), 0.4.3 (coast_warp_px at orbital sampling), 0.4.4 (D-255 extent inversion), 0.4.5 (Global sentinel), and now 0.4.6 (T-1237 one-course-per-river, diagnosed as T-1239 eight days after it shipped). The failure is silent and machine-dependent: it reproduces only where a warm cache exists, so the author with a cold checkout sees nothing wrong. Direction: a pre-push check in .config/hooks/pre-push — if the push touches the canvas-generation paths (server/src/atlas/step_canvas.rs, river_course.rs, layer1.rs, district_profile.rs, the client step_canvas layers) and project.yaml''s version line is unchanged in the same range, reject with the reason. Registry-driven like tooling/generator_sources.py rather than a hand-kept path list in the hook. A false positive is cheap (bump the version, entries miss once); a false negative is another week of a wrong map.', 'in_progress', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:27.624', '2026-08-14 21:41:02.683', NULL, 'efd83e7e6e689653b411d77629c19985', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G04975H3S7GRVQXHKYCR7BKR', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Enforce the canvas-generation/project.yaml-version pairing — four silent stale-cache regressions and counting', 'project.yaml''s version is the Atlas disk cache''s only invalidation signal, and nothing enforces that a change to canvas GENERATION also moves it. The file''s own comment block now records four bumps forced after the fact by exactly this failure: 0.4.2 (lake_margin_q semantics), 0.4.3 (coast_warp_px at orbital sampling), 0.4.4 (D-255 extent inversion), 0.4.5 (Global sentinel), and now 0.4.6 (T-1237 one-course-per-river, diagnosed as T-1239 eight days after it shipped). The failure is silent and machine-dependent: it reproduces only where a warm cache exists, so the author with a cold checkout sees nothing wrong. Direction: a pre-push check in .config/hooks/pre-push — if the push touches the canvas-generation paths (server/src/atlas/step_canvas.rs, river_course.rs, layer1.rs, district_profile.rs, the client step_canvas layers) and project.yaml''s version line is unchanged in the same range, reject with the reason. Registry-driven like tooling/generator_sources.py rather than a hand-kept path list in the hook. A false positive is cheap (bump the version, entries miss once); a false negative is another week of a wrong map.', 'in_progress', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:27.624', '2026-08-14 21:41:17.977', NULL, 'cb2da99199993e9c60322845ceaf22b8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G04975H3S7GRVQXHKYCR7BKR', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Enforce the canvas-generation/project.yaml-version pairing — four silent stale-cache regressions and counting', 'project.yaml''s version is the Atlas disk cache''s only invalidation signal, and nothing enforces that a change to canvas GENERATION also moves it. The file''s own comment block now records four bumps forced after the fact by exactly this failure: 0.4.2 (lake_margin_q semantics), 0.4.3 (coast_warp_px at orbital sampling), 0.4.4 (D-255 extent inversion), 0.4.5 (Global sentinel), and now 0.4.6 (T-1237 one-course-per-river, diagnosed as T-1239 eight days after it shipped). The failure is silent and machine-dependent: it reproduces only where a warm cache exists, so the author with a cold checkout sees nothing wrong. Direction: a pre-push check in .config/hooks/pre-push — if the push touches the canvas-generation paths (server/src/atlas/step_canvas.rs, river_course.rs, layer1.rs, district_profile.rs, the client step_canvas layers) and project.yaml''s version line is unchanged in the same range, reject with the reason. Registry-driven like tooling/generator_sources.py rather than a hand-kept path list in the hook. A false positive is cheap (bump the version, entries miss once); a false negative is another week of a wrong map.', 'done', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:27.624', '2026-08-14 22:06:55.412', NULL, 'eb7ca0296fcb5255bdb5cc94e66c45d5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TZC9MJV6KZTSRVRYQ327M', 'story', '06FT0TX2W0BA10PRR7NMJ2362M', 'Rung-0.5 expanded-layer generator (deterministic un-summarisation of rung 0)', 'Build the deterministic expansion from rung 0 (heightmap.png 1024x512 16-bit elevation + reliefmap.png 1024x512 colour biome) to rung 0.5, sized per D-258''s 2-px-per-gridunit-at-large-display rule. Same body + same seed must produce the same layer every time (byte-identical, per D-227''s determinism discipline extended by this carve-out). The reliefmap is a PLURALITY, not ground truth: each reliefmap cell already voted-and-discarded the dominant biome across ~38 km of ground, so this generator un-summarises it -- it does not upscale/interpolate it. Three binding invariants from D-258: (1) biome edges are gradients, never lines -- transitions blend so no boundary falls on a rung-0 cell edge (the D-243 climate edge-fuzz rule applied to biome); (2) descending the ladder reveals COMPOSITION not sharpness -- a cell reading ''forest'' globally must be able to contain clearings/marsh/rock/scrub the vote suppressed, emerging deterministically as the ladder descends; (3) CONSERVATIVE invention is the binding acceptance gate -- downsampling rung 0.5 must reproduce the rung-0 summary it came from (a forest cell may gain marsh pockets but must still read as forest from orbit). Blocked on T-1212 (cost/size measurement) landing first. Depends on: nothing else in this epic to start scaffolding, but hydrology (sibling ticket) and this generator are tightly coupled -- coordinate sequencing with whoever picks up hydrology. See governance/decisions/architecture.md#d-258.
---
UNBLOCKED 2026-08-06 (T-1211 re-scope, Jeroen''s call). The T-1212 blocker edge is
removed: that measurement priced a hydrology move that is not happening, and the
descent ladder showed there is no expansion artefact to measure yet anyway.
BUILD IT AS A PURE FUNCTION FIRST, not as a stored layer. The stored-layer half of
D-258 was materially weakened by its own 2026-07-27 amendment (the "not locally
computable" argument for the D-227 carve-out does not hold, because the whole-body
solve it cited already runs once per body in layer1.rs and is sampled at every
rung). So follow the mechanism that exists — D-255(f) mechanism B,
compute-once-sample-everywhere — measure it, and only argue for storage on those
numbers. Do NOT open with a cache.
WHAT "FLAT" MEANS CONCRETELY, so the fix has a target. Ferrath''s heightmap is
1024x512 over a 38,089 km circumference: 37.2 km per source pixel. Global draws at
35.267 km/gridunit, roughly 1:1 with the source, and reads as a real map. Region
draws at 0.379 km/gridunit — 98x finer than any stored datum — and is a single
uniform colour with dither. District is 0.0038 km/gridunit, ~9,800x finer, also
uniform. So the acceptance bar is not subtle: at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating composition
that still downsamples back to that pixel''s summary (D-258''s conservation
invariant, the binding gate).
VERIFY BY CAPTURE, NOT BY REASONING. The scenarios exist: atlas_GJ820Bc_land_*
(Region/District/Quarter/Block/Chunk, one land-anchored world point, no overlays)
in tests/visual.json. Captures run offscreen under gamescope at native 3440x1440
via tests/run-visual --screenshot <name> — they do not steal the desktop. Re-shoot
the ladder and look at it; a green unit test proves nothing here.
Note the ladder currently also reports courses=0 at every rung below Global —
rivers vanish on descent. That is tracked separately as T-1239 and is NOT this
ticket''s scope, but it will be visible in the same captures, so do not mistake it
for a failure of the un-summarisation work.
---
LADDER RE-SHOT COLD 2026-08-16 — the first ladder whose numbers can be trusted.
Every previous ladder, including the one this ticket''s re-scope was written from,
was captured against the developer''s persistent user:// cache (T-1239). The
harness now isolates user:// per capture, so these are fresh derives.
RESULT: the acceptance bar is NOT met. Region and below are still flat.
Flatness, measured rather than eyeballed (.cache/t1213_flatness.py samples the
terrain area, excluding the panels):
rung distinct R std G std B std lum p1-p99
Global 1581 24.05 35.75 36.21 145.69
Region 2923 4.80 8.31 4.19 33.59
District 53 2.56 4.90 2.56 13.72
Quarter 46 2.42 4.78 2.42 11.01
Note Region carries MORE distinct colours than Global (2923 vs 1581) while
holding a quarter of the structure. That is the dither/stipple pass adding colour
noise, not information — so distinct-count is precisely the wrong metric, and the
percentile luminance spread is the honest one. Structure falls ~92% from Global
to Quarter.
WHY IT IS FLAT — a channel mismatch, not a missing generator.
Composition IS working in the data. The conservation test, run explicitly against
real terrain, reports over a 2,048 m patch at the ladder''s own anchor:
conservation: majority class 3 at 98.9% across 2 classes {1: 175, 3: 16209}
So D-258 invariant 3 (conservation) holds and invariant 2 (composition) is
happening: 1.07% of the patch is a minority class.
But composition perturbs `moisture_q` and `slope_q`, which resolve into
`vegetation_class`. The base map draws neither. step_canvas_colorize.gd''s own
comment states it: "The base layer reads hue from morphology and lightness from
elev_q, and on a real body below Global that collapses: morphology resolves to
ONE zone". The ladder scenarios pass `[]` for overlays deliberately
(visual_scenarios.gd: "No overlays, deliberately: a colour ramp on top would mask
the very thing being judged"), so the composed fields are never rendered in the
shots that judge this work.
The un-summarisation lands in channels the default view does not draw. That is
why perfectly correct composition and a flat map coexist.
THE CONSERVATION TEST WAS NOT PROTECTING ANYTHING (fixed here).
Its "not a monoculture" assertion read:
assert!(tally.len() > 1 || share == 1.0, ...)
which is a TAUTOLOGY — a single-class tally has a 100% share by definition, so
both branches are always satisfiable and the check could never fail, including in
the exact case its message names ("or nothing was composed"). It was the only
thing standing behind invariant 2, and it stood behind nothing. Now split into
the two bounds the invariant actually has: majority > 50% (conservation, the
ceiling on invention) and minority >= 0.1% (composition, the floor). Measured
1.07% against a 0.1% floor.
It is also still `#[ignore]`d, so the binding acceptance gate for this epic runs
only when someone types it. Worth moving into a harness that runs — the
believability and window-derivation harnesses already load real bodies in the
normal `cargo test` path, so the precedent exists.
courses=0 BELOW GLOBAL IS NOT A BUG — it is arithmetic, and the ticket''s note
attributing it to T-1239 is superseded. Global now reports courses=73 drawn=18
(T-1239 fixed). Region/District/Quarter report courses=0 because the hydrology
network is 615 river cells on a 512x256 grid (74.4 km/cell) and a Region window
is 489 x 205 km = 0.0217% of a 462M km2 body: expected river cells in view = 615
x 0.000217 = 0.13. Zero is the likely outcome, not a failure. The real
observation underneath it is that 615 river cells on an Earth-sized body is a
sparse network (~45,000 km of total watercourse), which is a believability
question for hydrology, not a rendering one.
THE OPEN DECISION — three readings, materially different work:
(A) Make the DISPLAYED channels carry sub-source-pixel composition. The literal
reading of this ticket''s acceptance bar ("at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating
composition"). Means morphology and/or elev_q gain a fine tier, which is
D-227/D-258 territory and the largest change.
(B) Accept that the base view is morphology + elevation, and judge composition
through the overlays — i.e. the ladder gains VEG/MST companion shots. Test
change only, but it concedes that the default map stays flat below Global.
(C) Raise inclusion density so the existing vegetation texture reads at map
scale. Tuning only; 1.07% is near-invisible.
Not chosen here — it needs Jeroen''s call, and it touches D-258.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-16 10:32:31.457', NULL, '21d816226e574f3b6489810453220b5e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TZC9MJV6KZTSRVRYQ327M', 'story', '06FT0TX2W0BA10PRR7NMJ2362M', 'Rung-0.5 expanded-layer generator (deterministic un-summarisation of rung 0)', 'Build the deterministic expansion from rung 0 (heightmap.png 1024x512 16-bit elevation + reliefmap.png 1024x512 colour biome) to rung 0.5, sized per D-258''s 2-px-per-gridunit-at-large-display rule. Same body + same seed must produce the same layer every time (byte-identical, per D-227''s determinism discipline extended by this carve-out). The reliefmap is a PLURALITY, not ground truth: each reliefmap cell already voted-and-discarded the dominant biome across ~38 km of ground, so this generator un-summarises it -- it does not upscale/interpolate it. Three binding invariants from D-258: (1) biome edges are gradients, never lines -- transitions blend so no boundary falls on a rung-0 cell edge (the D-243 climate edge-fuzz rule applied to biome); (2) descending the ladder reveals COMPOSITION not sharpness -- a cell reading ''forest'' globally must be able to contain clearings/marsh/rock/scrub the vote suppressed, emerging deterministically as the ladder descends; (3) CONSERVATIVE invention is the binding acceptance gate -- downsampling rung 0.5 must reproduce the rung-0 summary it came from (a forest cell may gain marsh pockets but must still read as forest from orbit). Blocked on T-1212 (cost/size measurement) landing first. Depends on: nothing else in this epic to start scaffolding, but hydrology (sibling ticket) and this generator are tightly coupled -- coordinate sequencing with whoever picks up hydrology. See governance/decisions/architecture.md#d-258.
---
UNBLOCKED 2026-08-06 (T-1211 re-scope, Jeroen''s call). The T-1212 blocker edge is
removed: that measurement priced a hydrology move that is not happening, and the
descent ladder showed there is no expansion artefact to measure yet anyway.
BUILD IT AS A PURE FUNCTION FIRST, not as a stored layer. The stored-layer half of
D-258 was materially weakened by its own 2026-07-27 amendment (the "not locally
computable" argument for the D-227 carve-out does not hold, because the whole-body
solve it cited already runs once per body in layer1.rs and is sampled at every
rung). So follow the mechanism that exists — D-255(f) mechanism B,
compute-once-sample-everywhere — measure it, and only argue for storage on those
numbers. Do NOT open with a cache.
WHAT "FLAT" MEANS CONCRETELY, so the fix has a target. Ferrath''s heightmap is
1024x512 over a 38,089 km circumference: 37.2 km per source pixel. Global draws at
35.267 km/gridunit, roughly 1:1 with the source, and reads as a real map. Region
draws at 0.379 km/gridunit — 98x finer than any stored datum — and is a single
uniform colour with dither. District is 0.0038 km/gridunit, ~9,800x finer, also
uniform. So the acceptance bar is not subtle: at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating composition
that still downsamples back to that pixel''s summary (D-258''s conservation
invariant, the binding gate).
VERIFY BY CAPTURE, NOT BY REASONING. The scenarios exist: atlas_GJ820Bc_land_*
(Region/District/Quarter/Block/Chunk, one land-anchored world point, no overlays)
in tests/visual.json. Captures run offscreen under gamescope at native 3440x1440
via tests/run-visual --screenshot <name> — they do not steal the desktop. Re-shoot
the ladder and look at it; a green unit test proves nothing here.
Note the ladder currently also reports courses=0 at every rung below Global —
rivers vanish on descent. That is tracked separately as T-1239 and is NOT this
ticket''s scope, but it will be visible in the same captures, so do not mistake it
for a failure of the un-summarisation work.
---
LADDER RE-SHOT COLD 2026-08-16 — the first ladder whose numbers can be trusted.
Every previous ladder, including the one this ticket''s re-scope was written from,
was captured against the developer''s persistent user:// cache (T-1239). The
harness now isolates user:// per capture, so these are fresh derives.
RESULT: the acceptance bar is NOT met. Region and below are still flat.
Flatness, measured rather than eyeballed (.cache/t1213_flatness.py samples the
terrain area, excluding the panels):
rung distinct R std G std B std lum p1-p99
Global 1581 24.05 35.75 36.21 145.69
Region 2923 4.80 8.31 4.19 33.59
District 53 2.56 4.90 2.56 13.72
Quarter 46 2.42 4.78 2.42 11.01
Note Region carries MORE distinct colours than Global (2923 vs 1581) while
holding a quarter of the structure. That is the dither/stipple pass adding colour
noise, not information — so distinct-count is precisely the wrong metric, and the
percentile luminance spread is the honest one. Structure falls ~92% from Global
to Quarter.
WHY IT IS FLAT — a channel mismatch, not a missing generator.
Composition IS working in the data. The conservation test, run explicitly against
real terrain, reports over a 2,048 m patch at the ladder''s own anchor:
conservation: majority class 3 at 98.9% across 2 classes {1: 175, 3: 16209}
So D-258 invariant 3 (conservation) holds and invariant 2 (composition) is
happening: 1.07% of the patch is a minority class.
But composition perturbs `moisture_q` and `slope_q`, which resolve into
`vegetation_class`. The base map draws neither. step_canvas_colorize.gd''s own
comment states it: "The base layer reads hue from morphology and lightness from
elev_q, and on a real body below Global that collapses: morphology resolves to
ONE zone". The ladder scenarios pass `[]` for overlays deliberately
(visual_scenarios.gd: "No overlays, deliberately: a colour ramp on top would mask
the very thing being judged"), so the composed fields are never rendered in the
shots that judge this work.
The un-summarisation lands in channels the default view does not draw. That is
why perfectly correct composition and a flat map coexist.
THE CONSERVATION TEST WAS NOT PROTECTING ANYTHING (fixed here).
Its "not a monoculture" assertion read:
assert!(tally.len() > 1 || share == 1.0, ...)
which is a TAUTOLOGY — a single-class tally has a 100% share by definition, so
both branches are always satisfiable and the check could never fail, including in
the exact case its message names ("or nothing was composed"). It was the only
thing standing behind invariant 2, and it stood behind nothing. Now split into
the two bounds the invariant actually has: majority > 50% (conservation, the
ceiling on invention) and minority >= 0.1% (composition, the floor). Measured
1.07% against a 0.1% floor.
It is also still `#[ignore]`d, so the binding acceptance gate for this epic runs
only when someone types it. Worth moving into a harness that runs — the
believability and window-derivation harnesses already load real bodies in the
normal `cargo test` path, so the precedent exists.
courses=0 BELOW GLOBAL IS NOT A BUG — it is arithmetic, and the ticket''s note
attributing it to T-1239 is superseded. Global now reports courses=73 drawn=18
(T-1239 fixed). Region/District/Quarter report courses=0 because the hydrology
network is 615 river cells on a 512x256 grid (74.4 km/cell) and a Region window
is 489 x 205 km = 0.0217% of a 462M km2 body: expected river cells in view = 615
x 0.000217 = 0.13. Zero is the likely outcome, not a failure. The real
observation underneath it is that 615 river cells on an Earth-sized body is a
sparse network (~45,000 km of total watercourse), which is a believability
question for hydrology, not a rendering one.
THE OPEN DECISION — three readings, materially different work:
(A) Make the DISPLAYED channels carry sub-source-pixel composition. The literal
reading of this ticket''s acceptance bar ("at Region, ~98 gridunits across a
single source pixel must carry visible, deterministic, non-repeating
composition"). Means morphology and/or elev_q gain a fine tier, which is
D-227/D-258 territory and the largest change.
(B) Accept that the base view is morphology + elevation, and judge composition
through the overlays — i.e. the ladder gains VEG/MST companion shots. Test
change only, but it concedes that the default map stays flat below Global.
(C) Raise inclusion density so the existing vegetation texture reads at map
scale. Tuning only; 1.07% is near-invisible.
Not chosen here — it needs Jeroen''s call, and it touches D-258.
---
FIXED 2026-08-16 (option A). The un-summarisation was reaching the wire and
falling off it one line before the renderer.
ROOT CAUSE: `step_canvas_protocol.gd`''s decode dictionary listed every dense
plane EXCEPT relief_q. The server has encoded it since 5eb394b36
(step_canvas.rs:1234) and the terrain layer has asked for it by name ever since
(`canvas.get("relief_q")`), but the key was never put in the decoded dictionary,
so the plane arrived nowhere. The server half of relief_q landed; the protocol
half did not.
That is why the deep rungs were flat. relief_q is the ONE field with signal below
District — elev_q''s 80 m steps quantise sub-district detail away — and it was
absent. Measured plane variety at District, before: {morphology: 1, elev_q: 11,
relief_q: 0, moisture_q: 25, vegetation: 3}. A 0 there means ABSENT, not merely
constant; the distinction is what made this diagnosable, and it did not exist
until this session added the plane-variety readout.
TWO CHANGES:
1. Decode relief_q (one line, client/scripts/protocol/step_canvas_protocol.gd).
2. Shade with it, SIGNED and continuously (step_canvas_colorize.gd). It was
previously spent only as a stipple PROBABILITY, so a ridge and a plain
differed in dot density, which at one pixel per cell reads as noise rather
than landform — and `_ruggedness()` took absf(relief_q - 50), discarding the
sign the server deliberately preserved ("a hollow and a rise are different
ground"). Rises now lighten, hollows darken, under the stipple rather than
instead of it.
LADDER, before -> after (tooling/atlas-flatness, lum p1-p99):
Global 145.69 -> 145.69 unchanged, and correct: relief_q is flat 50 at
orbital rungs by construction
Region 33.59 -> 71.01 2.1x
District 13.72 -> 77.01 5.6x
Quarter 11.01 -> 42.56 3.9x
Structure retention Global->Quarter went from 7.6% to 29%. Plane variety after:
Region relief_q 101 values, District 91, Quarter 33.
HONEST STATE. The acceptance bar ("at Region, ~98 gridunits across a single
source pixel must carry visible, deterministic, non-repeating composition") is
MET. It is not yet cartographically finished: Region reads as heavy speckle
because ruggedness is now real data rather than an elev_q-gradient fallback, so
far more cells earn a stipple mark than the T-1194 tuning assumed (17,599
distinct colours there). District reads as soft blobby relief — form without
directionality, since the field is isotropic noise with no hillshade direction
and no drainage alignment. Both are tuning/grammar follow-ups on top of a channel
that now carries signal, not blockers.
REGRESSION COVER ADDED. Every protocol test passed throughout the weeks the plane
was missing, because each asserted a field it already knew about and none
asserted the SET. There is now a test that walks all eight dense planes of
EncodedStepCanvas and fails if any is absent from the decode — verified by
disabling the fix and watching it fail by name.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-16 13:05:35.162', NULL, 'e611e465ff1fd8de7b71b3de10702fec', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0P0F9C3RKKK23ZHHTANCPZW', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_visibility_texture_update_performance flakes under gate load — min-of-7 was not enough', 'Wall-clock perf assertion in client/tests/test_fog_shader.gd:213 (best-of-7 < 0.5 ms, D-059 budget) failed the pre-push gate on 2026-08-16 at 0.606 ms, then passed 23/23 in isolation minutes later on the same build. The failing run was a full-suite pass inside the hook, which runs AFTER the tooling suite and cargo checks, so every one of the seven samples was inflated together by a loaded machine. My manual full-suite run of the identical tree passed 1830/0. Nothing in the pushed change touches the fog path (Atlas protocol decode, colorize, terrain layer, tooling). This is the SECOND recorded hardening cycle for the same failure mode: the estimator is already min-of-7 (T-1210) after median-of-5 flaked three times on 2026-07-26 when a cargo build saturated the cores (0.549/0.503/0.638). Taking the minimum defends against ONE slow sample, not against sustained saturation, which is precisely the condition the gate creates by design. Direction: measure CPU time rather than wall clock (Time.get_ticks_usec measures elapsed, so it prices the scheduler in), or make the budget assertion load-aware, or move the perf assertions out of the correctness gate into a dedicated bench run where the machine is quiet. Do NOT simply raise the 0.5 threshold -- that discards the budget D-059 set without deciding to.', 'backlog', 'medium', NULL, 'client', NULL, '2026-08-16 14:37:49.792', '2026-08-16 14:37:49.792', NULL, '513d27f28c0ff1a50bd36692fb96fa46', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'MIN_WL_BANDS_M octave cutoffs are pre-extent-inversion (stale, currently inert)', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.
---
NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own
scope note says the stale cutoff "is NOT the cause of the flat District/Quarter
rungs". That was correct, and it is now the cause of something else: it caps
REGION.
With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and
driving a hillshade, Region renders as a fine uniform stucco — texture at the
pixel scale, no landform. District and Quarter, on the same code, read as real
terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30).
The reason is sampling, and it is this ticket: a Region cell is 379 m of ground,
while the relief field''s own content sits in the 128-1024 m composition band plus
192 m inclusions. The field is at or below Nyquist for that sampling, so every
neighbouring cell differs by an essentially independent draw and the gradient the
hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on
the served path, nothing truncates the octaves Region cannot resolve — which is
precisely the fix this ticket proposes ("derive the cutoff from the resolved
canvas spacing rather than the rung cell size").
So the visible symptom to fix against is now concrete: Region should show the
same kind of ridge-and-valley form District does, at its own scale, and it will
once the octaves below its sampling resolution are cut. The T-1213 work made this
observable; before it, relief_q never arrived and the rung was flat for a
different reason entirely.', 'backlog', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-16 16:23:17.190', NULL, '1ed56273a1d81dbb6d0e1614598f744b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'MIN_WL_BANDS_M octave cutoffs are pre-extent-inversion (stale, currently inert)', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.
---
NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own
scope note says the stale cutoff "is NOT the cause of the flat District/Quarter
rungs". That was correct, and it is now the cause of something else: it caps
REGION.
With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and
driving a hillshade, Region renders as a fine uniform stucco — texture at the
pixel scale, no landform. District and Quarter, on the same code, read as real
terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30).
The reason is sampling, and it is this ticket: a Region cell is 379 m of ground,
while the relief field''s own content sits in the 128-1024 m composition band plus
192 m inclusions. The field is at or below Nyquist for that sampling, so every
neighbouring cell differs by an essentially independent draw and the gradient the
hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on
the served path, nothing truncates the octaves Region cannot resolve — which is
precisely the fix this ticket proposes ("derive the cutoff from the resolved
canvas spacing rather than the rung cell size").
So the visible symptom to fix against is now concrete: Region should show the
same kind of ridge-and-valley form District does, at its own scale, and it will
once the octaves below its sampling resolution are cut. The T-1213 work made this
observable; before it, relief_q never arrived and the rung was flat for a
different reason entirely.', 'in_progress', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-17 07:08:40.539', NULL, '67165f7b4321d58fbb98b706b50e9058', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'MIN_WL_BANDS_M octave cutoffs are pre-extent-inversion (stale, currently inert)', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.
---
NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own
scope note says the stale cutoff "is NOT the cause of the flat District/Quarter
rungs". That was correct, and it is now the cause of something else: it caps
REGION.
With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and
driving a hillshade, Region renders as a fine uniform stucco — texture at the
pixel scale, no landform. District and Quarter, on the same code, read as real
terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30).
The reason is sampling, and it is this ticket: a Region cell is 379 m of ground,
while the relief field''s own content sits in the 128-1024 m composition band plus
192 m inclusions. The field is at or below Nyquist for that sampling, so every
neighbouring cell differs by an essentially independent draw and the gradient the
hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on
the served path, nothing truncates the octaves Region cannot resolve — which is
precisely the fix this ticket proposes ("derive the cutoff from the resolved
canvas spacing rather than the rung cell size").
So the visible symptom to fix against is now concrete: Region should show the
same kind of ridge-and-valley form District does, at its own scale, and it will
once the octaves below its sampling resolution are cut. The T-1213 work made this
observable; before it, relief_q never arrived and the rung was flat for a
different reason entirely.', 'in_progress', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-17 07:08:54.825', NULL, '4300fa437b31a5f8e2b89999c882fcd8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'MIN_WL_BANDS_M octave cutoffs are pre-extent-inversion (stale, currently inert)', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.
---
NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own
scope note says the stale cutoff "is NOT the cause of the flat District/Quarter
rungs". That was correct, and it is now the cause of something else: it caps
REGION.
With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and
driving a hillshade, Region renders as a fine uniform stucco — texture at the
pixel scale, no landform. District and Quarter, on the same code, read as real
terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30).
The reason is sampling, and it is this ticket: a Region cell is 379 m of ground,
while the relief field''s own content sits in the 128-1024 m composition band plus
192 m inclusions. The field is at or below Nyquist for that sampling, so every
neighbouring cell differs by an essentially independent draw and the gradient the
hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on
the served path, nothing truncates the octaves Region cannot resolve — which is
precisely the fix this ticket proposes ("derive the cutoff from the resolved
canvas spacing rather than the rung cell size").
So the visible symptom to fix against is now concrete: Region should show the
same kind of ridge-and-valley form District does, at its own scale, and it will
once the octaves below its sampling resolution are cut. The T-1213 work made this
observable; before it, relief_q never arrived and the rung was flat for a
different reason entirely.
---
CLOSED 2026-08-18 (9b146f9e1). The cutoff is derived from the resolved canvas
spacing, which is what this ticket asked for — but two of its own premises turned
out to be wrong, and both were corrected by measurement rather than argument.
WRONG PREMISE 1 — "the fix is Nyquist". The ticket reasons in Nyquist terms
("District''s true Nyquist floor is ~7.6 m"). Implemented at 2 x spacing, Region
barely moved: lum p1-p99 56.16 -> 59.73, and the relief field''s mean cell-to-cell
gradient stayed at 18.24 steps. Nyquist is the ALIASING limit — the point below
which a wave is indistinguishable from a slower one — not a legibility one. Two
samples per cycle is unaliased and still renders jagged.
The rungs that already read as terrain say what the real bar is: District
reconstructs its finest surviving octave at 34 samples per cycle (gradient 0.30)
and Quarter at 135 (0.07). Shipped at 8 samples per cycle
(SMOOTH_SAMPLES_PER_WAVELENGTH), which puts Region at 1.08 gradient and 70.01
spread — the same order as the rungs that work.
WRONG PREMISE 2 — my own, in the 2026-08-16 note above: I wrote that the stucco
was the relief field aliasing, then after the 2x attempt failed I blamed the
CLIENT stipple instead. Both were guesses. Surfacing the terrain layer''s own mean
|relief_q gradient| in the capture readout answered it in one shot: 18.24 steps
per cell at Region means 144 m of relief between NEIGHBOURING cells. The server
was sending noise; the stipple was innocent. That diagnostic ships with the fix,
for the same reason `plane_variety` shipped with T-1213 — a noisy field and a
renderer inventing noise look identical, and one number separates them.
THE TRADE, taken deliberately. An 8x floor also truncates the coast warp''s 2,048
and 1,024 m octaves at Region — the band T-1160 added for "one coastline at every
rung". A test here asserted that band must survive; it now asserts the opposite,
with the reasoning recorded in the test body: a 1,024 m coastline wiggle at
379.3 m per cell is 2.7 samples per cycle, so drawing it draws noise rather than
coastline character, and a rung cannot show shape finer than its own cell. The
warp is amplitude-capped sub-pixel on the working grid, so the loss is small.
Reversal path, if a future pass disagrees: a relief-only floor threaded through
derive_at_metres — NOT a lower multiple, which takes the stucco back.
SCOPE ACTUALLY TOUCHED. Only the step-canvas served path. layer_proxy''s
MIN_WL_BANDS_M, its const assert and its cache key are UNCHANGED — the ticket
warned that a change there is not local, and none was needed: the fix is a
spacing-derived floor at the canvas builder, a pure function of (rung, extent,
body_radius), all three already in the step-canvas cache key. Global is exempt
(its ~70 km floor would silence the whole warp band, and its orbital derive
leaves relief_q flat at 50). District and Quarter floor below every octave in
play and derive byte-identically — pinned by
`floor_is_inert_at_the_rungs_that_already_resolve_their_band`, and confirmed by
the derivation/believability goldens passing untouched.
LEFT OPEN, not part of this ticket: Quarter''s contour banding (the stipple
lattice quantising the hillshade into dotted rings), and MIN_WL_BANDS_M itself,
which remains stale for the legacy layer_proxy district-window consumer. Neither
blocks the ladder.', 'in_progress', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-18 18:48:58.815', NULL, '7594ac8497738d4474acf52db20a23fc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'MIN_WL_BANDS_M octave cutoffs are pre-extent-inversion (stale, currently inert)', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.
---
NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own
scope note says the stale cutoff "is NOT the cause of the flat District/Quarter
rungs". That was correct, and it is now the cause of something else: it caps
REGION.
With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and
driving a hillshade, Region renders as a fine uniform stucco — texture at the
pixel scale, no landform. District and Quarter, on the same code, read as real
terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30).
The reason is sampling, and it is this ticket: a Region cell is 379 m of ground,
while the relief field''s own content sits in the 128-1024 m composition band plus
192 m inclusions. The field is at or below Nyquist for that sampling, so every
neighbouring cell differs by an essentially independent draw and the gradient the
hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on
the served path, nothing truncates the octaves Region cannot resolve — which is
precisely the fix this ticket proposes ("derive the cutoff from the resolved
canvas spacing rather than the rung cell size").
So the visible symptom to fix against is now concrete: Region should show the
same kind of ridge-and-valley form District does, at its own scale, and it will
once the octaves below its sampling resolution are cut. The T-1213 work made this
observable; before it, relief_q never arrived and the rung was flat for a
different reason entirely.
---
CLOSED 2026-08-18 (9b146f9e1). The cutoff is derived from the resolved canvas
spacing, which is what this ticket asked for — but two of its own premises turned
out to be wrong, and both were corrected by measurement rather than argument.
WRONG PREMISE 1 — "the fix is Nyquist". The ticket reasons in Nyquist terms
("District''s true Nyquist floor is ~7.6 m"). Implemented at 2 x spacing, Region
barely moved: lum p1-p99 56.16 -> 59.73, and the relief field''s mean cell-to-cell
gradient stayed at 18.24 steps. Nyquist is the ALIASING limit — the point below
which a wave is indistinguishable from a slower one — not a legibility one. Two
samples per cycle is unaliased and still renders jagged.
The rungs that already read as terrain say what the real bar is: District
reconstructs its finest surviving octave at 34 samples per cycle (gradient 0.30)
and Quarter at 135 (0.07). Shipped at 8 samples per cycle
(SMOOTH_SAMPLES_PER_WAVELENGTH), which puts Region at 1.08 gradient and 70.01
spread — the same order as the rungs that work.
WRONG PREMISE 2 — my own, in the 2026-08-16 note above: I wrote that the stucco
was the relief field aliasing, then after the 2x attempt failed I blamed the
CLIENT stipple instead. Both were guesses. Surfacing the terrain layer''s own mean
|relief_q gradient| in the capture readout answered it in one shot: 18.24 steps
per cell at Region means 144 m of relief between NEIGHBOURING cells. The server
was sending noise; the stipple was innocent. That diagnostic ships with the fix,
for the same reason `plane_variety` shipped with T-1213 — a noisy field and a
renderer inventing noise look identical, and one number separates them.
THE TRADE, taken deliberately. An 8x floor also truncates the coast warp''s 2,048
and 1,024 m octaves at Region — the band T-1160 added for "one coastline at every
rung". A test here asserted that band must survive; it now asserts the opposite,
with the reasoning recorded in the test body: a 1,024 m coastline wiggle at
379.3 m per cell is 2.7 samples per cycle, so drawing it draws noise rather than
coastline character, and a rung cannot show shape finer than its own cell. The
warp is amplitude-capped sub-pixel on the working grid, so the loss is small.
Reversal path, if a future pass disagrees: a relief-only floor threaded through
derive_at_metres — NOT a lower multiple, which takes the stucco back.
SCOPE ACTUALLY TOUCHED. Only the step-canvas served path. layer_proxy''s
MIN_WL_BANDS_M, its const assert and its cache key are UNCHANGED — the ticket
warned that a change there is not local, and none was needed: the fix is a
spacing-derived floor at the canvas builder, a pure function of (rung, extent,
body_radius), all three already in the step-canvas cache key. Global is exempt
(its ~70 km floor would silence the whole warp band, and its orbital derive
leaves relief_q flat at 50). District and Quarter floor below every octave in
play and derive byte-identically — pinned by
`floor_is_inert_at_the_rungs_that_already_resolve_their_band`, and confirmed by
the derivation/believability goldens passing untouched.
LEFT OPEN, not part of this ticket: Quarter''s contour banding (the stipple
lattice quantising the hillshade into dotted rings), and MIN_WL_BANDS_M itself,
which remains stale for the legacy layer_proxy district-window consumer. Neither
blocks the ladder.', 'done', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-18 18:49:07.176', NULL, 'b79d8865e3da7d586bf8c724de2d34b2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1R8WZ987YCP1ARANSA70R0W', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Seed variance: 68% of bodies are tectonics:low — is the categorical monoculture intended?', 'Measured 2026-08-20 over all 2,625 body definitions in wiki/star-systems/*/bodies/*/index.md. The seed''s CONTINUOUS axes vary richly: seed is unique per body, distance_au 716 distinct values, polar_ice_lat 691, axial_tilt_deg 578, land_fraction 460. The CATEGORICAL axes do not: tectonics 4 values with 67.8% ''low'', atmosphere 4 with 66.3% ''none'', planet_class 13 with 51.1% ''frozen'', substrate 3 with 51.1% ''ice'', geothermal_flux 3 with 91.2% ''low''. Those categoricals are what gate the morphology and biome branches, so a mostly-low-tectonics world is mostly FLAT BY THE SEED, before any rendering question. This matters because a full session (2026-08-16..18) went into making the Atlas render terrain legibly at the deep rungs, and composition.rs already states the honest position: ''where the terrain is flat, flat is the honest answer, and the morphology variety visible at Global comes from places that actually have slope''. If two thirds of bodies genuinely have no slope, there is a ceiling on what renderer work can deliver and it is set in the seed. QUESTION TO ANSWER FIRST, before any change: is the distribution intended? A galaxy where most worlds are dull and the interesting ones stand out is a legitimate and probably good design; an authoring pass that defaulted tectonics to ''low'' and moved on is not. Check how the values were produced (scaffold_bodies.py / the authoring pass) before touching them. NOT a defect report -- two things that looked like defects in the same measurement turned out deliberate (chemosynthetic:false is a namespace reservation for dextro-DNA biochemistry once geology and nature spawn to the 1x1m pixel; enabled:false on ~65% is staged rollout, clean planet types first then generator scripts for the others). Method caveat: the aggregator reads scalar frontmatter only, so list-valued fields are unreliable -- atmosphere_color first read as ''100% null'' and that was a parser artefact. Full measurement and method: docs/wiki-structure-findings.md.', 'backlog', 'medium', NULL, 'server', NULL, '2026-08-19 22:28:11.722', '2026-08-19 22:28:11.722', NULL, 'd5f89318c9d9845ae46aa165a731821f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1RB7GSJA4379EAQDZPQQDN8', 'initiative', NULL, 'Roadmap that carries intent — where the game is going, not just what order the work runs in', 'The project has a work breakdown and calls it a roadmap. The cascade owns ORDER, the ticket tree owns DECOMPOSITION, the DQR tree owns individual RULINGS — and none of them answer what the game is going to be, what the big unsolved problems are, or what is deliberately deferred. Pick-up instructions are in the appended body: the first pass is harvest/interview/investigate-form/propose-with-options, and EPICS ARE AN OUTPUT of that pass, not an input. Do not open this by inventing an epic list.', 'backlog', 'high', NULL, 'meta', NULL, '2026-08-19 22:38:22.412', '2026-08-19 22:38:22.412', NULL, 'ec77d9e27fa2cad2c124a8ae196e11d4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1RB7GSJA4379EAQDZPQQDN8', 'initiative', NULL, 'Roadmap that carries intent — where the game is going, not just what order the work runs in', 'The project has a work breakdown and calls it a roadmap. The cascade owns ORDER, the ticket tree owns DECOMPOSITION, the DQR tree owns individual RULINGS — and none of them answer what the game is going to be, what the big unsolved problems are, or what is deliberately deferred. Pick-up instructions are in the appended body: the first pass is harvest/interview/investigate-form/propose-with-options, and EPICS ARE AN OUTPUT of that pass, not an input. Do not open this by inventing an epic list.
---
WHY THIS EXISTS
The project has a work breakdown and calls it a roadmap. It is not one.
What exists today, and what each is good at:
- The Development Cascade (T-745 -> six phase epics, D-166): strict ORDER. It
says what must be finished before what, and it is enforced. It does not say
what the thing being built is, or why a phase is worth its cost.
- The ticket tree (initiative/epic/story/task): decomposition of work already
decided on. It answers "what is left in this batch", never "is this batch the
right thing".
- The DQR tree (governance/decisions|questions|rejected): individual rulings with
rationale, each excellent in isolation. 260+ D-records do not compose into a
direction; a reader can know every decision and still not know where the game
is going.
- Briefings, workshops, discussions: rich, but point-in-time and per-agent.
None of these answer the questions someone actually asks about a roadmap: what is
this game going to BE, what are the big unsolved problems between here and that,
in what order do they unlock each other, what does "done" look like for each, and
what is deliberately not being solved yet.
THE EVIDENCE THAT THIS IS A REAL GAP, not a tidiness urge:
On 2026-08-20, in one conversation, three roadmap-level facts surfaced that exist
in NO artefact in this repo:
1. The economics tree is not just trade-sim data — once geology and nature
spawn to the 1x1 m pixel, the economics information GENERATES WORLD CONTENT.
Production chains decide what is physically on the ground.
2. `chemosynthetic: false` on every body is a namespace RESERVATION for
dextro-DNA-style biochemistry at that same future tier — not a dead field.
3. `enabled: false` on ~65% of bodies is staged rollout: learn the clean planet
types first, then add generator scripts for the other types and the playable
count rises.
Two of those three were written up as suspected DEFECTS by an agent reading the
repo carefully, because nothing recorded them as intent. That is the cost, and it
recurs: the roadmap lives in one person''s head and leaks out only when someone
happens to ask the right question.
WHAT THIS INITIATIVE IS FOR
Produce a roadmap artefact that carries INTENT and SHAPE, in a form that survives
being read by someone (or something) with no memory of the conversations that
produced it — and that stays honest as the project moves.
Explicitly NOT: another ticket hierarchy, a Gantt chart, dates, or a restatement
of the cascade. The cascade already owns order; this owns meaning.
HOW TO PICK THIS UP — READ THIS BEFORE CREATING ANY EPICS
The first work is INVESTIGATION AND PLANNING, not implementation, and not
epic-cutting. Do not open this initiative by inventing a list of epics; the epic
list is an OUTPUT of the first pass, not an input to it. Sequence:
1. HARVEST. Mine what already exists for roadmap-shaped content that was never
collected: docs/workshops/, docs/discussions/, docs/design/, the briefings,
the D-records'' "why" sections, the phase-epic descriptions, and the
conversational hints that only appear in commit messages and ticket notes.
Expect the good material to exist and be scattered — this project documents
heavily. The gap is composition, not absence.
2. INTERVIEW. Whatever the harvest cannot answer, ask Jeroen — directly and in
batches. The three facts above emerged from ordinary questions; assume more
are waiting behind questions nobody has asked. Record the answers as they
are given, before interpreting them.
3. INVESTIGATE FORM. Do not assume markdown-in-docs/ is right. Options worth
weighing: a single narrative document; a per-phase "what this buys and why"
layer attached to the existing phase epics; a D-record class for direction
rather than decisions; something queryable through pql alongside the DQR
tree; a visual map. Judge each against the failure mode this exists to
prevent — a reader who knows every decision and still cannot state the
direction — and against staying current without ceremony.
4. PROPOSE, WITH OPTIONS. Bring 2-3 concrete shapes with trade-offs and a
recommendation. This is a design decision about the project''s own memory;
it deserves a D-record and Jeroen''s ruling, not an agent''s unilateral pick.
5. ONLY THEN CUT EPICS, against the chosen shape and what the harvest showed is
missing. Epics come last because the work is unknown until steps 1-4 have
run.
ACCEPTANCE — the artefact earns its keep if:
- A fresh session can read it and state, without asking, what the game is
trying to be and what the next three big problems are.
- Facts of the kind listed above (forward reservations, staged gates, "this
system is really for that future thing") have an obvious home, so the next
one gets written down instead of surfacing years later in a chat.
- It is cheap enough to keep current that it actually gets updated. A roadmap
that rots is worse than none, because it lies with authority.
- It does not duplicate the cascade, the ticket tree, or the DQR records. If a
section restates any of those, it belongs there instead.
RELATED: D-166 (cascade order), T-745 (the cascade initiative), and this
session''s docs/wiki-structure-findings.md, whose root-cause finding was the same
shape — the information existed and nothing pointed at it.', 'backlog', 'high', NULL, 'meta', NULL, '2026-08-19 22:38:22.412', '2026-08-19 22:38:30.454', NULL, 'f787c3a0250b47f88079efda1b22bb97', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1RB7GSJA4379EAQDZPQQDN8', 'initiative', NULL, 'Roadmap that carries intent — where the game is going, not just what order the work runs in', 'The project has a work breakdown and calls it a roadmap. The cascade owns ORDER, the ticket tree owns DECOMPOSITION, the DQR tree owns individual RULINGS — and none of them answer what the game is going to be, what the big unsolved problems are, or what is deliberately deferred. Pick-up instructions are in the appended body: the first pass is harvest/interview/investigate-form/propose-with-options, and EPICS ARE AN OUTPUT of that pass, not an input. Do not open this by inventing an epic list.
---
WHY THIS EXISTS
The project has a work breakdown and calls it a roadmap. It is not one.
What exists today, and what each is good at:
- The Development Cascade (T-745 -> six phase epics, D-166): strict ORDER. It
says what must be finished before what, and it is enforced. It does not say
what the thing being built is, or why a phase is worth its cost.
- The ticket tree (initiative/epic/story/task): decomposition of work already
decided on. It answers "what is left in this batch", never "is this batch the
right thing".
- The DQR tree (governance/decisions|questions|rejected): individual rulings with
rationale, each excellent in isolation. 260+ D-records do not compose into a
direction; a reader can know every decision and still not know where the game
is going.
- Briefings, workshops, discussions: rich, but point-in-time and per-agent.
None of these answer the questions someone actually asks about a roadmap: what is
this game going to BE, what are the big unsolved problems between here and that,
in what order do they unlock each other, what does "done" look like for each, and
what is deliberately not being solved yet.
THE EVIDENCE THAT THIS IS A REAL GAP, not a tidiness urge:
On 2026-08-20, in one conversation, three roadmap-level facts surfaced that exist
in NO artefact in this repo:
1. The economics tree is not just trade-sim data — once geology and nature
spawn to the 1x1 m pixel, the economics information GENERATES WORLD CONTENT.
Production chains decide what is physically on the ground.
2. `chemosynthetic: false` on every body is a namespace RESERVATION for
dextro-DNA-style biochemistry at that same future tier — not a dead field.
3. `enabled: false` on ~65% of bodies is staged rollout: learn the clean planet
types first, then add generator scripts for the other types and the playable
count rises.
Two of those three were written up as suspected DEFECTS by an agent reading the
repo carefully, because nothing recorded them as intent. That is the cost, and it
recurs: the roadmap lives in one person''s head and leaks out only when someone
happens to ask the right question.
WHAT THIS INITIATIVE IS FOR
Produce a roadmap artefact that carries INTENT and SHAPE, in a form that survives
being read by someone (or something) with no memory of the conversations that
produced it — and that stays honest as the project moves.
Explicitly NOT: another ticket hierarchy, a Gantt chart, dates, or a restatement
of the cascade. The cascade already owns order; this owns meaning.
HOW TO PICK THIS UP — READ THIS BEFORE CREATING ANY EPICS
The first work is INVESTIGATION AND PLANNING, not implementation, and not
epic-cutting. Do not open this initiative by inventing a list of epics; the epic
list is an OUTPUT of the first pass, not an input to it. Sequence:
1. HARVEST. Mine what already exists for roadmap-shaped content that was never
collected: docs/workshops/, docs/discussions/, docs/design/, the briefings,
the D-records'' "why" sections, the phase-epic descriptions, and the
conversational hints that only appear in commit messages and ticket notes.
Expect the good material to exist and be scattered — this project documents
heavily. The gap is composition, not absence.
2. INTERVIEW. Whatever the harvest cannot answer, ask Jeroen — directly and in
batches. The three facts above emerged from ordinary questions; assume more
are waiting behind questions nobody has asked. Record the answers as they
are given, before interpreting them.
3. INVESTIGATE FORM. Do not assume markdown-in-docs/ is right. Options worth
weighing: a single narrative document; a per-phase "what this buys and why"
layer attached to the existing phase epics; a D-record class for direction
rather than decisions; something queryable through pql alongside the DQR
tree; a visual map. Judge each against the failure mode this exists to
prevent — a reader who knows every decision and still cannot state the
direction — and against staying current without ceremony.
4. PROPOSE, WITH OPTIONS. Bring 2-3 concrete shapes with trade-offs and a
recommendation. This is a design decision about the project''s own memory;
it deserves a D-record and Jeroen''s ruling, not an agent''s unilateral pick.
5. ONLY THEN CUT EPICS, against the chosen shape and what the harvest showed is
missing. Epics come last because the work is unknown until steps 1-4 have
run.
ACCEPTANCE — the artefact earns its keep if:
- A fresh session can read it and state, without asking, what the game is
trying to be and what the next three big problems are.
- Facts of the kind listed above (forward reservations, staged gates, "this
system is really for that future thing") have an obvious home, so the next
one gets written down instead of surfacing years later in a chat.
- It is cheap enough to keep current that it actually gets updated. A roadmap
that rots is worse than none, because it lies with authority.
- It does not duplicate the cascade, the ticket tree, or the DQR records. If a
section restates any of those, it belongs there instead.
RELATED: D-166 (cascade order), T-745 (the cascade initiative), and this
session''s docs/wiki-structure-findings.md, whose root-cause finding was the same
shape — the information existed and nothing pointed at it.
---
THE MECHANISM THAT CREATES THIS GAP, from Jeroen (2026-08-20):
"I tend to restrict future side quests to not confuse your context. I should have
you file tickets at least."
That is the root cause, and it is a REASONABLE practice producing a bad outcome.
Withholding forward-looking intent keeps a working session focused — a real
benefit, not a mistake. But the intent then exists only in conversation, and
conversations are not artefacts. Every fact in the list above reached this repo
by accident: someone happened to ask, on a day when it happened to be relevant.
The resolution is not "share everything" — that would trade focus for memory.
It is to SEPARATE THE TWO CHANNELS:
- Working context stays narrow. Unchanged.
- Forward intent gets FILED, not discussed. A ticket, a note on an existing
ticket, or a roadmap entry — written at the moment it surfaces, without
pulling the current task sideways.
So whatever form this initiative lands on must be CHEAP TO APPEND TO from inside
an unrelated session. If recording "this field is reserved for a future tier"
requires opening a planning ritual, it will not happen while someone is midway
through a rendering bug — which is exactly when these facts surface.
Design implication, carried into step 3 (INVESTIGATE FORM): weight the options by
cost-to-append, not just by cost-to-read. An artefact that is pleasant to read
and expensive to update will rot, and this project already has heavily-documented
trees that stayed accurate precisely because updating them was one line.', 'backlog', 'high', NULL, 'meta', NULL, '2026-08-19 22:38:22.412', '2026-08-19 22:39:34.248', NULL, '69d785aeb9a77c4c9296c6b404237dfb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1R8WZ987YCP1ARANSA70R0W', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Seed variance: 68% of bodies are tectonics:low — is the categorical monoculture intended?', 'Measured 2026-08-20 over all 2,625 body definitions in wiki/star-systems/*/bodies/*/index.md. The seed''s CONTINUOUS axes vary richly: seed is unique per body, distance_au 716 distinct values, polar_ice_lat 691, axial_tilt_deg 578, land_fraction 460. The CATEGORICAL axes do not: tectonics 4 values with 67.8% ''low'', atmosphere 4 with 66.3% ''none'', planet_class 13 with 51.1% ''frozen'', substrate 3 with 51.1% ''ice'', geothermal_flux 3 with 91.2% ''low''. Those categoricals are what gate the morphology and biome branches, so a mostly-low-tectonics world is mostly FLAT BY THE SEED, before any rendering question. This matters because a full session (2026-08-16..18) went into making the Atlas render terrain legibly at the deep rungs, and composition.rs already states the honest position: ''where the terrain is flat, flat is the honest answer, and the morphology variety visible at Global comes from places that actually have slope''. If two thirds of bodies genuinely have no slope, there is a ceiling on what renderer work can deliver and it is set in the seed. QUESTION TO ANSWER FIRST, before any change: is the distribution intended? A galaxy where most worlds are dull and the interesting ones stand out is a legitimate and probably good design; an authoring pass that defaulted tectonics to ''low'' and moved on is not. Check how the values were produced (scaffold_bodies.py / the authoring pass) before touching them. NOT a defect report -- two things that looked like defects in the same measurement turned out deliberate (chemosynthetic:false is a namespace reservation for dextro-DNA biochemistry once geology and nature spawn to the 1x1m pixel; enabled:false on ~65% is staged rollout, clean planet types first then generator scripts for the others). Method caveat: the aggregator reads scalar frontmatter only, so list-valued fields are unreliable -- atmosphere_color first read as ''100% null'' and that was a parser artefact. Full measurement and method: docs/wiki-structure-findings.md.
---
MATERIAL CORRECTION (2026-08-20, same day): tectonics is DERIVED, not authored.
`tooling/planet-gen/body_definition_parser.py:563` reads
`tectonics = tectonic_map.get(planet_class, "low")`, with an override hook on the
next line. So the "67.8% tectonics: low" measured above is not an authoring
decision at all — it is a projection of the planet_class distribution (51.1%
frozen, and whatever else maps to low) through a fixed lookup, plus a default of
"low" for any class not in the map.
That changes the question this ticket asks. It is NOT "did the authoring pass
default tectonics and move on". It is:
1. Is the PLANET_CLASS distribution intended? That is the real authored axis,
and it is where variance would have to come from.
2. Is the class -> tectonics map right? A lookup that sends most classes to
"low", plus a "low" default for unmapped classes, concentrates the output
further than the input. Check the map''s spread before blaming the data.
3. Is the override hook used at all? If no body sets it, the map is the whole
story.
Found by a cold-context agent reading body_definition_parser.py while testing
Skill(wiki); verified directly afterwards. Worth noting how it was missed: the
original measurement scanned frontmatter VALUES and never asked where they came
from, so a derived field was read as an authored one. Same class of error as the
scalar-only parser caveat already recorded — a scan tells you what a field says,
never who decided it.', 'backlog', 'medium', NULL, 'server', NULL, '2026-08-19 22:28:11.722', '2026-08-19 22:45:45.557', NULL, '7a1737a72bba4913d5e8f5fa78e44312', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1R8WZ987YCP1ARANSA70R0W', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Seed variance: 68% of bodies are tectonics:low — is the categorical monoculture intended?', 'Measured 2026-08-20 over all 2,625 body definitions in wiki/star-systems/*/bodies/*/index.md. The seed''s CONTINUOUS axes vary richly: seed is unique per body, distance_au 716 distinct values, polar_ice_lat 691, axial_tilt_deg 578, land_fraction 460. The CATEGORICAL axes do not: tectonics 4 values with 67.8% ''low'', atmosphere 4 with 66.3% ''none'', planet_class 13 with 51.1% ''frozen'', substrate 3 with 51.1% ''ice'', geothermal_flux 3 with 91.2% ''low''. Those categoricals are what gate the morphology and biome branches, so a mostly-low-tectonics world is mostly FLAT BY THE SEED, before any rendering question. This matters because a full session (2026-08-16..18) went into making the Atlas render terrain legibly at the deep rungs, and composition.rs already states the honest position: ''where the terrain is flat, flat is the honest answer, and the morphology variety visible at Global comes from places that actually have slope''. If two thirds of bodies genuinely have no slope, there is a ceiling on what renderer work can deliver and it is set in the seed. QUESTION TO ANSWER FIRST, before any change: is the distribution intended? A galaxy where most worlds are dull and the interesting ones stand out is a legitimate and probably good design; an authoring pass that defaulted tectonics to ''low'' and moved on is not. Check how the values were produced (scaffold_bodies.py / the authoring pass) before touching them. NOT a defect report -- two things that looked like defects in the same measurement turned out deliberate (chemosynthetic:false is a namespace reservation for dextro-DNA biochemistry once geology and nature spawn to the 1x1m pixel; enabled:false on ~65% is staged rollout, clean planet types first then generator scripts for the others). Method caveat: the aggregator reads scalar frontmatter only, so list-valued fields are unreliable -- atmosphere_color first read as ''100% null'' and that was a parser artefact. Full measurement and method: docs/wiki-structure-findings.md.
---
MATERIAL CORRECTION (2026-08-20, same day): tectonics is DERIVED, not authored.
`tooling/planet-gen/body_definition_parser.py:563` reads
`tectonics = tectonic_map.get(planet_class, "low")`, with an override hook on the
next line. So the "67.8% tectonics: low" measured above is not an authoring
decision at all — it is a projection of the planet_class distribution (51.1%
frozen, and whatever else maps to low) through a fixed lookup, plus a default of
"low" for any class not in the map.
That changes the question this ticket asks. It is NOT "did the authoring pass
default tectonics and move on". It is:
1. Is the PLANET_CLASS distribution intended? That is the real authored axis,
and it is where variance would have to come from.
2. Is the class -> tectonics map right? A lookup that sends most classes to
"low", plus a "low" default for unmapped classes, concentrates the output
further than the input. Check the map''s spread before blaming the data.
3. Is the override hook used at all? If no body sets it, the map is the whole
story.
Found by a cold-context agent reading body_definition_parser.py while testing
Skill(wiki); verified directly afterwards. Worth noting how it was missed: the
original measurement scanned frontmatter VALUES and never asked where they came
from, so a derived field was read as an authored one. Same class of error as the
scalar-only parser caveat already recorded — a scan tells you what a field says,
never who decided it.
---
THE VARIANCE SPLIT IS NOW FULLY EXPLAINED (2026-08-20, after reading
body_definition_parser.py and scaffold_bodies.py properly rather than grepping
them).
Body frontmatter is written once by `scaffold_bodies.py`, which reads the SYSTEM
page''s Celestial Bodies table and resolves each field through a documented
priority order (body_definition_parser.py header):
1. override dict (hand-authored, special cases like Sol)
2. direct read (the field is verbatim in the bodies table)
3. derived (computed — e.g. tectonics = lookup off planet_class)
4. inferred (implied by a combination of fields)
5. randomised (SEEDED, within planet-class constraints — the "rand"
sentinel)
That resolves the measurement in this ticket exactly:
- The CONTINUOUS axes vary richly (seed unique, distance_au 716 distinct,
axial_tilt_deg 578, land_fraction 460) because they fall through to tier 5
and are seeded-random per body.
- The CATEGORICAL axes are concentrated because they come from tier 2 — read
from the bodies table, i.e. from systems.db — and tier 3 derives from them.
So the variance question does not belong to the wiki at all. It belongs to the
BODIES CATALOG in systems.db, authored through the atlas CLI, and to the
class->tectonics map. Whoever picks this up should start at
`tooling/atlas add-body` / `author-system` and the catalog''s planet_class
distribution, NOT at the wiki pages, which are a faithful projection of it.
Second-order note worth keeping: this ticket has now been corrected twice from
the same root cause — measuring VALUES without reading the CODE that produced
them. First correction: tectonics is derived. Second: the whole continuous/
categorical split is an artefact of the resolution tiers. Both were available in
a docstring the original pass grepped but never read.', 'backlog', 'medium', NULL, 'server', NULL, '2026-08-19 22:28:11.722', '2026-08-19 22:52:46.636', NULL, '65db5461d46f8f84aed68526113025a6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1RWR7M1H1PC3TY2RT7AC174', 'story', '06FBPPMZNNEV052DBYYY3A897C', 'Investigate retooling Python tooling into a Rust CLI (Q-124)', 'Answer Q-124. Investigation and costing FIRST — do not open with a rewrite.
Price the cheap alternative before recommending Rust: a single Python dispatcher entrypoint (one command, many subcommands) would resolve the permission-prompt friction and most of the startup cost without touching the numerics. If that is enough, say so and close Q-124 with it.
Scope order if Rust is chosen: port the check/gate family first (check-client-version, check-canvas-version, check-systems-db-stamp, check-dataflow-graph) — small, pure, no numerics, highest prompt-friction, runs on every push. Leave planet-gen numerics (numpy/scipy/PIL) in Python behind a stable interface.
Hard constraint: import_economics is stamped by source SHA via tooling/generator_sources.py. Any port must keep the stamp/pre-push contract intact through the transition, not disabled during it.
Outcome: a recommendation with costs, then a D-record and Jeroen''s ruling. Epics only after that.', 'backlog', 'low', NULL, 'tooling', 'Q-124', '2026-08-19 23:54:55.776', '2026-08-19 23:54:55.776', NULL, 'cfb08832c72e06f85b075a46cd3223f2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1RWR7M1H1PC3TY2RT7AC174', 'story', '06FBPPMZNNEV052DBYYY3A897C', 'Investigate retooling Python tooling into a Rust CLI (Q-124)', 'Answer Q-124. Investigation and costing FIRST — do not open with a rewrite.
Price the cheap alternative before recommending Rust: a single Python dispatcher entrypoint (one command, many subcommands) would resolve the permission-prompt friction and most of the startup cost without touching the numerics. If that is enough, say so and close Q-124 with it.
Scope order if Rust is chosen: port the check/gate family first (check-client-version, check-canvas-version, check-systems-db-stamp, check-dataflow-graph) — small, pure, no numerics, highest prompt-friction, runs on every push. Leave planet-gen numerics (numpy/scipy/PIL) in Python behind a stable interface.
Hard constraint: import_economics is stamped by source SHA via tooling/generator_sources.py. Any port must keep the stamp/pre-push contract intact through the transition, not disabled during it.
Outcome: a recommendation with costs, then a D-record and Jeroen''s ruling. Epics only after that.
2026-08-20 — Jeroen raised Typer (typer.tiangolo.com) as the Python-CLI option. Q-124 amended with the costing.
Start here, not with Rust. Measured state: pyproject.toml already exists and make setup-venv already does pip install -e ''.[dev]'', but there is NO [project.scripts] entry — no console entrypoint exists at all. 22 tooling files already use argparse. So this is consolidation, not authorship.
The real deliverable is ''one bare command reliably on PATH'', not ''a CLI framework''. A [project.scripts] entrypoint lands in .venv/bin/, which is on PATH only when activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same failure recorded for tea (absolute path breaks the Bash(tea *) rule and prompts every time). Install via uv tool/pipx into ~/.local/bin, or symlink. A Typer app behind an absolute venv path solves nothing.
Watch startup cost: a single entrypoint importing every subcommand eagerly pays all 123 modules on every invocation, four times per push. Lazy subcommand registration is mandatory, not an optimisation — measure before and after.', 'backlog', 'low', NULL, 'tooling', 'Q-124', '2026-08-19 23:54:55.776', '2026-08-20 00:00:11.592', NULL, 'b48a1523bdfbddb038c083b365414e9f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S37Y1ARMV68RJT802Z3VPG', 'initiative', NULL, 'reach — one CLI for all repo tooling (D-263)', 'Consolidate the 123-file tooling/ tree into one installable Python package behind a single console command, reach. Resolves Q-124; Rust rejected as R-014 because the friction is packaging, not language. Shape per D-263: a routing-only main.py, every domain under tooling/domains/<name>/ split router/service/schemas/helpers, a bounded core/ holding only what has no domain, shared logging and error handling attached as decorators, pydantic for data shapes but kept off the push-gate import path, installed via uv tool install so reach is a bare name on PATH. Deliberately parentless — this is phase-independent tooling, not cascade work. Six epics; each unfolds into tickets when refined.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:17.258', '2026-08-20 00:23:17.258', NULL, 'bac7a14e2e36595a5348ea47f6c5dea8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S392DEG41MRGEFSYWJWP2W', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E1 — The door: reach on PATH, routing-only main.py, lazy domains', 'The walking skeleton, and the epic every other one depends on. Deliverable: typing reach in any shell — interactive, agent, or git hook — prints the domain list, and one domain is ported end to end through router/service as the reference every later port copies. Pieces that become tickets: (1) package skeleton — tooling/__init__.py, main.py, core/, domains/, setuptools discovery configured to exclude the econ-sim Rust crate; (2) pyproject [project.scripts] reach = tooling.main:app, plus typer and pydantic added to dependencies; (3) the PATH guarantee — uv tool install --editable, verified from a NON-INTERACTIVE context (git hook, agent Bash) and not just a login shell, since that split is the whole point; (4) lazy router registration — main.py must list domains in --help WITHOUT importing their services, which is the mechanism the 104 ms gate budget depends on; (5) help at every level: reach --help enumerates domains, reach <domain> --help enumerates verbs; (6) one domain ported end to end as the reference implementation; (7) Bash(reach *) added to .claude/settings.json. Done when: reach runs from a git hook with no venv activation, --help answers what tooling exists without an ls, and the reference domain has zero logic in its router.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:26.571', '2026-08-20 00:23:26.571', NULL, '1d9add2650ae88a9b96bd96df97577ab', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E2 — Decorators: shared logging, robust error handling, failures that teach', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:32.680', '2026-08-20 00:23:32.680', NULL, '4e7715d95d89a3030c6e82c5dce8b5cc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E3 — domains/: move the 123-file tree into the layered package', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-20 00:23:58.880', NULL, '60b3389274f65fb923e5444ffaf631e1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3DTRHSBYJS55NDT9YR4R0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E4 — The gate family and the push hook, inside the 104 ms budget', 'The highest-frequency, highest-friction surface, and the one with a hard number attached. Baseline measured 2026-08-20: check-client-version 20 ms, check-dataflow-graph 38 ms, check-canvas-version 46 ms — about 104 ms for the three unconditional checks; check-systems-db-stamp runs only when systems.db is in the push. The ported gates must not exceed that. Pieces that become tickets: (1) port the four checks into domains/check/ — client-version, canvas-version, dataflow-graph, systems-db-stamp; (2) port validate-content, validate-checklist, validate-ron, check-fact-ids into domains/validate/; (3) rewire .config/hooks/pre-push to call reach, keeping the fail_check by-name reporting that hook deliberately has; (4) prove each gate still FAILS — a check that explains itself and exits 0 silently disables its own gate, which is exactly the clide failure recorded in D-263; test each one against a deliberately broken tree, not just a clean one; (5) the wall-clock assertion that turns the budget into a test; (6) confirm domains/check/ imports no pydantic — 87 ms against a 20 ms check is a 3.5x regression bought for nothing, four times per push. Depends on E1 (lazy registration) and E2 (the error contract) being real first.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:24:05.572', '2026-08-20 00:24:05.572', NULL, 'ff8b3ac61202a3e1facc4d723bd3d6e0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3F6SKZ6EQQ7TY0RKT2970', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E5 — Generators and numerics, without breaking the systems.db stamp', 'The riskiest epic, and the one with a hard sequencing constraint. tooling/generator_sources.py SHAs the concatenated bytes of the generator sources SORTED BY PATH, so renaming a file changes the stamp even when its content is byte-identical. The relocation must therefore land as ONE commit — registry paths updated, make regen-db run, stamp verified — never split across pushes, or the pre-push gate rejects an intermediate state that is in fact correct. Pieces that become tickets: (1) move import_economics.py and the economy_import package into domains/db/, update generator_sources.py in the same change, regen and verify the stamp; (2) the generate_brands Rust binary is a SUBROUTINE of the Python importer, not an independent generator — keep that relationship intact; (3) canvas_sources.py and schema_version.py move with their consumers, and check-canvas-version must keep resolving the registry; (4) planet-gen into domains/planet/ — import_heightmaps, import_province_boundaries, planet_simulation, scaffold_bodies, render_heightmap; these are one-time build imports baked into the committed DB, intentionally unstamped, and they lean on numpy/scipy/PIL; (5) garment-fit into domains/garment/ with its 23 blender_author_* files going to scripts/blender/ per the carve-out; (6) db/wiki_sync.py and the audio/image/trellis connectors, which resolve a venv explicitly today in tooling/db/common.py and should stop needing to; (7) test_sim_determinism and test_oasis_ring_scaling keep passing — they are the guard that the numerics did not move under us. NOTHING here is rewritten. Code is relocated and re-fronted; no numerical behaviour changes, which is precisely why Rust was rejected.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:24:16.844', '2026-08-20 00:24:16.844', NULL, '796fd7edeb556309549bbff444046d1c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3G1D2828RX2F3ZFNWRJGC', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E6 — Retire the old paths: make targets, docs, skills, permissions', 'The migration is not done when reach works; it is done when nothing calls the old paths. Surface: 84 Makefile targets, 160 markdown files under .claude/ and docs/ that name tooling/ paths, the pre-push hook, and the skills. Pieces that become tickets: (1) make targets become THIN WRAPPERS over reach — make stays the door for zero-argument repo verbs, reach is the door for anything taking arguments; one implementation, and the door holding it is reach; note Bash(make *) is already blanket-allowed so this epic does not claim to make make cheaper; (2) the 10 hand-written Bash(tooling/...) entries in .claude/settings.json are deleted, replaced by the single Bash(reach *); (3) deprecation shims — each retired path leaves a stub that prints the new command and exits NON-ZERO, the failure contract applied to the migration itself, removed after a window rather than deleted under the callers; (4) sweep the 160 markdown callers, including .claude/rules/*.md and every skill that shells out to a tooling path; (5) the reach skill documenting usage AND how to add a domain or command; (6) the CLAUDE.md rule that no new tooling is written outside the CLI, so the tree cannot re-fragment; (7) delete the shims and confirm no tooling/ script path survives outside scripts/blender/.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:24:23.657', '2026-08-20 00:24:23.657', NULL, '46e71123daa96f64882085b936674e92', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3RFJ739EWN4JDG8ZEX89W', 'story', '06G1S3G1D2828RX2F3ZFNWRJGC', 'reach skill — usage, and how to add a domain or command', 'Project-scope skill at .claude/skills/reach/SKILL.md, user-invocable, covering two audiences. USAGE: the domain list, how to discover verbs (reach --help then reach <domain> --help), the flags that matter to agents and hooks (--no-input, --verbose), and the rule that a non-zero exit always carries the command that fixes it. AUTHORING — the part that keeps the tree from re-fragmenting: how to add a verb to an existing domain, how to add a whole domain, and the invariants from D-263 that a reviewer will check — router.py holds no logic, service.py holds no Typer, every command carries the composed @command decorator (logging plus error handling), all output goes through core/console.py, pydantic models live in domains/<d>/schemas.py and must not be reachable from main.py, and core/ holds only what has no domain. Include the failure-message contract with a worked example (raise ReachError with a fix= remedy) because that is the requirement most likely to be skipped under time pressure. Write this AFTER E1 and E2 land so the skill documents a CLI that exists rather than one that is planned.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:32.817', '2026-08-20 00:25:32.817', NULL, 'f65a35978c2117e381ce77fa4afc1394', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3SJQKECYH3CKZ3Z215CMW', 'story', '06G1S3G1D2828RX2F3ZFNWRJGC', 'CLAUDE.md rule — no new tooling outside the reach CLI', 'Add the standing rule to settled-reach/CLAUDE.md that all tooling is developed IN the reach CLI and nowhere else — no new loose scripts under tooling/, no new extensionless executables, no new python3 tooling/<script>.py entrypoints. A new tool is a verb on an existing domain, or a new domain under tooling/domains/. Rationale to state in one line: the tree fragmented into 123 files and four naming conventions precisely because there was never a rule against adding one more, and a consolidation with no standing rule re-fragments. Name the two legitimate exceptions so they are not litigated later: (1) the Blender payload scripts under tooling/scripts/blender/, which run under Blender bundled interpreter and physically cannot import the package; (2) tooling/econ-sim, a Rust crate. Also update .claude/rules/project-structure.md tooling/ entry to describe the package layout. NOTE ON WORDING — Jeroen phrased this 2026-08-20 as all tooling should not be developed in that new cli; read as no tooling developed OUTSIDE it, since the opposite reading contradicts D-263 entirely. Confirm before writing the rule.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:41.820', '2026-08-20 00:25:41.820', NULL, '32d39b03d15628da8f810ec710b28f37', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3Z1TJHNK1A3YS4HGMBPE4', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E7 — Adoption: the skill, the rule, and the docs that stop re-fragmentation', 'A consolidation with no standing rule re-fragments. The tree reached 123 files and four naming conventions precisely because there was never a rule against adding one more, so the documentation half of this initiative is not decoration — it is the part that makes the other six stick. Distinct from E6, which retires OLD paths; this establishes the NEW way as the only way. Pieces that become tickets: (1) the reach skill — usage and authoring (T-1254); (2) the CLAUDE.md standing rule that no tooling is developed outside the CLI (T-1255); (3) .claude/rules/project-structure.md — the tooling/ entry rewritten to describe the package layout rather than a file list; (4) the D-263 diagram at docs/diagrams/architecture/reach-cli.d2 — the door, the layers, the domains and the Blender carve-out; D-263 already cross-references this path, so until it exists the record names a file that is not there; (5) a short authoring-contract section wherever a reviewer will look — the invariants that make the layering real (no logic in routers, no Typer in services, every command decorated, pydantic off the gate path) are easy to state and easy to skip. Sequence AFTER E1 and E2 so the docs describe a CLI that exists.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:26.644', '2026-08-20 00:26:26.644', NULL, '26d362ab165b2fc7868a977a214875c7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S40293BXWK39D9HAXHM8Q4', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E8 — The tooling test surface, rewired', 'make test-tooling is the tooling half of the push gate and it runs five suites that ALL move during this initiative: planet-gen test_sim_determinism.py and test_oasis_ring_scaling.py (via VENV_PY), test_canvas_version_check.py, economy-db test_traits.py and test_atlas_idempotency.py, plus an import_economics --dry-run against the committed DB. Nobody owns rewiring them, so this epic does. Pieces that become tickets: (1) the five suites move with their domains and keep passing — they are the guard that the numerics did not shift under the relocation, so a green run before and after the move is the actual acceptance evidence; (2) VENV_PY disappears from the Makefile — the whole point of uv tool install is that there is no interpreter fork between a human shell and a hook, and every surviving VENV_PY is a place where that promise is not yet kept; (3) a home for service-level tests — the layering exists so services can be called directly without a CLI round trip, and if no test ever does that, the layering is unverified decoration; (4) the conformance test from E2 joins this gate rather than living alone; (5) the wall-clock budget assertion from E4 joins it too; (6) confirm the gate still FAILS on a broken tree — a test gate that only passes has never been tested. Watch: ruff config currently ignores E402, E702 and F841; the E402 debt is script-style sys.path.insert before imports and should largely vanish with a real package, so re-enable it here rather than carrying the ignore forever.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:34.952', '2026-08-20 00:26:34.952', NULL, '46c63386f34fbb52f8abf946afa854bd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3RFJ739EWN4JDG8ZEX89W', 'story', '06G1S3Z1TJHNK1A3YS4HGMBPE4', 'reach skill — usage, and how to add a domain or command', 'Project-scope skill at .claude/skills/reach/SKILL.md, user-invocable, covering two audiences. USAGE: the domain list, how to discover verbs (reach --help then reach <domain> --help), the flags that matter to agents and hooks (--no-input, --verbose), and the rule that a non-zero exit always carries the command that fixes it. AUTHORING — the part that keeps the tree from re-fragmenting: how to add a verb to an existing domain, how to add a whole domain, and the invariants from D-263 that a reviewer will check — router.py holds no logic, service.py holds no Typer, every command carries the composed @command decorator (logging plus error handling), all output goes through core/console.py, pydantic models live in domains/<d>/schemas.py and must not be reachable from main.py, and core/ holds only what has no domain. Include the failure-message contract with a worked example (raise ReachError with a fix= remedy) because that is the requirement most likely to be skipped under time pressure. Write this AFTER E1 and E2 land so the skill documents a CLI that exists rather than one that is planned.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:32.817', '2026-08-20 00:26:44.635', NULL, '9435f993de52370214847529ddb30bdf', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3SJQKECYH3CKZ3Z215CMW', 'story', '06G1S3Z1TJHNK1A3YS4HGMBPE4', 'CLAUDE.md rule — no new tooling outside the reach CLI', 'Add the standing rule to settled-reach/CLAUDE.md that all tooling is developed IN the reach CLI and nowhere else — no new loose scripts under tooling/, no new extensionless executables, no new python3 tooling/<script>.py entrypoints. A new tool is a verb on an existing domain, or a new domain under tooling/domains/. Rationale to state in one line: the tree fragmented into 123 files and four naming conventions precisely because there was never a rule against adding one more, and a consolidation with no standing rule re-fragments. Name the two legitimate exceptions so they are not litigated later: (1) the Blender payload scripts under tooling/scripts/blender/, which run under Blender bundled interpreter and physically cannot import the package; (2) tooling/econ-sim, a Rust crate. Also update .claude/rules/project-structure.md tooling/ entry to describe the package layout. NOTE ON WORDING — Jeroen phrased this 2026-08-20 as all tooling should not be developed in that new cli; read as no tooling developed OUTSIDE it, since the opposite reading contradicts D-263 entirely. Confirm before writing the rule.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:41.820', '2026-08-20 00:26:44.768', NULL, '784a21074f9cb5f4a89094cd715455c9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S37Y1ARMV68RJT802Z3VPG', 'initiative', NULL, 'reach — one CLI for all repo tooling (D-263)', 'Consolidate the 123-file tooling/ tree into one installable Python package behind a single console command, reach. Resolves Q-124; Rust rejected as R-014 because the friction is packaging, not language. Shape per D-263: a routing-only main.py, every domain under tooling/domains/<name>/ split router/service/schemas/helpers, a bounded core/ holding only what has no domain, shared logging and error handling attached as decorators, pydantic for data shapes but kept off the push-gate import path, installed via uv tool install so reach is a bare name on PATH. Deliberately parentless — this is phase-independent tooling, not cascade work. Six epics; each unfolds into tickets when refined.
Amended 2026-08-20 during the planning session: EIGHT epics, not six. E7 (Adoption) was split out of E6 because retiring old paths and establishing the new way as the only way are different jobs with different failure modes. E8 (Test surface) was added because make test-tooling runs five suites that all move during E3/E5 and nothing else owned rewiring them.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:17.258', '2026-08-20 00:26:54.553', NULL, 'd8a1d79223020984e9a9d65d4bf0ed04', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3SJQKECYH3CKZ3Z215CMW', 'story', '06G1S3Z1TJHNK1A3YS4HGMBPE4', 'CLAUDE.md rule — no new tooling outside the reach CLI', 'Add the standing rule to settled-reach/CLAUDE.md that all tooling is developed IN the reach CLI and nowhere else — no new loose scripts under tooling/, no new extensionless executables, no new python3 tooling/<script>.py entrypoints. A new tool is a verb on an existing domain, or a new domain under tooling/domains/. Rationale to state in one line: the tree fragmented into 123 files and four naming conventions precisely because there was never a rule against adding one more, and a consolidation with no standing rule re-fragments. Name the two legitimate exceptions so they are not litigated later: (1) the Blender payload scripts under tooling/scripts/blender/, which run under Blender bundled interpreter and physically cannot import the package; (2) tooling/econ-sim, a Rust crate. Also update .claude/rules/project-structure.md tooling/ entry to describe the package layout. NOTE ON WORDING — Jeroen phrased this 2026-08-20 as all tooling should not be developed in that new cli; read as no tooling developed OUTSIDE it, since the opposite reading contradicts D-263 entirely. Confirm before writing the rule.
CONFIRMED 2026-08-20 by Jeroen: your read is correct for the tooling. The rule is NO TOOLING DEVELOPED OUTSIDE THE CLI. No further confirmation needed before writing it.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:25:41.820', '2026-08-20 00:28:43.014', NULL, '11bef4568939ff71fdf9f4e9f5b6e6fb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3Z1TJHNK1A3YS4HGMBPE4', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E7 — Adoption: the skill, the rule, and the docs that stop re-fragmentation', 'A consolidation with no standing rule re-fragments. The tree reached 123 files and four naming conventions precisely because there was never a rule against adding one more, so the documentation half of this initiative is not decoration — it is the part that makes the other six stick. Distinct from E6, which retires OLD paths; this establishes the NEW way as the only way. Pieces that become tickets: (1) the reach skill — usage and authoring (T-1254); (2) the CLAUDE.md standing rule that no tooling is developed outside the CLI (T-1255); (3) .claude/rules/project-structure.md — the tooling/ entry rewritten to describe the package layout rather than a file list; (4) the D-263 diagram at docs/diagrams/architecture/reach-cli.d2 — the door, the layers, the domains and the Blender carve-out; D-263 already cross-references this path, so until it exists the record names a file that is not there; (5) a short authoring-contract section wherever a reviewer will look — the invariants that make the layering real (no logic in routers, no Typer in services, every command decorated, pydantic off the gate path) are easy to state and easy to skip. Sequence AFTER E1 and E2 so the docs describe a CLI that exists.
Amended 2026-08-20: the forward reference to docs/diagrams/architecture/reach-cli.d2 was REMOVED from D-263 rather than left pointing at a file that does not exist. So the diagram ticket now carries a second half — when the diagram lands, ADD the cross-reference back into D-263. Per .claude/rules/diagrams.md a diagram not referenced from its D-record is unreachable from the governance tree, which is the failure this avoids in the other direction.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:26.644', '2026-08-20 00:28:49.216', NULL, '06d68cc8318313dd746a0f55e5834dc0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S37Y1ARMV68RJT802Z3VPG', 'initiative', NULL, 'reach — one CLI for all repo tooling (D-263)', 'Consolidate the 123-file tooling/ tree into one installable Python package behind a single console command, reach. Resolves Q-124; Rust rejected as R-014 because the friction is packaging, not language. Shape per D-263: a routing-only main.py, every domain under tooling/domains/<name>/ split router/service/schemas/helpers, a bounded core/ holding only what has no domain, shared logging and error handling attached as decorators, pydantic for data shapes but kept off the push-gate import path, installed via uv tool install so reach is a bare name on PATH. Deliberately parentless — this is phase-independent tooling, not cascade work. Six epics; each unfolds into tickets when refined.
Amended 2026-08-20 during the planning session: EIGHT epics, not six. E7 (Adoption) was split out of E6 because retiring old paths and establishing the new way as the only way are different jobs with different failure modes. E8 (Test surface) was added because make test-tooling runs five suites that all move during E3/E5 and nothing else owned rewiring them.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:17.258', '2026-08-20 00:33:11.395', NULL, '4511becb743553860e9c685e6c29891d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S392DEG41MRGEFSYWJWP2W', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E1 — The door: reach on PATH, routing-only main.py, lazy domains', 'The walking skeleton, and the epic every other one depends on. Deliverable: typing reach in any shell — interactive, agent, or git hook — prints the domain list, and one domain is ported end to end through router/service as the reference every later port copies. Pieces that become tickets: (1) package skeleton — tooling/__init__.py, main.py, core/, domains/, setuptools discovery configured to exclude the econ-sim Rust crate; (2) pyproject [project.scripts] reach = tooling.main:app, plus typer and pydantic added to dependencies; (3) the PATH guarantee — uv tool install --editable, verified from a NON-INTERACTIVE context (git hook, agent Bash) and not just a login shell, since that split is the whole point; (4) lazy router registration — main.py must list domains in --help WITHOUT importing their services, which is the mechanism the 104 ms gate budget depends on; (5) help at every level: reach --help enumerates domains, reach <domain> --help enumerates verbs; (6) one domain ported end to end as the reference implementation; (7) Bash(reach *) added to .claude/settings.json. Done when: reach runs from a git hook with no venv activation, --help answers what tooling exists without an ls, and the reference domain has zero logic in its router.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:26.571', '2026-08-20 00:34:08.839', NULL, '7e67500e72784b0e6c7454247c5854bd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 00:36:12.032', NULL, '2f496f6b45f1e81169d577f168307312', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S67EQ8EQTT2E9E3CGTXS7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The reach entrypoint: [project.scripts] plus typer and pydantic', 'pyproject.toml currently has NO [project.scripts] at all — no console entrypoint is defined anywhere. Adding one plus the two dependencies is the whole delta. Deliverables: [project.scripts] reach = tooling.main:cli; typer and pydantic added to [project] dependencies, both version-pinned and CVE-checked against NVD plus OSV the way scipy and Pillow already are in this file, with the check date in a comment since that is the established convention here. NOTE the entrypoint target is main:cli not main:app — see the lazy-registration task; the root is a click Group subclass rather than a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and because keeping typer off the --help path is worth having. Acceptance: .venv/bin/reach --help runs. This is the venv-local proof only; the bare-name-on-PATH guarantee is a separate task and is the one that actually matters.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:19.770', '2026-08-20 00:36:19.770', NULL, 'de8d6892a3b2a55b08846450349a801a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — the mechanism the gate budget depends on', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-20 00:36:25.557', NULL, '2f29bb49be355f9ed08a401ebbe72f9b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same scar tea left: an absolute path breaks the Bash(tea *) rule and prompts every time; the fix was a bare name on PATH. Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed. VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-20 00:36:34.486', NULL, 'c7d96f13996f3e8d48713482c4ee0ef1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-20 00:36:41.192', NULL, '37a44aedee96ec2fdae9b2d30a973974', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3DTRHSBYJS55NDT9YR4R0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E4 — The gate family and the push hook, inside the 104 ms budget', 'The highest-frequency, highest-friction surface, and the one with a hard number attached. Baseline measured 2026-08-20: check-client-version 20 ms, check-dataflow-graph 38 ms, check-canvas-version 46 ms — about 104 ms for the three unconditional checks; check-systems-db-stamp runs only when systems.db is in the push. The ported gates must not exceed that. Pieces that become tickets: (1) port the four checks into domains/check/ — client-version, canvas-version, dataflow-graph, systems-db-stamp; (2) port validate-content, validate-checklist, validate-ron, check-fact-ids into domains/validate/; (3) rewire .config/hooks/pre-push to call reach, keeping the fail_check by-name reporting that hook deliberately has; (4) prove each gate still FAILS — a check that explains itself and exits 0 silently disables its own gate, which is exactly the clide failure recorded in D-263; test each one against a deliberately broken tree, not just a clean one; (5) the wall-clock assertion that turns the budget into a test; (6) confirm domains/check/ imports no pydantic — 87 ms against a 20 ms check is a 3.5x regression bought for nothing, four times per push. Depends on E1 (lazy registration) and E2 (the error contract) being real first.
AMENDED 2026-08-20 — the 104 ms ceiling in the description above is WITHDRAWN. D-263 was amended the same day: the acceptance criterion is OUTPUT PARITY, not timing parity. A ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast. Reason: the only automatic consumer is the pre-push hook, which on a server or client push already runs cargo test or the gdUnit4 suite (minutes), and on a governance-only push totals about a second — so a few hundred ms is invisible either way. The replacement budget is a ceiling with headroom: a reach invocation should feel instant to a human, under about 250 ms, and the unconditional gate set stays comfortably under a second. Item (5) of this ticket, the wall-clock assertion, is REPLACED by an import-graph assertion — a timing test is flaky on a loaded machine and reports that something got slow rather than what; asserting sys.modules after reach --help names the offender. Item (6), the no-pydantic-in-check rule, is DROPPED — pydantic at 87 ms is unremarkable against a 250 ms ceiling, and the carve-out bought 87 ms at the cost of an exception to the layering.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:24:05.572', '2026-08-20 00:41:07.767', NULL, '3f25a3d2abace7b875bfcb4345fb3ed6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — the mechanism the gate budget depends on', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 — the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn — see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms — about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-20 00:41:10.589', NULL, '26d59ae3fb7c386ad6c9f566bc5dabaa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3DTRHSBYJS55NDT9YR4R0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E4 — The gate family and the push hook, at output parity', 'The highest-frequency, highest-friction surface, and the one with a hard number attached. Baseline measured 2026-08-20: check-client-version 20 ms, check-dataflow-graph 38 ms, check-canvas-version 46 ms — about 104 ms for the three unconditional checks; check-systems-db-stamp runs only when systems.db is in the push. The ported gates must not exceed that. Pieces that become tickets: (1) port the four checks into domains/check/ — client-version, canvas-version, dataflow-graph, systems-db-stamp; (2) port validate-content, validate-checklist, validate-ron, check-fact-ids into domains/validate/; (3) rewire .config/hooks/pre-push to call reach, keeping the fail_check by-name reporting that hook deliberately has; (4) prove each gate still FAILS — a check that explains itself and exits 0 silently disables its own gate, which is exactly the clide failure recorded in D-263; test each one against a deliberately broken tree, not just a clean one; (5) the wall-clock assertion that turns the budget into a test; (6) confirm domains/check/ imports no pydantic — 87 ms against a 20 ms check is a 3.5x regression bought for nothing, four times per push. Depends on E1 (lazy registration) and E2 (the error contract) being real first.
AMENDED 2026-08-20 — the 104 ms ceiling in the description above is WITHDRAWN. D-263 was amended the same day: the acceptance criterion is OUTPUT PARITY, not timing parity. A ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast. Reason: the only automatic consumer is the pre-push hook, which on a server or client push already runs cargo test or the gdUnit4 suite (minutes), and on a governance-only push totals about a second — so a few hundred ms is invisible either way. The replacement budget is a ceiling with headroom: a reach invocation should feel instant to a human, under about 250 ms, and the unconditional gate set stays comfortably under a second. Item (5) of this ticket, the wall-clock assertion, is REPLACED by an import-graph assertion — a timing test is flaky on a loaded machine and reports that something got slow rather than what; asserting sys.modules after reach --help names the offender. Item (6), the no-pydantic-in-check rule, is DROPPED — pydantic at 87 ms is unremarkable against a 250 ms ceiling, and the carve-out bought 87 ms at the cost of an exception to the layering.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:24:05.572', '2026-08-20 00:41:16.768', NULL, 'f0edcbda3612efe3f7cb94fdba9864bd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — --help must not import the tree', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 — the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn — see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms — about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-20 00:41:21.127', NULL, '797a573d0900ff331fd6da90ff026e82', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-20 00:41:24.521', NULL, '55d192c18a2a86bbceb65f7bf22a971b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S392DEG41MRGEFSYWJWP2W', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E1 — The door: reach on PATH, routing-only main.py, lazy domains', 'The walking skeleton, and the epic every other one depends on. Deliverable: typing reach in any shell — interactive, agent, or git hook — prints the domain list, and one domain is ported end to end through router/service as the reference every later port copies. Pieces that become tickets: (1) package skeleton — tooling/__init__.py, main.py, core/, domains/, setuptools discovery configured to exclude the econ-sim Rust crate; (2) pyproject [project.scripts] reach = tooling.main:app, plus typer and pydantic added to dependencies; (3) the PATH guarantee — uv tool install --editable, verified from a NON-INTERACTIVE context (git hook, agent Bash) and not just a login shell, since that split is the whole point; (4) lazy router registration — main.py must list domains in --help WITHOUT importing their services, which is the mechanism the 104 ms gate budget depends on; (5) help at every level: reach --help enumerates domains, reach <domain> --help enumerates verbs; (6) one domain ported end to end as the reference implementation; (7) Bash(reach *) added to .claude/settings.json. Done when: reach runs from a git hook with no venv activation, --help answers what tooling exists without an ls, and the reference domain has zero logic in its router.
AMENDED 2026-08-20 — two corrections to the description above. (a) Item (4) calls lazy registration the mechanism the 104 ms gate budget depends on; that budget is WITHDRAWN (D-263 amendment, T-1251). Lazy registration is still mandatory, justified instead by what an eager entrypoint would really cost: scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms, about 460 ms before executing a line of its own. (b) Item (2) names the entrypoint tooling.main:app; it is tooling.main:cli — the root is a click Group subclass, not a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and keeping typer off the --help path is worth having. Refined into six tasks: T-1258 package skeleton, T-1259 entrypoint and deps, T-1260 lazy registration, T-1261 PATH guarantee, T-1262 reference domain, T-1263 permission entry.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:26.571', '2026-08-20 00:41:30.963', NULL, 'f3f7ea151cf854053a5f7a08f4a36be3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S40293BXWK39D9HAXHM8Q4', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E8 — The tooling test surface, rewired', 'make test-tooling is the tooling half of the push gate and it runs five suites that ALL move during this initiative: planet-gen test_sim_determinism.py and test_oasis_ring_scaling.py (via VENV_PY), test_canvas_version_check.py, economy-db test_traits.py and test_atlas_idempotency.py, plus an import_economics --dry-run against the committed DB. Nobody owns rewiring them, so this epic does. Pieces that become tickets: (1) the five suites move with their domains and keep passing — they are the guard that the numerics did not shift under the relocation, so a green run before and after the move is the actual acceptance evidence; (2) VENV_PY disappears from the Makefile — the whole point of uv tool install is that there is no interpreter fork between a human shell and a hook, and every surviving VENV_PY is a place where that promise is not yet kept; (3) a home for service-level tests — the layering exists so services can be called directly without a CLI round trip, and if no test ever does that, the layering is unverified decoration; (4) the conformance test from E2 joins this gate rather than living alone; (5) the wall-clock budget assertion from E4 joins it too; (6) confirm the gate still FAILS on a broken tree — a test gate that only passes has never been tested. Watch: ruff config currently ignores E402, E702 and F841; the E402 debt is script-style sys.path.insert before imports and should largely vanish with a real package, so re-enable it here rather than carrying the ignore forever.
AMENDED 2026-08-20 — item (5), the wall-clock budget assertion from E4, is WITHDRAWN along with the 104 ms timing-parity budget it enforced (D-263 amendment, T-1251). It is replaced by the import-graph assertion: after reach --help, no domain service module and no heavy dependency (numpy, scipy, PIL, pydantic) may appear in sys.modules. That test is not flaky on a loaded machine and it names the offending import rather than reporting that something got slower.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:34.952', '2026-08-20 00:41:33.421', NULL, 'c908934cd550d418cadeddd4ea0ed028', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Permission entry: Bash(reach *) replaces per-script prompting', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-20 00:41:43.428', NULL, 'b75a9ad1310d58b1299aa8325e032735', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Detach mode and the jobs domain: long runs that outlive the caller', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-20 00:42:39.894', NULL, 'f9e89128d4cb7cd808cef0ee601f2be7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 — core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR — stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL — a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict — outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 00:47:37.518', NULL, '098878cce987cc1a6ae43b33c16cfde0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-20 00:47:37.950', NULL, '1ddb7a7200e1d6f24b80ada09940740f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
AMENDED 2026-08-20 — scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time — you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter — JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive — detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-20 00:47:48.268', NULL, '47c90408026cfde9d412830a8e03a44c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E2 — Decorators: shared logging, robust error handling, failures that teach', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.
AMENDED 2026-08-20 — the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility — it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:32.680', '2026-08-20 00:47:55.366', NULL, '091136a4ca712cf2cb7f118759c5b396', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same scar tea left: an absolute path breaks the Bash(tea *) rule and prompts every time; the fix was a bare name on PATH. Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed. VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
ADDED 2026-08-20 — the NEGATIVE acceptance criterion, which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or via the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, python -m tooling, .venv/bin/reach, or any file path. The only supported form is the bare word reach. Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. Highest-risk site is E6: 84 make targets become reach wrappers, and one -m tooling among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-20 00:49:03.924', NULL, '4a841f89b5ecaf04ceb0f65b20662aea', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same scar tea left: an absolute path breaks the Bash(tea *) rule and prompts every time; the fix was a bare name on PATH.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-20 00:49:27.688', NULL, '888e7dde02f2da1ac01c66dd50039870', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S37Y1ARMV68RJT802Z3VPG', 'initiative', NULL, 'reach — one CLI for all repo tooling (D-263)', 'Consolidate the 123-file tooling/ tree into one installable Python package behind a single console command, reach. Resolves Q-124; Rust rejected as R-014 because the friction is packaging, not language. Shape per D-263: a routing-only main.py, every domain under tooling/domains/<name>/ split router/service/schemas/helpers, a bounded core/ holding only what has no domain, shared logging and error handling attached as decorators, pydantic for data shapes but kept off the push-gate import path, installed via uv tool install so reach is a bare name on PATH. Deliberately parentless — this is phase-independent tooling, not cascade work. Six epics; each unfolds into tickets when refined.
Amended 2026-08-20 during the planning session: EIGHT epics, not six. E7 (Adoption) was split out of E6 because retiring old paths and establishing the new way as the only way are different jobs with different failure modes. E8 (Test surface) was added because make test-tooling runs five suites that all move during E3/E5 and nothing else owned rewiring them.
Amended again 2026-08-20: NINE epics. T-1264 was added after a session finding that the gates are milliseconds but the generators are minutes, and an agent Bash call gives up at two minutes sending nothing — so reach streams JSONL progress events and detached runs append to a tailable per-job log, with no daemon. Epic ids, since the E-prefixes in the titles are a reading aid and not handles: T-1248 the door, T-1249 decorators, T-1250 the domain move, T-1251 the gate family, T-1252 generators and numerics, T-1253 retiring old paths, T-1256 adoption, T-1257 the test surface, T-1264 streaming and jobs. Also amended this session: the 104 ms timing-parity budget was withdrawn in favour of output parity plus a 250 ms feels-instant ceiling, which in turn dropped the no-pydantic-in-check carve-out.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:17.258', '2026-08-20 00:51:13.519', NULL, 'db66dd46fad193cb77e4a88b6230c419', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 — core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR — stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL — a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict — outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 00:51:31.670', NULL, '33f8f175d37e326c042a023b515a2769', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 — core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR — stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL — a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict — outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 00:52:06.891', NULL, 'd6ac17453435122bcfee990719d36c61', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 — core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR — stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL — a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict — outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.
DONE 2026-08-20. Package skeleton landed and verified. Findings and deviations below, since several affect later tickets.
VERIFIED (each check could have failed, and the failure paths were exercised):
- uv pip install -e . succeeds; dist-info top_level.txt contains exactly "tooling" — discovery did NOT pull in client/, server/, docs/, wiki/, db/ or tests/, which was the risk that made explicit config necessary.
- import tooling.core.config, tooling.core.console, tooling.domains all succeed.
- console non-TTY: JSONL on stderr, command output alone on stdout (proved by redirecting stderr to /dev/null and seeing only DATA).
- console SR_OUTPUT_FORMAT=text: human rendering, phase prefix, percentage, and the verdict printing its Fix line last.
- debug-level events suppressed at the default threshold; verdict never suppressed.
- config sentinel REJECTS a bad override: SR_REPO_ROOT=/tmp raises and names both remedies.
- ruff check tooling/ clean (full tree, as the push gate runs it).
- No regression to the existing scripts: check-client-version, check-canvas-version and check-dataflow-graph all still pass with tooling/ now a package.
- Import cost: tooling.core.config 0.4 ms, tooling.core.console 3.6 ms — of which 1.9 ms is typing, which typer and click both import anyway, so it is free in any real invocation.
DEVIATION FROM THE WRITTEN ACCEPTANCE — pip is not available.
The acceptance says "pip install -e . succeeds". There is no pip in .venv: pyvenv.cfg records uv = 0.10.2, so the venv was made by uv, which does not install pip. Acceptance was met with `uv pip install -e . --python .venv/bin/python` instead. This is the direction of travel per D-263 anyway.
FINDING FOR T-1261 (the make target) — make setup-venv is STALE.
It runs `python3 -m venv .venv` then `.venv/bin/pip install -e ".[dev]" --quiet`. Neither matches the venv on disk, and the pip line cannot work against it. This is precisely the venv split Q-124 named: the recorded procedure and the actual state have already diverged. T-1261 owns the make target and should replace it with the uv path rather than patch pip back in.
FINDING — the project had never actually been installed.
Before this ticket site-packages held the DEPENDENCIES but no settled-reach-tooling dist-info and no .pth for it. That is consistent: with no __init__.py anywhere, an editable install had no package to expose. So this is the first commit where `import tooling` means anything.
DELIBERATE OMISSION — err() was dropped.
The original description asked for out() to stdout and err() to stderr. The amendment made console the event emitter, and a raw-text err() would then be a SECOND output path competing with the event stream — exactly the drift D-263 forbids by naming console the single one. event(level="error") and verdict(ok=False) cover every case err() would have. Flagging it because the word appears in the description and its absence is intentional, not an oversight.
CARRIED TO T-1249 — config raises RuntimeError, not ReachError.
core/errors.py does not exist yet, so _validated raises RuntimeError with an instructional message that already follows the contract (it names the fix). One call site to convert when T-1249 lands; there is a comment at the raise saying so.
INCIDENTAL — .gitignore.
Added *.egg-info/ (build output the install creates). Also added .venv/, which was only ignored via .git/info/exclude — machine-local, so a fresh clone or a new worktree would not have ignored it.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 16:53:05.530', NULL, 'c7fc59092f1f13d14deab7207adf662b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same scar tea left: an absolute path breaks the Bash(tea *) rule and prompts every time; the fix was a bare name on PATH.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.
FINDING from T-1258 (2026-08-20): make setup-venv is already stale and this ticket owns the fix. It runs python3 -m venv .venv then .venv/bin/pip install -e .[dev] --quiet, but the venv on disk was created by uv (pyvenv.cfg records uv = 0.10.2) and therefore has NO pip at all, so that second line cannot work against it. Replace the target with the uv path rather than patching pip back in — uv is the direction of travel for this initiative anyway, and installing pip into a uv venv just to satisfy a stale recipe recreates the interpreter fork the ticket exists to remove. Related: the project itself had never actually been installed before T-1258 — site-packages held the dependencies but no settled-reach-tooling dist-info, which is consistent with there being no __init__.py to expose.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-20 16:53:12.179', NULL, 'fe96cd641763396858e412c5af53b982', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S66GG3RZ3V2F9EXNSFW2G4', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Package skeleton: tooling/ becomes an importable package with a bounded core/', 'Turn tooling/ into a real package without moving anything yet. Deliverables: tooling/__init__.py (deliberately empty — every import here is paid by every reach invocation including four per push); tooling/core/__init__.py stating the bound (only what has no domain; the moment a core module grows a service it moves out); tooling/core/config.py resolving the repo root from __file__ with a project.yaml sentinel check and an SR_REPO_ROOT override, no subprocess and no git call since this is on the gate path; tooling/core/console.py as the SINGLE output path, stdlib only, out() to stdout and err() to stderr, no rich and no typer import so it stays cheap; tooling/domains/__init__.py. Also configure setuptools explicitly rather than relying on flat-layout auto-discovery — [tool.setuptools.packages.find] with include tooling*. Note the four hyphenated directories planet-gen, economy-db, garment-fit and pql-migrate are invisible to package discovery because a hyphen is not a valid identifier, and tooling/econ-sim is a Rust crate; none need explicit exclusion until E3 renames them, at which point they need __init__.py. Acceptance: pip install -e . succeeds, python -c import tooling.core.config works, ruff clean.
AMENDED 2026-08-20 — core/console.py changes shape BEFORE this ticket runs. The description above specs it as a print wrapper (stdlib only, out() to stdout, err() to stderr). D-263 was amended the same day with a streaming execution model, so console.py is the EVENT EMITTER instead: it emits JSONL events, one object per line, fields ts/level/phase/message and optional progress, to STDERR — stdout stays reserved for the command actual output so reach ... | jq keeps working. Still the single output path and still stdlib-only, since json is stdlib. Rendering to human-readable text happens at the sink, not at the emit site, which is what lets the same artefact serve a person tailing a log and a conformance test asserting on it. Two properties to get right here because everything downstream depends on them: (1) emitting is OPTIONAL — a command that emits no progress events must work normally, and the gates should emit none, so this is a channel not an obligation; (2) the event stream never replaces the final verdict — outcome, exit code and remedy are still printed once, last. See E9 (T-1264) for the job log and tail, which consume what this emits.
DONE 2026-08-20. Package skeleton landed and verified. Findings and deviations below, since several affect later tickets.
VERIFIED (each check could have failed, and the failure paths were exercised):
- uv pip install -e . succeeds; dist-info top_level.txt contains exactly "tooling" — discovery did NOT pull in client/, server/, docs/, wiki/, db/ or tests/, which was the risk that made explicit config necessary.
- import tooling.core.config, tooling.core.console, tooling.domains all succeed.
- console non-TTY: JSONL on stderr, command output alone on stdout (proved by redirecting stderr to /dev/null and seeing only DATA).
- console SR_OUTPUT_FORMAT=text: human rendering, phase prefix, percentage, and the verdict printing its Fix line last.
- debug-level events suppressed at the default threshold; verdict never suppressed.
- config sentinel REJECTS a bad override: SR_REPO_ROOT=/tmp raises and names both remedies.
- ruff check tooling/ clean (full tree, as the push gate runs it).
- No regression to the existing scripts: check-client-version, check-canvas-version and check-dataflow-graph all still pass with tooling/ now a package.
- Import cost: tooling.core.config 0.4 ms, tooling.core.console 3.6 ms — of which 1.9 ms is typing, which typer and click both import anyway, so it is free in any real invocation.
DEVIATION FROM THE WRITTEN ACCEPTANCE — pip is not available.
The acceptance says "pip install -e . succeeds". There is no pip in .venv: pyvenv.cfg records uv = 0.10.2, so the venv was made by uv, which does not install pip. Acceptance was met with `uv pip install -e . --python .venv/bin/python` instead. This is the direction of travel per D-263 anyway.
FINDING FOR T-1261 (the make target) — make setup-venv is STALE.
It runs `python3 -m venv .venv` then `.venv/bin/pip install -e ".[dev]" --quiet`. Neither matches the venv on disk, and the pip line cannot work against it. This is precisely the venv split Q-124 named: the recorded procedure and the actual state have already diverged. T-1261 owns the make target and should replace it with the uv path rather than patch pip back in.
FINDING — the project had never actually been installed.
Before this ticket site-packages held the DEPENDENCIES but no settled-reach-tooling dist-info and no .pth for it. That is consistent: with no __init__.py anywhere, an editable install had no package to expose. So this is the first commit where `import tooling` means anything.
DELIBERATE OMISSION — err() was dropped.
The original description asked for out() to stdout and err() to stderr. The amendment made console the event emitter, and a raw-text err() would then be a SECOND output path competing with the event stream — exactly the drift D-263 forbids by naming console the single one. event(level="error") and verdict(ok=False) cover every case err() would have. Flagging it because the word appears in the description and its absence is intentional, not an oversight.
CARRIED TO T-1249 — config raises RuntimeError, not ReachError.
core/errors.py does not exist yet, so _validated raises RuntimeError with an instructional message that already follows the contract (it names the fix). One call site to convert when T-1249 lands; there is a comment at the raise saying so.
INCIDENTAL — .gitignore.
Added *.egg-info/ (build output the install creates). Also added .venv/, which was only ignored via .git/info/exclude — machine-local, so a fresh clone or a new worktree would not have ignored it.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:12.032', '2026-08-20 16:53:51.369', NULL, '90e67d766cb10aff69fbc9eaa35377f4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S67EQ8EQTT2E9E3CGTXS7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The reach entrypoint: [project.scripts] plus typer and pydantic', 'pyproject.toml currently has NO [project.scripts] at all — no console entrypoint is defined anywhere. Adding one plus the two dependencies is the whole delta. Deliverables: [project.scripts] reach = tooling.main:cli; typer and pydantic added to [project] dependencies, both version-pinned and CVE-checked against NVD plus OSV the way scipy and Pillow already are in this file, with the check date in a comment since that is the established convention here. NOTE the entrypoint target is main:cli not main:app — see the lazy-registration task; the root is a click Group subclass rather than a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and because keeping typer off the --help path is worth having. Acceptance: .venv/bin/reach --help runs. This is the venv-local proof only; the bare-name-on-PATH guarantee is a separate task and is the one that actually matters.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:19.770', '2026-08-20 16:54:55.954', NULL, '737668978ade0231373d65c11a6bdfb1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S67EQ8EQTT2E9E3CGTXS7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The reach entrypoint: [project.scripts] plus typer and pydantic', 'pyproject.toml currently has NO [project.scripts] at all — no console entrypoint is defined anywhere. Adding one plus the two dependencies is the whole delta. Deliverables: [project.scripts] reach = tooling.main:cli; typer and pydantic added to [project] dependencies, both version-pinned and CVE-checked against NVD plus OSV the way scipy and Pillow already are in this file, with the check date in a comment since that is the established convention here. NOTE the entrypoint target is main:cli not main:app — see the lazy-registration task; the root is a click Group subclass rather than a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and because keeping typer off the --help path is worth having. Acceptance: .venv/bin/reach --help runs. This is the venv-local proof only; the bare-name-on-PATH guarantee is a separate task and is the one that actually matters.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:19.770', '2026-08-20 16:55:17.084', NULL, '2ebb34aa80cde666bae90bc287c3cba7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S67EQ8EQTT2E9E3CGTXS7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The reach entrypoint: [project.scripts] plus typer and pydantic', 'pyproject.toml currently has NO [project.scripts] at all — no console entrypoint is defined anywhere. Adding one plus the two dependencies is the whole delta. Deliverables: [project.scripts] reach = tooling.main:cli; typer and pydantic added to [project] dependencies, both version-pinned and CVE-checked against NVD plus OSV the way scipy and Pillow already are in this file, with the check date in a comment since that is the established convention here. NOTE the entrypoint target is main:cli not main:app — see the lazy-registration task; the root is a click Group subclass rather than a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and because keeping typer off the --help path is worth having. Acceptance: .venv/bin/reach --help runs. This is the venv-local proof only; the bare-name-on-PATH guarantee is a separate task and is the one that actually matters.
DONE 2026-08-23. Entrypoint works: .venv/bin/reach --help runs in 80 ms. But the design in the description above is WRONG in one load-bearing way and had to change.
TYPER VENDORS CLICK — the click.Group root is impossible.
The description specifies main:cli as a click Group subclass, on the reasoning that it keeps typer off the --help path. That cannot be done. As of typer 0.26.0 Click is VENDORED into typer as typer._click: no top-level click package is installed at all, and the docs are explicit that extracting the internal Click app is no longer supported. A real click.Group root hosting typer sub-apps would mean two different Click implementations in one process. Discovered the direct way — main.py imported click and reach --help died with ModuleNotFoundError.
Replacement, and it is the supported surface: the root is a typer.Typer, and lazy registration goes through typer.Typer(cls=...) with a typer.core.TyperGroup subclass. Verified TyperGroup subclasses typer._click.core.Command and that cls= composes. T-1260 builds the lazy group on that surface, NOT on click.Group.
THE ZERO-COMMAND CLAIM WAS HALF RIGHT.
The description says a Typer root with zero eagerly-registered commands raises at build time. Tested both ways: with no commands AND no callback it does raise (RuntimeError: Could not get a command for this Typer instance); with a callback and no commands it builds fine and prints help. So the empty root is legal as long as it carries a callback, which main.py now has. That matters because lazy registration means NO command is ever eager.
rich_markup_mode=None IS LOAD-BEARING, NOT COSMETIC.
An empty --help costs 168 ms with typer default rich rendering and 74 ms with rich disabled — 94 ms, 56 percent of runtime, for an empty CLI. Verified rich and pygments are then absent from sys.modules entirely, so this removes them from the import path rather than merely skipping the render. It also stops typer drawing box-art help, which it does EVEN WHEN STDOUT IS A PIPE — that would have put box-drawing characters into every hook log and agent capture. One line to revert if the boxes are ever worth more than the milliseconds.
typer-slim IS NOT THE ANSWER (checked, since rich is unused).
Deprecated as of typer 0.22.0 and now a shallow wrapper that installs all of typer, so depending on it buys nothing. Dependency stays plain typer; rich ships transitively but is never imported.
SECURITY — both direct deps and the transitive set checked 2026-08-23 against NVD, OSV and the GitHub Advisory Database:
- typer 0.27.1: no advisories on record at all.
- pydantic 2.13.4: clean. PYSEC-2026-1812 / CVE-2024-3772 (email-regex ReDoS) is fixed in 2.4.0. NOTE the 2026 SSRF advisories CVE-2026-25580 and CVE-2026-54249 are against pydantic-ai, a DIFFERENT package that is not a dependency here — recorded in pyproject so the next sweep does not re-panic.
- transitive: pygments 2.21.0 clears CVE-2026-4539 (archetype-lexer ReDoS, fixed 2.20.0); rich and click have no advisories on record.
TIMING, for the record: 80 ms for reach --help with zero domains registered. Interpreter startup is 27 ms of that and typer import is ~42 ms. Against the D-263 ceiling — raised to 500 ms on 2026-08-23 — that leaves real headroom for the domains to come.
BARE `reach` EXITS 2. no_args_is_help=True prints help and exits 2 (Click usage-error convention). Flagging rather than changing it: that is standard for no-command-given, and D-263''s exit-code contract is about FAILURES carrying a remedy, which this is not. Revisit in T-1260 if the domain list should exit 0.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:19.770', '2026-08-23 12:00:53.737', NULL, '1b39db8971c19cd69a747c87014ed058', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — --help must not import the tree', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 — the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn — see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms — about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.
AMENDED 2026-08-23 from T-1259 — the base class in the description is WRONG and this ticket cannot be built as written.
The description specifies LazyDomainGroup(click.Group). There is no top-level click package: typer VENDORS click as of 0.26.0 (as typer._click), and the docs are explicit that extracting the internal Click app is unsupported. Adding click as a direct dependency would be worse, not better — two Click implementations in one process, with typer.main.get_command returning a typer._click Command that a real click.Group cannot host.
CORRECTED DESIGN, verified working in T-1259:
- The root is `cli = typer.Typer(cls=LazyDomainGroup, ...)` in tooling/main.py — already in place.
- LazyDomainGroup subclasses **typer.core.TyperGroup**, not click.Group. Confirmed TyperGroup''s MRO is (TyperGroup, typer._click.core.Command, ABC) and that cls= composes.
- Override list_commands, get_command and format_commands on that class. The format_commands trap is UNCHANGED and is still the heart of this ticket: the base implementation calls get_command for every subcommand to render short help, which would import every domain just to print --help. It must read short help from the DOMAINS declaration table instead.
- get_command resolves the invoked domain via importlib and converts the domain''s Typer sub-app with typer.main.get_command.
- The sys.modules acceptance test is unchanged and is still the point: after `reach --help`, no domain service module may appear in sys.modules. Extend it to assert `rich` and `pygments` are absent too — that is currently true and is worth pinning, since it is one careless kwarg away from regressing.
ALREADY DONE IN T-1259, do not redo:
- main.py exists with the Typer root and a callback. The callback is REQUIRED: a Typer root with no commands and no callback raises RuntimeError("Could not get a command for this Typer instance") at build time, and lazy registration means no command is ever eager.
- rich_markup_mode=None is set and is load-bearing — worth 94 ms of a 168 ms empty --help, and it keeps rich and pygments off the import path. Do not remove it while adding the lazy group.
OPEN QUESTION carried here: bare `reach` currently exits 2 (Click''s usage-error convention via no_args_is_help). Once domains are registered and a bare `reach` prints a useful domain list, decide whether that should exit 0 instead. It is not a failure, and D-263''s exit-code contract concerns failures carrying a remedy.
BUDGET NOTE: the D-263 ceiling was raised to ~500 ms on 2026-08-23. Current cost of `reach --help` with zero domains is 80 ms. Stay smart about what loads; do not contort for tightness.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-23 12:01:12.824', NULL, '9f7e2efceadf8ec6d46fd5094d1564ed', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S67EQ8EQTT2E9E3CGTXS7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The reach entrypoint: [project.scripts] plus typer and pydantic', 'pyproject.toml currently has NO [project.scripts] at all — no console entrypoint is defined anywhere. Adding one plus the two dependencies is the whole delta. Deliverables: [project.scripts] reach = tooling.main:cli; typer and pydantic added to [project] dependencies, both version-pinned and CVE-checked against NVD plus OSV the way scipy and Pillow already are in this file, with the check date in a comment since that is the established convention here. NOTE the entrypoint target is main:cli not main:app — see the lazy-registration task; the root is a click Group subclass rather than a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and because keeping typer off the --help path is worth having. Acceptance: .venv/bin/reach --help runs. This is the venv-local proof only; the bare-name-on-PATH guarantee is a separate task and is the one that actually matters.
DONE 2026-08-23. Entrypoint works: .venv/bin/reach --help runs in 80 ms. But the design in the description above is WRONG in one load-bearing way and had to change.
TYPER VENDORS CLICK — the click.Group root is impossible.
The description specifies main:cli as a click Group subclass, on the reasoning that it keeps typer off the --help path. That cannot be done. As of typer 0.26.0 Click is VENDORED into typer as typer._click: no top-level click package is installed at all, and the docs are explicit that extracting the internal Click app is no longer supported. A real click.Group root hosting typer sub-apps would mean two different Click implementations in one process. Discovered the direct way — main.py imported click and reach --help died with ModuleNotFoundError.
Replacement, and it is the supported surface: the root is a typer.Typer, and lazy registration goes through typer.Typer(cls=...) with a typer.core.TyperGroup subclass. Verified TyperGroup subclasses typer._click.core.Command and that cls= composes. T-1260 builds the lazy group on that surface, NOT on click.Group.
THE ZERO-COMMAND CLAIM WAS HALF RIGHT.
The description says a Typer root with zero eagerly-registered commands raises at build time. Tested both ways: with no commands AND no callback it does raise (RuntimeError: Could not get a command for this Typer instance); with a callback and no commands it builds fine and prints help. So the empty root is legal as long as it carries a callback, which main.py now has. That matters because lazy registration means NO command is ever eager.
rich_markup_mode=None IS LOAD-BEARING, NOT COSMETIC.
An empty --help costs 168 ms with typer default rich rendering and 74 ms with rich disabled — 94 ms, 56 percent of runtime, for an empty CLI. Verified rich and pygments are then absent from sys.modules entirely, so this removes them from the import path rather than merely skipping the render. It also stops typer drawing box-art help, which it does EVEN WHEN STDOUT IS A PIPE — that would have put box-drawing characters into every hook log and agent capture. One line to revert if the boxes are ever worth more than the milliseconds.
typer-slim IS NOT THE ANSWER (checked, since rich is unused).
Deprecated as of typer 0.22.0 and now a shallow wrapper that installs all of typer, so depending on it buys nothing. Dependency stays plain typer; rich ships transitively but is never imported.
SECURITY — both direct deps and the transitive set checked 2026-08-23 against NVD, OSV and the GitHub Advisory Database:
- typer 0.27.1: no advisories on record at all.
- pydantic 2.13.4: clean. PYSEC-2026-1812 / CVE-2024-3772 (email-regex ReDoS) is fixed in 2.4.0. NOTE the 2026 SSRF advisories CVE-2026-25580 and CVE-2026-54249 are against pydantic-ai, a DIFFERENT package that is not a dependency here — recorded in pyproject so the next sweep does not re-panic.
- transitive: pygments 2.21.0 clears CVE-2026-4539 (archetype-lexer ReDoS, fixed 2.20.0); rich and click have no advisories on record.
TIMING, for the record: 80 ms for reach --help with zero domains registered. Interpreter startup is 27 ms of that and typer import is ~42 ms. Against the D-263 ceiling — raised to 500 ms on 2026-08-23 — that leaves real headroom for the domains to come.
BARE `reach` EXITS 2. no_args_is_help=True prints help and exits 2 (Click usage-error convention). Flagging rather than changing it: that is standard for no-command-given, and D-263''s exit-code contract is about FAILURES carrying a remedy, which this is not. Revisit in T-1260 if the domain list should exit 0.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:19.770', '2026-08-23 12:01:35.203', NULL, 'c23886b83a683b3de7c97acf77098fb0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — --help must not import the tree', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 — the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn — see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms — about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.
AMENDED 2026-08-23 from T-1259 — the base class in the description is WRONG and this ticket cannot be built as written.
The description specifies LazyDomainGroup(click.Group). There is no top-level click package: typer VENDORS click as of 0.26.0 (as typer._click), and the docs are explicit that extracting the internal Click app is unsupported. Adding click as a direct dependency would be worse, not better — two Click implementations in one process, with typer.main.get_command returning a typer._click Command that a real click.Group cannot host.
CORRECTED DESIGN, verified working in T-1259:
- The root is `cli = typer.Typer(cls=LazyDomainGroup, ...)` in tooling/main.py — already in place.
- LazyDomainGroup subclasses **typer.core.TyperGroup**, not click.Group. Confirmed TyperGroup''s MRO is (TyperGroup, typer._click.core.Command, ABC) and that cls= composes.
- Override list_commands, get_command and format_commands on that class. The format_commands trap is UNCHANGED and is still the heart of this ticket: the base implementation calls get_command for every subcommand to render short help, which would import every domain just to print --help. It must read short help from the DOMAINS declaration table instead.
- get_command resolves the invoked domain via importlib and converts the domain''s Typer sub-app with typer.main.get_command.
- The sys.modules acceptance test is unchanged and is still the point: after `reach --help`, no domain service module may appear in sys.modules. Extend it to assert `rich` and `pygments` are absent too — that is currently true and is worth pinning, since it is one careless kwarg away from regressing.
ALREADY DONE IN T-1259, do not redo:
- main.py exists with the Typer root and a callback. The callback is REQUIRED: a Typer root with no commands and no callback raises RuntimeError("Could not get a command for this Typer instance") at build time, and lazy registration means no command is ever eager.
- rich_markup_mode=None is set and is load-bearing — worth 94 ms of a 168 ms empty --help, and it keeps rich and pygments off the import path. Do not remove it while adding the lazy group.
OPEN QUESTION carried here: bare `reach` currently exits 2 (Click''s usage-error convention via no_args_is_help). Once domains are registered and a bare `reach` prints a useful domain list, decide whether that should exit 0 instead. It is not a failure, and D-263''s exit-code contract concerns failures carrying a remedy.
BUDGET NOTE: the D-263 ceiling was raised to ~500 ms on 2026-08-23. Current cost of `reach --help` with zero domains is 80 ms. Stay smart about what loads; do not contort for tightness.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-23 12:02:01.993', NULL, 'b60b13ca2f5cf299e3baf03fa108cf28', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — --help must not import the tree', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 — the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn — see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms — about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.
AMENDED 2026-08-23 from T-1259 — the base class in the description is WRONG and this ticket cannot be built as written.
The description specifies LazyDomainGroup(click.Group). There is no top-level click package: typer VENDORS click as of 0.26.0 (as typer._click), and the docs are explicit that extracting the internal Click app is unsupported. Adding click as a direct dependency would be worse, not better — two Click implementations in one process, with typer.main.get_command returning a typer._click Command that a real click.Group cannot host.
CORRECTED DESIGN, verified working in T-1259:
- The root is `cli = typer.Typer(cls=LazyDomainGroup, ...)` in tooling/main.py — already in place.
- LazyDomainGroup subclasses **typer.core.TyperGroup**, not click.Group. Confirmed TyperGroup''s MRO is (TyperGroup, typer._click.core.Command, ABC) and that cls= composes.
- Override list_commands, get_command and format_commands on that class. The format_commands trap is UNCHANGED and is still the heart of this ticket: the base implementation calls get_command for every subcommand to render short help, which would import every domain just to print --help. It must read short help from the DOMAINS declaration table instead.
- get_command resolves the invoked domain via importlib and converts the domain''s Typer sub-app with typer.main.get_command.
- The sys.modules acceptance test is unchanged and is still the point: after `reach --help`, no domain service module may appear in sys.modules. Extend it to assert `rich` and `pygments` are absent too — that is currently true and is worth pinning, since it is one careless kwarg away from regressing.
ALREADY DONE IN T-1259, do not redo:
- main.py exists with the Typer root and a callback. The callback is REQUIRED: a Typer root with no commands and no callback raises RuntimeError("Could not get a command for this Typer instance") at build time, and lazy registration means no command is ever eager.
- rich_markup_mode=None is set and is load-bearing — worth 94 ms of a 168 ms empty --help, and it keeps rich and pygments off the import path. Do not remove it while adding the lazy group.
OPEN QUESTION carried here: bare `reach` currently exits 2 (Click''s usage-error convention via no_args_is_help). Once domains are registered and a bare `reach` prints a useful domain list, decide whether that should exit 0 instead. It is not a failure, and D-263''s exit-code contract concerns failures carrying a remedy.
BUDGET NOTE: the D-263 ceiling was raised to ~500 ms on 2026-08-23. Current cost of `reach --help` with zero domains is 80 ms. Stay smart about what loads; do not contort for tightness.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-23 12:02:25.470', NULL, 'f7f81d09d9939736e2dc1161f3240b89', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — --help must not import the tree', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 — the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn — see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms — about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.
AMENDED 2026-08-23 from T-1259 — the base class in the description is WRONG and this ticket cannot be built as written.
The description specifies LazyDomainGroup(click.Group). There is no top-level click package: typer VENDORS click as of 0.26.0 (as typer._click), and the docs are explicit that extracting the internal Click app is unsupported. Adding click as a direct dependency would be worse, not better — two Click implementations in one process, with typer.main.get_command returning a typer._click Command that a real click.Group cannot host.
CORRECTED DESIGN, verified working in T-1259:
- The root is `cli = typer.Typer(cls=LazyDomainGroup, ...)` in tooling/main.py — already in place.
- LazyDomainGroup subclasses **typer.core.TyperGroup**, not click.Group. Confirmed TyperGroup''s MRO is (TyperGroup, typer._click.core.Command, ABC) and that cls= composes.
- Override list_commands, get_command and format_commands on that class. The format_commands trap is UNCHANGED and is still the heart of this ticket: the base implementation calls get_command for every subcommand to render short help, which would import every domain just to print --help. It must read short help from the DOMAINS declaration table instead.
- get_command resolves the invoked domain via importlib and converts the domain''s Typer sub-app with typer.main.get_command.
- The sys.modules acceptance test is unchanged and is still the point: after `reach --help`, no domain service module may appear in sys.modules. Extend it to assert `rich` and `pygments` are absent too — that is currently true and is worth pinning, since it is one careless kwarg away from regressing.
ALREADY DONE IN T-1259, do not redo:
- main.py exists with the Typer root and a callback. The callback is REQUIRED: a Typer root with no commands and no callback raises RuntimeError("Could not get a command for this Typer instance") at build time, and lazy registration means no command is ever eager.
- rich_markup_mode=None is set and is load-bearing — worth 94 ms of a 168 ms empty --help, and it keeps rich and pygments off the import path. Do not remove it while adding the lazy group.
OPEN QUESTION carried here: bare `reach` currently exits 2 (Click''s usage-error convention via no_args_is_help). Once domains are registered and a bare `reach` prints a useful domain list, decide whether that should exit 0 instead. It is not a failure, and D-263''s exit-code contract concerns failures carrying a remedy.
BUDGET NOTE: the D-263 ceiling was raised to ~500 ms on 2026-08-23. Current cost of `reach --help` with zero domains is 80 ms. Stay smart about what loads; do not contort for tightness.
DONE 2026-08-25. Lazy registration works and is guarded by a test that has been proven to fail.
THE TRAP IS REAL — confirmed in the vendored source, not assumed from upstream Click.
typer.core.TyperGroup.format_commands loops over list_commands calling get_command on each, purely to read get_short_help_str off the loaded command. With lazy loading underneath that imports every domain in the registry to render --help, while the output looks entirely correct. Overridden to read short help from the DOMAINS table and never touch get_command.
Note on the base class: TyperGroup''s MRO is (TyperGroup, Command, ABC, object) — typer''s vendored click has NO separate Group/MultiCommand class, everything is collapsed into Command, and TyperGroup defines list_commands/get_command/format_commands itself. So the overrides sit directly on TyperGroup as the amendment said.
DELIVERED
- tooling/main.py: DOMAINS registry (name -> import target, short help) and LazyDomainGroup(TyperGroup) overriding list_commands, get_command, format_commands. _load_domain resolves via importlib and typer.main.get_command only for the domain actually invoked.
- tooling/domains/check/: __init__.py, router.py, service.py, schemas.py — the check domain, registered so the mechanism has a subject.
- tooling/test_lazy_domains.py, wired into make test-tooling.
THE TEST WAS PROVEN TO FAIL, which is the only reason its pass means anything. Disabled the format_commands override and re-ran: it failed and named the cause, listing tooling.domains, .check, .check.router, .check.schemas and .check.service as leaked. Restored, green again. Three properties: (1) nothing under tooling.domains in sys.modules after --help; (2) no heavy third-party module (rich, pygments, numpy, scipy, pydantic, PIL); (3) POSITIVE CONTROL — invoking check client-version DOES import the service, without which properties 1 and 2 would also pass for a loader that is simply broken and imports nothing ever. The test also fails on an empty DOMAINS registry, which would otherwise satisfy everything vacuously. Probes run in a FRESH subprocess, since the assertion is about a module graph and an in-process check would inherit whatever the harness already imported.
SCOPE OVERLAP WITH T-1262 — READ BEFORE PICKING THAT UP.
The acceptance test needs a real domain or it asserts nothing, so this ticket created the check domain rather than a stub (stub code that raises NotImplementedError would have been committed dead code). check/service.py now implements client_version for real, ported from tooling/check-client-version, and router.py has the client-version verb. What T-1262 STILL OWNS, unchanged: converting schemas.py from the stdlib dataclass to pydantic; byte-for-byte output-parity verification against the old script on both the OK and drift paths including exit codes; and the failure-path tests. T-1262 should be re-read as a hardening ticket rather than a port.
TYPER GOTCHA worth knowing before every future domain: Typer COLLAPSES a single-command app into a bare command. reach check client-version failed with "Got unexpected extra argument(s) (client-version)" until the check router got an @app.callback(). Every domain router needs that callback until it has two or more verbs, and keeping it afterwards costs nothing and stops the shape changing under you. Same mechanism as the root callback from T-1259, different symptom.
HELP AT EVERY LEVEL now works, which closes item (5) of the parent epic T-1248: reach --help lists domains under a "Domains" heading, reach check --help lists verbs under "Commands".
TIMING: reach --help is 98 ms with one domain registered, against 80 ms with none. The registry itself is the only growth; the point of the override is that this stays flat as domains are added. D-263 ceiling is 500 ms.
STILL OPEN, carried forward: bare `reach` exits 2 via no_args_is_help (Click''s usage-error convention). It now prints a genuinely useful domain list, so exit 0 is arguably more honest — it is not a failure. Left as-is rather than changed silently; belongs with T-1249''s error contract work.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-25 05:30:54.478', NULL, '429368bce7b536f612270d52288b05ec', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-25 05:31:01.531', NULL, '22e7c3529424eda485dc84e15561343a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S685APSPZRXSM9F62DTKGM', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Lazy domain registration — --help must not import the tree', 'reach --help must list every domain WITHOUT importing any of them. This is not an optimisation; it is the mechanism that keeps the 104 ms push-gate budget reachable, and it has one specific trap. Click Group.format_commands calls get_command for every subcommand to render its short help — which imports all 123 modules just to print help. So format_commands MUST be overridden to read short help from a declaration table rather than from the loaded command. Deliverables: tooling/main.py holding a DOMAINS table mapping name to (import_path, short_help); a LazyDomainGroup(click.Group) overriding list_commands, get_command and format_commands; get_command resolving via importlib and typer.main.get_command only for the domain actually invoked. main.py is a router and nothing else — no logic, no I/O, no pydantic, no domain imports at module level. Acceptance is a TEST, not an eyeball: run reach --help in a subprocess and assert no tooling.domains.<name>.service module is in sys.modules afterwards. That test is the whole point of the ticket and belongs with it, not in E8.
AMENDED 2026-08-20 — the justification changes, the requirement does not. Lazy registration is still mandatory, but NOT because of a 104 ms parity budget (withdrawn — see D-263 amendment and T-1251). It is mandatory because of what an eager entrypoint would actually cost: measured in the repo venv, scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms — about 460 ms paid before executing a line of its own, growing every time a domain is added. The format_commands trap and the sys.modules acceptance test are unchanged and are the heart of this ticket.
AMENDED 2026-08-23 from T-1259 — the base class in the description is WRONG and this ticket cannot be built as written.
The description specifies LazyDomainGroup(click.Group). There is no top-level click package: typer VENDORS click as of 0.26.0 (as typer._click), and the docs are explicit that extracting the internal Click app is unsupported. Adding click as a direct dependency would be worse, not better — two Click implementations in one process, with typer.main.get_command returning a typer._click Command that a real click.Group cannot host.
CORRECTED DESIGN, verified working in T-1259:
- The root is `cli = typer.Typer(cls=LazyDomainGroup, ...)` in tooling/main.py — already in place.
- LazyDomainGroup subclasses **typer.core.TyperGroup**, not click.Group. Confirmed TyperGroup''s MRO is (TyperGroup, typer._click.core.Command, ABC) and that cls= composes.
- Override list_commands, get_command and format_commands on that class. The format_commands trap is UNCHANGED and is still the heart of this ticket: the base implementation calls get_command for every subcommand to render short help, which would import every domain just to print --help. It must read short help from the DOMAINS declaration table instead.
- get_command resolves the invoked domain via importlib and converts the domain''s Typer sub-app with typer.main.get_command.
- The sys.modules acceptance test is unchanged and is still the point: after `reach --help`, no domain service module may appear in sys.modules. Extend it to assert `rich` and `pygments` are absent too — that is currently true and is worth pinning, since it is one careless kwarg away from regressing.
ALREADY DONE IN T-1259, do not redo:
- main.py exists with the Typer root and a callback. The callback is REQUIRED: a Typer root with no commands and no callback raises RuntimeError("Could not get a command for this Typer instance") at build time, and lazy registration means no command is ever eager.
- rich_markup_mode=None is set and is load-bearing — worth 94 ms of a 168 ms empty --help, and it keeps rich and pygments off the import path. Do not remove it while adding the lazy group.
OPEN QUESTION carried here: bare `reach` currently exits 2 (Click''s usage-error convention via no_args_is_help). Once domains are registered and a bare `reach` prints a useful domain list, decide whether that should exit 0 instead. It is not a failure, and D-263''s exit-code contract concerns failures carrying a remedy.
BUDGET NOTE: the D-263 ceiling was raised to ~500 ms on 2026-08-23. Current cost of `reach --help` with zero domains is 80 ms. Stay smart about what loads; do not contort for tightness.
DONE 2026-08-25. Lazy registration works and is guarded by a test that has been proven to fail.
THE TRAP IS REAL — confirmed in the vendored source, not assumed from upstream Click.
typer.core.TyperGroup.format_commands loops over list_commands calling get_command on each, purely to read get_short_help_str off the loaded command. With lazy loading underneath that imports every domain in the registry to render --help, while the output looks entirely correct. Overridden to read short help from the DOMAINS table and never touch get_command.
Note on the base class: TyperGroup''s MRO is (TyperGroup, Command, ABC, object) — typer''s vendored click has NO separate Group/MultiCommand class, everything is collapsed into Command, and TyperGroup defines list_commands/get_command/format_commands itself. So the overrides sit directly on TyperGroup as the amendment said.
DELIVERED
- tooling/main.py: DOMAINS registry (name -> import target, short help) and LazyDomainGroup(TyperGroup) overriding list_commands, get_command, format_commands. _load_domain resolves via importlib and typer.main.get_command only for the domain actually invoked.
- tooling/domains/check/: __init__.py, router.py, service.py, schemas.py — the check domain, registered so the mechanism has a subject.
- tooling/test_lazy_domains.py, wired into make test-tooling.
THE TEST WAS PROVEN TO FAIL, which is the only reason its pass means anything. Disabled the format_commands override and re-ran: it failed and named the cause, listing tooling.domains, .check, .check.router, .check.schemas and .check.service as leaked. Restored, green again. Three properties: (1) nothing under tooling.domains in sys.modules after --help; (2) no heavy third-party module (rich, pygments, numpy, scipy, pydantic, PIL); (3) POSITIVE CONTROL — invoking check client-version DOES import the service, without which properties 1 and 2 would also pass for a loader that is simply broken and imports nothing ever. The test also fails on an empty DOMAINS registry, which would otherwise satisfy everything vacuously. Probes run in a FRESH subprocess, since the assertion is about a module graph and an in-process check would inherit whatever the harness already imported.
SCOPE OVERLAP WITH T-1262 — READ BEFORE PICKING THAT UP.
The acceptance test needs a real domain or it asserts nothing, so this ticket created the check domain rather than a stub (stub code that raises NotImplementedError would have been committed dead code). check/service.py now implements client_version for real, ported from tooling/check-client-version, and router.py has the client-version verb. What T-1262 STILL OWNS, unchanged: converting schemas.py from the stdlib dataclass to pydantic; byte-for-byte output-parity verification against the old script on both the OK and drift paths including exit codes; and the failure-path tests. T-1262 should be re-read as a hardening ticket rather than a port.
TYPER GOTCHA worth knowing before every future domain: Typer COLLAPSES a single-command app into a bare command. reach check client-version failed with "Got unexpected extra argument(s) (client-version)" until the check router got an @app.callback(). Every domain router needs that callback until it has two or more verbs, and keeping it afterwards costs nothing and stops the shape changing under you. Same mechanism as the root callback from T-1259, different symptom.
HELP AT EVERY LEVEL now works, which closes item (5) of the parent epic T-1248: reach --help lists domains under a "Domains" heading, reach check --help lists verbs under "Commands".
TIMING: reach --help is 98 ms with one domain registered, against 80 ms with none. The registry itself is the only growth; the point of the override is that this stays flat as domains are added. D-263 ceiling is 500 ms.
STILL OPEN, carried forward: bare `reach` exits 2 via no_args_is_help (Click''s usage-error convention). It now prints a genuinely useful domain list, so exit 0 is arguably more honest — it is not a failure. Left as-is rather than changed silently; belongs with T-1249''s error contract work.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:25.557', '2026-08-25 05:31:18.127', NULL, '0447ecc7be9154fb5b53cde3b97a39fc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same scar tea left: an absolute path breaks the Bash(tea *) rule and prompts every time; the fix was a bare name on PATH.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.
FINDING from T-1258 (2026-08-20): make setup-venv is already stale and this ticket owns the fix. It runs python3 -m venv .venv then .venv/bin/pip install -e .[dev] --quiet, but the venv on disk was created by uv (pyvenv.cfg records uv = 0.10.2) and therefore has NO pip at all, so that second line cannot work against it. Replace the target with the uv path rather than patching pip back in — uv is the direction of travel for this initiative anyway, and installing pip into a uv venv just to satisfy a stale recipe recreates the interpreter fork the ticket exists to remove. Related: the project itself had never actually been installed before T-1258 — site-packages held the dependencies but no settled-reach-tooling dist-info, which is consistent with there being no __init__.py to expose.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-25 18:21:38.902', NULL, '728d72f471f44ecc6ab1d591fdefb577', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same scar tea left: an absolute path breaks the Bash(tea *) rule and prompts every time; the fix was a bare name on PATH.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.
FINDING from T-1258 (2026-08-20): make setup-venv is already stale and this ticket owns the fix. It runs python3 -m venv .venv then .venv/bin/pip install -e .[dev] --quiet, but the venv on disk was created by uv (pyvenv.cfg records uv = 0.10.2) and therefore has NO pip at all, so that second line cannot work against it. Replace the target with the uv path rather than patching pip back in — uv is the direction of travel for this initiative anyway, and installing pip into a uv venv just to satisfy a stale recipe recreates the interpreter fork the ticket exists to remove. Related: the project itself had never actually been installed before T-1258 — site-packages held the dependencies but no settled-reach-tooling dist-info, which is consistent with there being no __init__.py to expose.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-25 18:21:50.604', NULL, '3649abd33f7c0aaddd173c33b83dba0f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same scar tea left: an absolute path breaks the Bash(tea *) rule and prompts every time; the fix was a bare name on PATH.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.
FINDING from T-1258 (2026-08-20): make setup-venv is already stale and this ticket owns the fix. It runs python3 -m venv .venv then .venv/bin/pip install -e .[dev] --quiet, but the venv on disk was created by uv (pyvenv.cfg records uv = 0.10.2) and therefore has NO pip at all, so that second line cannot work against it. Replace the target with the uv path rather than patching pip back in — uv is the direction of travel for this initiative anyway, and installing pip into a uv venv just to satisfy a stale recipe recreates the interpreter fork the ticket exists to remove. Related: the project itself had never actually been installed before T-1258 — site-packages held the dependencies but no settled-reach-tooling dist-info, which is consistent with there being no __init__.py to expose.
DONE 2026-08-30. `reach` is a bare name on PATH, verified in all three contexts with a negative control that actually fails.
VERIFICATION — the part this ticket said was the ticket.
Baseline first, so the checks could fail: VIRTUAL_ENV was unset in agent Bash calls and `reach` was NOT on PATH. Then after `uv tool install --python 3.14 --editable .`:
(a) Stripped non-interactive shell — `env -i HOME=$HOME PATH=$HOME/.local/bin:/usr/bin:/bin bash -c ''reach check client-version''` — resolves and runs. No profile sourcing, no venv.
(b) REAL git hook, not a simulation — wrote a scratch hook and ran `git -c core.hooksPath=.cache/hooktest hook run pre-push`. Inside the hook process: VIRTUAL_ENV unset, `command -v reach` -> ~/.local/bin/reach, check ran, exit 0. Scratch hook deleted afterwards.
(c) Agent Bash call — resolves at ~/.local/bin/reach with VIRTUAL_ENV unset.
NEGATIVE CONTROL: `env -i PATH=/usr/bin:/bin bash -c ''command -v reach''` -> NOT-FOUND. So the three passes above are discriminating, not vacuous, and confirm reach is not merely resolving because a venv happens to be active.
FOUND AND FIXED — a silent interpreter fork, which is this initiative''s own failure mode.
`uv tool install` without --python chose CPython 3.11 for the tool environment while .venv and system python3 are both 3.14.6. uv picks the lowest interpreter satisfying requires-python (">=3.11"). That is precisely the human-shell-versus-hook divergence D-263 exists to remove, just relocated: `reach` would have run on one interpreter and the test scripts on another, with different wheels for numpy/scipy/PIL, and any future 3.12+ syntax would break the tool while the venv stayed green. Reinstalled with --python 3.14 and introduced PYTHON_VERSION ?= 3.14 in the Makefile so setup-venv and install-reach share one pin. Verified the tool env is now lib/python3.14.
make setup-venv REBUILT (the T-1258 finding) — and the first fix was wrong.
Replaced `python3 -m venv` + `.venv/bin/pip` with the uv path. First attempt used plain `uv venv`, which FAILS on an existing venv ("A virtual environment already exists at .venv. Use --clear to replace it") — so the target was not idempotent, while the python3 -m venv version it replaced had been. Caught by actually running it twice rather than dry-running it, which is how the old one rotted unnoticed in the first place. Fixed with --allow-existing. Ran it twice, both clean, and test_lazy_domains still passes against the rebuilt venv.
DELIVERED
- make install-reach — wraps uv tool install with the pin and --editable, and SELF-CHECKS: fails loudly if `reach` is not on PATH afterwards, pointing at `uv tool update-shell`. Added to `make setup`.
- make reach-repoint — same command, but the situation has a name now. uv records the source path at install time, so an install made from a worktree keeps resolving there after the worktree is gone: `reach` still runs, edits in the main checkout do nothing, and there is NO error message. That silent-wrong-source failure is the reason this deserves its own target rather than a doc line.
- docs/DEVOPS.md — a Setup subsection covering why ~/.local/bin rather than .venv/bin, the bare-name rule, what --editable and --python buy, and the re-pointing trap.
- make test-tooling passes end to end (test-tooling: PASS).
NEGATIVE ACCEPTANCE CRITERION — verified manually, NOT yet automated.
`grep -rn "python -m tooling\|python3 -m tooling\|\.venv/bin/reach" Makefile .config/hooks .claude docs` returns nothing. The automated version belongs to T-1257''s conformance suite and has been recorded there with the exact pattern; deliberately not written here to avoid taking that ticket''s work.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-30 12:36:09.500', NULL, '4064e418e6602016571d63e8a0396d55', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S40293BXWK39D9HAXHM8Q4', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E8 — The tooling test surface, rewired', 'make test-tooling is the tooling half of the push gate and it runs five suites that ALL move during this initiative: planet-gen test_sim_determinism.py and test_oasis_ring_scaling.py (via VENV_PY), test_canvas_version_check.py, economy-db test_traits.py and test_atlas_idempotency.py, plus an import_economics --dry-run against the committed DB. Nobody owns rewiring them, so this epic does. Pieces that become tickets: (1) the five suites move with their domains and keep passing — they are the guard that the numerics did not shift under the relocation, so a green run before and after the move is the actual acceptance evidence; (2) VENV_PY disappears from the Makefile — the whole point of uv tool install is that there is no interpreter fork between a human shell and a hook, and every surviving VENV_PY is a place where that promise is not yet kept; (3) a home for service-level tests — the layering exists so services can be called directly without a CLI round trip, and if no test ever does that, the layering is unverified decoration; (4) the conformance test from E2 joins this gate rather than living alone; (5) the wall-clock budget assertion from E4 joins it too; (6) confirm the gate still FAILS on a broken tree — a test gate that only passes has never been tested. Watch: ruff config currently ignores E402, E702 and F841; the E402 debt is script-style sys.path.insert before imports and should largely vanish with a real package, so re-enable it here rather than carrying the ignore forever.
AMENDED 2026-08-20 — item (5), the wall-clock budget assertion from E4, is WITHDRAWN along with the 104 ms timing-parity budget it enforced (D-263 amendment, T-1251). It is replaced by the import-graph assertion: after reach --help, no domain service module and no heavy dependency (numpy, scipy, PIL, pydantic) may appear in sys.modules. That test is not flaky on a loaded machine and it names the offending import rather than reporting that something got slower.
FROM T-1261 (2026-08-30) — the negative-invocation check is yours, and it is currently unguarded. T-1261 verified by hand that nothing invokes the CLI through an interpreter or a path, but wrote no automated check, deliberately, so as not to take this ticket''s work. The exact command that passed: grep -rn for the patterns ''python -m tooling'', ''python3 -m tooling'' and ''.venv/bin/reach'' across Makefile, .config/hooks, .claude and docs — currently returns nothing. Automate that in the conformance suite. It matters most at T-1253, where 84 make targets become reach wrappers and one interpreter-prefixed invocation among them silently undoes the PATH guarantee. Also add PYTHON_VERSION drift detection while you are there: T-1261 found uv had installed the reach tool on CPython 3.11 while .venv and system python were 3.14, because uv picks the lowest interpreter satisfying requires-python. The Makefile now pins PYTHON_VERSION for both, but nothing detects the two environments diverging again — and that divergence is invisible until a version-specific bug appears in one and not the other.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:34.952', '2026-08-30 12:36:17.515', NULL, 'fd68c72105e921c0a7d1fefd4bd69240', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same scar tea left: an absolute path breaks the Bash(tea *) rule and prompts every time; the fix was a bare name on PATH.
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.
FINDING from T-1258 (2026-08-20): make setup-venv is already stale and this ticket owns the fix. It runs python3 -m venv .venv then .venv/bin/pip install -e .[dev] --quiet, but the venv on disk was created by uv (pyvenv.cfg records uv = 0.10.2) and therefore has NO pip at all, so that second line cannot work against it. Replace the target with the uv path rather than patching pip back in — uv is the direction of travel for this initiative anyway, and installing pip into a uv venv just to satisfy a stale recipe recreates the interpreter fork the ticket exists to remove. Related: the project itself had never actually been installed before T-1258 — site-packages held the dependencies but no settled-reach-tooling dist-info, which is consistent with there being no __init__.py to expose.
DONE 2026-08-30. `reach` is a bare name on PATH, verified in all three contexts with a negative control that actually fails.
VERIFICATION — the part this ticket said was the ticket.
Baseline first, so the checks could fail: VIRTUAL_ENV was unset in agent Bash calls and `reach` was NOT on PATH. Then after `uv tool install --python 3.14 --editable .`:
(a) Stripped non-interactive shell — `env -i HOME=$HOME PATH=$HOME/.local/bin:/usr/bin:/bin bash -c ''reach check client-version''` — resolves and runs. No profile sourcing, no venv.
(b) REAL git hook, not a simulation — wrote a scratch hook and ran `git -c core.hooksPath=.cache/hooktest hook run pre-push`. Inside the hook process: VIRTUAL_ENV unset, `command -v reach` -> ~/.local/bin/reach, check ran, exit 0. Scratch hook deleted afterwards.
(c) Agent Bash call — resolves at ~/.local/bin/reach with VIRTUAL_ENV unset.
NEGATIVE CONTROL: `env -i PATH=/usr/bin:/bin bash -c ''command -v reach''` -> NOT-FOUND. So the three passes above are discriminating, not vacuous, and confirm reach is not merely resolving because a venv happens to be active.
FOUND AND FIXED — a silent interpreter fork, which is this initiative''s own failure mode.
`uv tool install` without --python chose CPython 3.11 for the tool environment while .venv and system python3 are both 3.14.6. uv picks the lowest interpreter satisfying requires-python (">=3.11"). That is precisely the human-shell-versus-hook divergence D-263 exists to remove, just relocated: `reach` would have run on one interpreter and the test scripts on another, with different wheels for numpy/scipy/PIL, and any future 3.12+ syntax would break the tool while the venv stayed green. Reinstalled with --python 3.14 and introduced PYTHON_VERSION ?= 3.14 in the Makefile so setup-venv and install-reach share one pin. Verified the tool env is now lib/python3.14.
make setup-venv REBUILT (the T-1258 finding) — and the first fix was wrong.
Replaced `python3 -m venv` + `.venv/bin/pip` with the uv path. First attempt used plain `uv venv`, which FAILS on an existing venv ("A virtual environment already exists at .venv. Use --clear to replace it") — so the target was not idempotent, while the python3 -m venv version it replaced had been. Caught by actually running it twice rather than dry-running it, which is how the old one rotted unnoticed in the first place. Fixed with --allow-existing. Ran it twice, both clean, and test_lazy_domains still passes against the rebuilt venv.
DELIVERED
- make install-reach — wraps uv tool install with the pin and --editable, and SELF-CHECKS: fails loudly if `reach` is not on PATH afterwards, pointing at `uv tool update-shell`. Added to `make setup`.
- make reach-repoint — same command, but the situation has a name now. uv records the source path at install time, so an install made from a worktree keeps resolving there after the worktree is gone: `reach` still runs, edits in the main checkout do nothing, and there is NO error message. That silent-wrong-source failure is the reason this deserves its own target rather than a doc line.
- docs/DEVOPS.md — a Setup subsection covering why ~/.local/bin rather than .venv/bin, the bare-name rule, what --editable and --python buy, and the re-pointing trap.
- make test-tooling passes end to end (test-tooling: PASS).
NEGATIVE ACCEPTANCE CRITERION — verified manually, NOT yet automated.
`grep -rn "python -m tooling\|python3 -m tooling\|\.venv/bin/reach" Makefile .config/hooks .claude docs` returns nothing. The automated version belongs to T-1257''s conformance suite and has been recorded there with the exact pattern; deliberately not written here to avoid taking that ticket''s work.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-30 12:36:33.820', NULL, '00afdc1b9e62622f714f50635638f890', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-31 06:28:59.493', NULL, '717d2fc2a15ce71caf3f61a1eebe0319', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-31 06:29:33.965', NULL, '67af8f489ca03bb6ca0c68a946f07aab', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3DTRHSBYJS55NDT9YR4R0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E4 — The gate family and the push hook, at output parity', 'The highest-frequency, highest-friction surface, and the one with a hard number attached. Baseline measured 2026-08-20: check-client-version 20 ms, check-dataflow-graph 38 ms, check-canvas-version 46 ms — about 104 ms for the three unconditional checks; check-systems-db-stamp runs only when systems.db is in the push. The ported gates must not exceed that. Pieces that become tickets: (1) port the four checks into domains/check/ — client-version, canvas-version, dataflow-graph, systems-db-stamp; (2) port validate-content, validate-checklist, validate-ron, check-fact-ids into domains/validate/; (3) rewire .config/hooks/pre-push to call reach, keeping the fail_check by-name reporting that hook deliberately has; (4) prove each gate still FAILS — a check that explains itself and exits 0 silently disables its own gate, which is exactly the clide failure recorded in D-263; test each one against a deliberately broken tree, not just a clean one; (5) the wall-clock assertion that turns the budget into a test; (6) confirm domains/check/ imports no pydantic — 87 ms against a 20 ms check is a 3.5x regression bought for nothing, four times per push. Depends on E1 (lazy registration) and E2 (the error contract) being real first.
AMENDED 2026-08-20 — the 104 ms ceiling in the description above is WITHDRAWN. D-263 was amended the same day: the acceptance criterion is OUTPUT PARITY, not timing parity. A ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast. Reason: the only automatic consumer is the pre-push hook, which on a server or client push already runs cargo test or the gdUnit4 suite (minutes), and on a governance-only push totals about a second — so a few hundred ms is invisible either way. The replacement budget is a ceiling with headroom: a reach invocation should feel instant to a human, under about 250 ms, and the unconditional gate set stays comfortably under a second. Item (5) of this ticket, the wall-clock assertion, is REPLACED by an import-graph assertion — a timing test is flaky on a loaded machine and reports that something got slow rather than what; asserting sys.modules after reach --help names the offender. Item (6), the no-pydantic-in-check rule, is DROPPED — pydantic at 87 ms is unremarkable against a 250 ms ceiling, and the carve-out bought 87 ms at the cost of an exception to the layering.
FROM T-1262 (2026-08-31) — what ''output parity'' means was sharpened while porting the first gate, and it applies to every port in this epic. Byte-for-byte parity is IMPOSSIBLE under the streaming model and should not be attempted: D-263 puts the verdict on stderr as part of the event stream, while the scripts being replaced write their success line to stdout. Matching both would mean abandoning streaming or special-casing each ported gate. The enforceable definition, now in D-263: (a) EXIT CODES MATCH EXACTLY — the hook gates on this and it is the only part a caller can act on programmatically; (b) NO FACT IS LOST — every version number, path and reason the old message carried must appear in the new one, since a migration that silently drops a detail makes the failure harder to fix than before; (c) FAILURES NAME A REMEDY as a structured field, which the old scripts did only in prose. In text mode the success line is in fact byte-identical; only the stream differs. tooling/test_check_parity.py is the working pattern to copy for the other three checks — it builds a throwaway fixture repo (sentinel project.yaml plus client/project.godot), copies the OLD script into it so its __file__-relative root resolves there, points the new command at the same fixture via SR_REPO_ROOT, and compares. Note the asymmetry it exposes: the old scripts have NO root override, which is precisely why they are hard to test and part of why the port is worth doing.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:24:05.572', '2026-08-31 11:45:04.553', NULL, 'b0847f6443ed27f26989088e3d5adbd9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.
DONE 2026-08-31. All three remaining deliverables landed, and one of them changed a definition that applies well beyond this ticket.
(1) schemas.py IS NOW PYDANTIC.
VersionCheck is a frozen pydantic BaseModel. Frozen deliberately: a result object is a statement about what was found, and nothing downstream should be able to edit the finding on its way to being reported. Verified pydantic stays OFF the --help path — test_lazy_domains still passes, which is the assertion that pydantic loads with the domain rather than with the CLI. The reference domain is now the normal pattern with no footnote.
(2) BYTE-FOR-BYTE PARITY IS IMPOSSIBLE, AND SHOULD NOT BE ATTEMPTED. This is the real finding.
The acceptance criterion in the description predates the D-263 streaming amendment (2026-08-20, written after this ticket). Streaming puts the verdict on STDERR as an event; the old script writes its success line to STDOUT. Measured both: the TEXT is byte-identical in text mode — `check-client-version: OK — 0.4.13` from each — but the STREAM differs. Matching both would mean abandoning the streaming model or special-casing every ported gate, and neither is worth it.
Replaced with a definition that is stronger where it counts, now recorded in D-263 and carried to T-1251 because it governs every gate port in that epic:
(a) EXIT CODES MATCH EXACTLY — what the hook gates on, and the only part a caller can act on programmatically.
(b) NO FACT IS LOST — every version number, path and reason the old message carried appears in the new one. A migration that silently drops a detail makes a failure HARDER to fix than it was before, which is the opposite of the point.
(c) FAILURES NAME A REMEDY as a structured field, which the old scripts did only in prose.
(3) FAILURE-PATH TESTS — tooling/test_check_parity.py, wired into make test-tooling.
Three paths, each run through BOTH implementations and compared: ok, drift, missing-godot-file. Builds a throwaway fixture repo per case (sentinel project.yaml with commentary quoting OTHER version numbers, mirroring the real file so an unanchored regex would be caught) and copies the OLD script into fixture/tooling/ so its __file__-relative root resolves there. The new command is pointed at the same fixture with SR_REPO_ROOT. Worth noting the asymmetry that exposed: the old scripts have NO root override, which is exactly why they are hard to test and part of why the port earns its keep.
The test also asserts drift and missing-file actually exit NON-ZERO — without that, "the exit codes matched" would be vacuous for a pair of checks that both silently pass.
Invokes `reach` by BARE NAME per T-1261''s negative criterion, and fails with "Fix: make install-reach" if it is not on PATH.
PROVEN TO FAIL, twice, which is the only reason the green means anything.
First accidentally: the initial version asserted the yaml version appears on every failing path, but the old script does not report it when the client file is missing — so the test was wrong, not the code. Fixed by deriving expected facts from what the OLD OUTPUT ACTUALLY CONTAINS rather than a hardcoded list, which is a better statement of the rule anyway ("nothing the old message said is lost" — so the old message defines it).
Then deliberately: mutated the router to print a literal instead of result.godot_version. The test failed and named the dropped fact with both outputs side by side. Restored, green again.
make test-tooling: PASS end to end with both new tests wired in.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-31 11:45:25.451', NULL, 'ad72b0871a63801dc2597549712689f3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.
DONE 2026-08-31. All three remaining deliverables landed, and one of them changed a definition that applies well beyond this ticket.
(1) schemas.py IS NOW PYDANTIC.
VersionCheck is a frozen pydantic BaseModel. Frozen deliberately: a result object is a statement about what was found, and nothing downstream should be able to edit the finding on its way to being reported. Verified pydantic stays OFF the --help path — test_lazy_domains still passes, which is the assertion that pydantic loads with the domain rather than with the CLI. The reference domain is now the normal pattern with no footnote.
(2) BYTE-FOR-BYTE PARITY IS IMPOSSIBLE, AND SHOULD NOT BE ATTEMPTED. This is the real finding.
The acceptance criterion in the description predates the D-263 streaming amendment (2026-08-20, written after this ticket). Streaming puts the verdict on STDERR as an event; the old script writes its success line to STDOUT. Measured both: the TEXT is byte-identical in text mode — `check-client-version: OK — 0.4.13` from each — but the STREAM differs. Matching both would mean abandoning the streaming model or special-casing every ported gate, and neither is worth it.
Replaced with a definition that is stronger where it counts, now recorded in D-263 and carried to T-1251 because it governs every gate port in that epic:
(a) EXIT CODES MATCH EXACTLY — what the hook gates on, and the only part a caller can act on programmatically.
(b) NO FACT IS LOST — every version number, path and reason the old message carried appears in the new one. A migration that silently drops a detail makes a failure HARDER to fix than it was before, which is the opposite of the point.
(c) FAILURES NAME A REMEDY as a structured field, which the old scripts did only in prose.
(3) FAILURE-PATH TESTS — tooling/test_check_parity.py, wired into make test-tooling.
Three paths, each run through BOTH implementations and compared: ok, drift, missing-godot-file. Builds a throwaway fixture repo per case (sentinel project.yaml with commentary quoting OTHER version numbers, mirroring the real file so an unanchored regex would be caught) and copies the OLD script into fixture/tooling/ so its __file__-relative root resolves there. The new command is pointed at the same fixture with SR_REPO_ROOT. Worth noting the asymmetry that exposed: the old scripts have NO root override, which is exactly why they are hard to test and part of why the port earns its keep.
The test also asserts drift and missing-file actually exit NON-ZERO — without that, "the exit codes matched" would be vacuous for a pair of checks that both silently pass.
Invokes `reach` by BARE NAME per T-1261''s negative criterion, and fails with "Fix: make install-reach" if it is not on PATH.
PROVEN TO FAIL, twice, which is the only reason the green means anything.
First accidentally: the initial version asserted the yaml version appears on every failing path, but the old script does not report it when the client file is missing — so the test was wrong, not the code. Fixed by deriving expected facts from what the OLD OUTPUT ACTUALLY CONTAINS rather than a hardcoded list, which is a better statement of the rule anyway ("nothing the old message said is lost" — so the old message defines it).
Then deliberately: mutated the router to print a literal instead of result.godot_version. The test failed and named the dropped fact with both outputs side by side. Restored, green again.
make test-tooling: PASS end to end with both new tests wired in.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-31 11:45:42.408', NULL, '03b4b4d4756c7727d38d66395de47585', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Permission entry: Bash(reach *) replaces per-script prompting', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-31 11:48:52.107', NULL, '0a1bd26b50d16fa9a290d10fec2d61a7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Permission entry: Bash(reach *) replaces per-script prompting', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-31 11:48:59.985', NULL, '8923b296996cf054e2ddcdb44ec3be2a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Permission entry: Bash(reach *) replaces per-script prompting', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix.
DONE 2026-08-31, with an honest limit on the verification — read that part before trusting this.
DELIVERED — TWO entries, not one.
Added both Bash(reach) and Bash(reach *) to .claude/settings.json, placed beside the pql pair. The ticket asked only for Bash(reach *), but a rule ending in " *" does NOT match the bare word, and bare `reach` is a real invocation now that it prints the domain list. The established convention here confirms it: pql, make, cargo test and ruff check each carry a bare-form entry alongside the wildcard one, for exactly this reason. Adding only the wildcard would have left `reach` prompting while `reach check ...` did not — a confusing half-fix.
The ten tooling/ entries were left in place per the scope note; removing them is T-1253, and taking their permissions away while they are still the working tools would make them prompt through T-1250 to T-1252.
VERIFICATION — WHAT WAS AND WAS NOT PROVEN. This matters given the ticket''s own warning.
Ran real calls through the gate: `reach check client-version` (exit 0) and `reach --help` (domain list). Both succeeded with no prompt.
BUT THAT IS NOT EVIDENCE THE RULE WORKED. Those same calls succeeded throughout this session BEFORE the rule existed — I checked, and there was no Bash(reach ...) entry in either .claude/settings.json or .claude/settings.local.json, and no blanket Bash(*) grant in either. So something else (session permission mode) was already allowing them, and the observation is confounded. A passing call here cannot distinguish "the rule matched" from "the rule was never consulted".
WHAT WOULD ACTUALLY PROVE IT: a session started AFTER this commit, in a mode that prompts, where `reach check client-version` runs without a prompt. settings.json is read at session start, so this cannot be self-verified from inside the session that wrote it. Flagging rather than claiming a green.
What IS established: the JSON parses, both entries are present in permissions.allow, and the rule shape is byte-identical in form to the pql pair, which is empirically frictionless in this repo. That is inference from a working precedent, not proof.
FINDING — env-prefixed calls will still prompt, and that is accepted.
Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-31 11:52:32.631', NULL, '0f1abd699ed3f113681cd62d89bef251', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Permission entry: Bash(reach *) replaces per-script prompting', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix.
DONE 2026-08-31, with an honest limit on the verification — read that part before trusting this.
DELIVERED — TWO entries, not one.
Added both Bash(reach) and Bash(reach *) to .claude/settings.json, placed beside the pql pair. The ticket asked only for Bash(reach *), but a rule ending in " *" does NOT match the bare word, and bare `reach` is a real invocation now that it prints the domain list. The established convention here confirms it: pql, make, cargo test and ruff check each carry a bare-form entry alongside the wildcard one, for exactly this reason. Adding only the wildcard would have left `reach` prompting while `reach check ...` did not — a confusing half-fix.
The ten tooling/ entries were left in place per the scope note; removing them is T-1253, and taking their permissions away while they are still the working tools would make them prompt through T-1250 to T-1252.
VERIFICATION — WHAT WAS AND WAS NOT PROVEN. This matters given the ticket''s own warning.
Ran real calls through the gate: `reach check client-version` (exit 0) and `reach --help` (domain list). Both succeeded with no prompt.
BUT THAT IS NOT EVIDENCE THE RULE WORKED. Those same calls succeeded throughout this session BEFORE the rule existed — I checked, and there was no Bash(reach ...) entry in either .claude/settings.json or .claude/settings.local.json, and no blanket Bash(*) grant in either. So something else (session permission mode) was already allowing them, and the observation is confounded. A passing call here cannot distinguish "the rule matched" from "the rule was never consulted".
WHAT WOULD ACTUALLY PROVE IT: a session started AFTER this commit, in a mode that prompts, where `reach check client-version` runs without a prompt. settings.json is read at session start, so this cannot be self-verified from inside the session that wrote it. Flagging rather than claiming a green.
What IS established: the JSON parses, both entries are present in permissions.allow, and the rule shape is byte-identical in form to the pql pair, which is empirically frictionless in this repo. That is inference from a working precedent, not proof.
FINDING — env-prefixed calls will still prompt, and that is accepted.
Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-31 11:52:48.373', NULL, '3e9b835e22c15d9e6163c9c82a453dbb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S392DEG41MRGEFSYWJWP2W', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E1 — The door: reach on PATH, routing-only main.py, lazy domains', 'The walking skeleton, and the epic every other one depends on. Deliverable: typing reach in any shell — interactive, agent, or git hook — prints the domain list, and one domain is ported end to end through router/service as the reference every later port copies. Pieces that become tickets: (1) package skeleton — tooling/__init__.py, main.py, core/, domains/, setuptools discovery configured to exclude the econ-sim Rust crate; (2) pyproject [project.scripts] reach = tooling.main:app, plus typer and pydantic added to dependencies; (3) the PATH guarantee — uv tool install --editable, verified from a NON-INTERACTIVE context (git hook, agent Bash) and not just a login shell, since that split is the whole point; (4) lazy router registration — main.py must list domains in --help WITHOUT importing their services, which is the mechanism the 104 ms gate budget depends on; (5) help at every level: reach --help enumerates domains, reach <domain> --help enumerates verbs; (6) one domain ported end to end as the reference implementation; (7) Bash(reach *) added to .claude/settings.json. Done when: reach runs from a git hook with no venv activation, --help answers what tooling exists without an ls, and the reference domain has zero logic in its router.
AMENDED 2026-08-20 — two corrections to the description above. (a) Item (4) calls lazy registration the mechanism the 104 ms gate budget depends on; that budget is WITHDRAWN (D-263 amendment, T-1251). Lazy registration is still mandatory, justified instead by what an eager entrypoint would really cost: scipy.ndimage 275 ms, pydantic 87 ms, numpy 72 ms, PIL.Image 29 ms, about 460 ms before executing a line of its own. (b) Item (2) names the entrypoint tooling.main:app; it is tooling.main:cli — the root is a click Group subclass, not a typer.Typer instance, because a Typer root with zero eagerly-registered commands raises at build time and keeping typer off the --help path is worth having. Refined into six tasks: T-1258 package skeleton, T-1259 entrypoint and deps, T-1260 lazy registration, T-1261 PATH guarantee, T-1262 reference domain, T-1263 permission entry.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:26.571', '2026-08-31 11:52:51.341', NULL, 'edfafbfa748f71587e9789b1475d5d01', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E2 — Decorators: shared logging, robust error handling, failures that teach', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.
AMENDED 2026-08-20 — the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility — it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:32.680', '2026-08-31 11:58:57.338', NULL, '99383ce0ec381fd2221dd76e41b989a1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FBZENXG6N9BWR1SK9VNMVC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/errors.py — ReachError carries its remedy, @handle_errors preserves the exit code', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:02.831', '2026-08-31 11:59:02.831', NULL, 'a2cb8d1c982a6d0353e757e0aebb2153', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC0EGNR11ACT6B72AJS98R', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/logging.py — @logged emits through console, not a second sink', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:10.981', '2026-08-31 11:59:10.981', NULL, '7e919b74efc13c608fb2cec4c3c6eaf5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC15HDP9JRX3S4J0VA33QG', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The composed @command decorator — no command can carry one and not the other', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:16.875', '2026-08-31 11:59:16.875', NULL, 'e0d55b510640001d9b1049133e8bed51', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Global options: --verbose and --no-input, with TTY gating', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:25.521', '2026-08-31 11:59:25.521', NULL, 'af0114f51a976c51c281e09fe2915542', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC2VMM5EVM69YBK28MYG1W', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Closed-set enumeration: when the accepted set is known, print it', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:30.726', '2026-08-31 11:59:30.726', NULL, 'f85b09d156a72f3340436ca4500dc04b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC3YK5NDFABTF6P1FQSR28', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The conformance test — the contract becomes a test, not a style guide', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:39.673', '2026-08-31 11:59:39.673', NULL, '952ce5c0b046413dc25d87adea956785', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E2 — Decorators: shared logging, robust error handling, failures that teach', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.
AMENDED 2026-08-20 — the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility — it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.
Refined 2026-08-31 into six tasks: T-1265 core/errors.py and @handle_errors, T-1266 core/logging.py and @logged, T-1267 the composed @command decorator, T-1268 global --verbose and --no-input, T-1269 closed-set enumeration, T-1270 the conformance test. Item (4) of the description above — core/console.py as the single output path — is ALREADY DONE, delivered by T-1258 and reshaped as the JSONL event emitter by the streaming amendment; no task for it. Sequencing: T-1265 and T-1266 are unblocked and independent; T-1267 needs both; T-1268 needs T-1266; T-1269 needs T-1265; T-1270 needs T-1267. Ordering note recorded during refinement: this epic must land BEFORE T-1250 moves the tree. The check router today hand-rolls console.verdict plus raise typer.Exit at each exit — exactly the call-site discipline these decorators replace — and porting ~120 commands with that pattern means writing it 120 times and then unwinding it.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:32.680', '2026-08-31 11:59:53.312', NULL, '8f7300f8b603cfd25fed3217ffa38fa1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FBZENXG6N9BWR1SK9VNMVC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/errors.py — ReachError carries its remedy, @handle_errors preserves the exit code', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:02.831', '2026-08-31 12:32:59.102', NULL, '4e69391a2169d045581174c9e13379e3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC0EGNR11ACT6B72AJS98R', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/logging.py — @logged emits through console, not a second sink', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:10.981', '2026-08-31 12:33:43.759', NULL, 'ecff81337236b765c8a8ff1a035a143e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC15HDP9JRX3S4J0VA33QG', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The composed @command decorator — no command can carry one and not the other', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:16.875', '2026-08-31 12:34:00.331', NULL, 'ccc321b22d606fe734f7c441038a75da', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Global options: --verbose and --no-input, with TTY gating', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:25.521', '2026-08-31 12:35:20.910', NULL, '45e53497651467bc28c3972ef56bac9c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC2VMM5EVM69YBK28MYG1W', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Closed-set enumeration: when the accepted set is known, print it', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:30.726', '2026-08-31 12:41:15.523', NULL, 'ae4ca3a31411d7070f345ce8dcfdd844', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC3YK5NDFABTF6P1FQSR28', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The conformance test — the contract becomes a test, not a style guide', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:39.673', '2026-08-31 12:45:17.646', NULL, '96526bd1921ba2d015ed3016a04d9b88', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FBZENXG6N9BWR1SK9VNMVC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/errors.py — ReachError carries its remedy, @handle_errors preserves the exit code', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.
DONE 2026-08-31.
core/errors.py holds ReachError(message, fix=, exit_code=) and @handle_errors. Verified end to end: `SR_REPO_ROOT=/tmp reach check client-version` now emits a structured verdict carrying its remedy and exits 1.
TWO DESIGN POINTS worth keeping.
NO TYPER IN core/. @handle_errors raises a plain SystemExit rather than typer.Exit, because core/ is transport substrate — a service must be callable from a test, another service, or a future second front end, and an exception type that only makes sense inside a CLI would leak the transport into every layer. Click passes SystemExit through untouched. The conformance test (T-1270) now enforces this.
exit_code CANNOT BE ZERO. ReachError coerces 0 to 1 in its constructor. A ReachError carrying exit_code=0 would be a contradiction, and it is precisely the silent-gate failure D-263 records from clide: a tool that explains itself beautifully and exits 0 has disabled its own gate, and the explanation makes it look MORE correct rather than less.
The unexpected-exception path was tested too, not just the ReachError one: an unhandled ValueError produces "unexpected ValueError: ..." with fix="re-run with --verbose for the traceback" and exit 1. Under --verbose the traceback is emitted and the fix line changes to say the traceback is the whole story and this is a bug in reach rather than in the caller''s input.
RETROFIT DONE: tooling/core/config.py no longer raises RuntimeError. It raises ReachError, and the remedy improved in the process — it now names `make reach-repoint` (which did not exist when the original message was written in T-1258) rather than spelling out a raw uv command.
Also delivered here rather than deferred: errors.unknown_choice(kind, given, accepted), the closed-set helper T-1269 needed.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:02.831', '2026-08-31 13:02:16.094', NULL, '2274911d9c31df0760c7d988160dcb31', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC0EGNR11ACT6B72AJS98R', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/logging.py — @logged emits through console, not a second sink', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.
DONE 2026-08-31. core/logging.py holds @logged. It is a decorator and an event kind, NOT a logging subsystem — the record goes out through core/console as a JSONL event, per the amendment, so there is exactly one output path and no second sink to drift or forget to configure.
Verified: `reach --verbose check client-version` emits {"level":"debug","command":"client_version","args":{},"duration_ms":0.4,"outcome":"ok"}. Without --verbose it emits nothing, so the push hook''s output is byte-for-byte what it was before this landed — which was the requirement, since a gate that suddenly printed a line per check would train people to stop reading gate output.
The outcome is recorded in a `finally` with the exception type as its value, so a command that raises is reported as (say) outcome="ReachError" rather than vanishing. That is what makes the composition order in T-1267 load-bearing.
ADDED BEYOND THE TICKET: argument redaction. Values under keys matching password/token/secret/api_key are replaced with *** before the record is emitted. Repo tooling handles no credentials today, so this guards nothing yet — but the cost is one frozenset and the cost of discovering it was needed later is a leaked secret in a job log that T-1264 will persist to disk under .cache/reach/jobs/.
OBSERVATION, not a defect, flagged so it is a decision rather than an accident: under --verbose the debug invocation record prints AFTER the verdict, because console.verdict is called in the command body and @logged emits from its finally. D-263 says the verdict prints "once, last, where it cannot be missed". Strictly the verdict is now second-to-last under --verbose. Left as is — the record is telemetry rather than user-facing output, and --verbose is an explicit request for telemetry. Moving the log emission before the verdict would mean not knowing the duration or the outcome yet.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:10.981', '2026-08-31 13:02:27.494', NULL, 'dc4b88ee4d4073fd84d7dcd89ad74e57', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC15HDP9JRX3S4J0VA33QG', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The composed @command decorator — no command can carry one and not the other', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.
DONE 2026-08-31. core/command.py holds @command = handle_errors(logged(func)), plus a MARKER attribute the conformance test reads.
COMPOSITION ORDER, which the ticket flagged and which is genuinely load-bearing: handle_errors is OUTER, logged is INNER. The logger''s finally then sees the ORIGINAL exception and records its type as the outcome. Inverted, handle_errors converts everything to SystemExit first, so every failure would be logged as outcome="SystemExit" — the record says nothing about what actually went wrong while still looking like it worked, which is the worst kind of wrong.
The MARKER attribute is set explicitly on the composed wrapper rather than inferred. Unwrapping functools.wraps chains after the fact to prove "this was decorated" is brittle in exactly the way a conformance test must not be.
RETROFIT DONE. The check router no longer hand-rolls console.verdict(ok=False) followed by raise typer.Exit(1) at each failing branch — it raises ReachError with a remedy and the decorator does the rest. Both failure branches converted. The router also dropped its direct typer import entirely, now that cli.domain() builds the app (T-1269).
ACCEPTANCE MET AS SPECIFIED: test_check_parity.py passes UNCHANGED across the retrofit. That test was written in T-1262, before any of these decorators existed, and pins exit codes and message facts on three paths against the old script — so it is genuinely independent evidence that the retrofit is behaviour-preserving rather than a test adjusted to match new behaviour.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:16.875', '2026-08-31 13:02:38.059', NULL, '7a7e73b7af1f00cf8431c059da9bcccc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Global options: --verbose and --no-input, with TTY gating', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.
DONE 2026-08-31. Both flags live on the ROOT callback in tooling/main.py, so every domain inherits them and no router redeclares them — a per-domain copy is how the two would drift apart.
--verbose lowers the console level to debug, which surfaces both progress events and the @logged invocation record, AND promotes an unexpected exception from a one-line message to a full traceback. Verified: `reach --verbose check client-version` shows the invocation record; without it, nothing.
--no-input is recorded in a new tooling/core/runtime.py. IMPORTANT: the flag alone is not the guard. can_prompt() requires BOTH the absence of --no-input AND an interactive stdin and stderr, because a prompt with no TTY does not wait — it crashes, which is the recorded tea failure in this repo. Hooks and agents pass --no-input explicitly and would still be protected by the TTY check if they forgot. Any future interactive path must call can_prompt(), never isatty alone and never the flag alone; the two guard different failures.
Nothing prompts today. runtime.py exists so the first thing that wants to has an obvious correct answer to hand, rather than inventing its own half-right isatty check.
SCOPE NOTE the ticket asked for explicitly: --no-input is about PROMPTING, not output format. Console already chooses JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, and --no-input deliberately does NOT touch that. Making it a second, conflicting way to say the same thing would leave nobody sure which one wins.
The core/ imports inside the root callback are deferred to the function body rather than module level. main.py stays a router: the callback only runs when a command is actually being executed, and test_lazy_domains confirms --help still imports nothing.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:25.521', '2026-08-31 13:02:48.512', NULL, 'bd9ee7bc28f099ae29190e7f2734ce30', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC2VMM5EVM69YBK28MYG1W', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Closed-set enumeration: when the accepted set is known, print it', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.
DONE 2026-08-31. Both levels enumerate now.
BEFORE: `reach bogus` -> "Error: No such command ''bogus''." and `reach check bogus` -> the same. Neither said what WAS available, though in both cases the accepted set was already loaded in memory.
AFTER: `reach bogus` -> "unknown domain ''bogus''" followed by "Choose one of: check". `reach check bogus` -> "unknown command ''bogus''" followed by "Choose one of: client-version". Both exit 2, Click''s usage-error convention.
Delivered: errors.unknown_choice(kind, given, accepted) as the ReachError-shaped helper for enum arguments; LazyDomainGroup.resolve_command in main.py for the domain level (free — DOMAINS is already loaded for --help); and a new shared ReachDomainGroup for the verb level.
DECISION THAT NEEDED MAKING, and it amends a D-263 invariant.
Per-domain verb enumeration needs a shared TyperGroup subclass. That collides with "no typer/click outside main.py and router.py": putting it in core/ appeared to break the rule, and the alternatives were worse — importing from main.py into routers is the wrong dependency direction, and copying the subclass into every router guarantees the copies drift so only some domains enumerate.
Resolved by sharpening the invariant rather than breaking it. Its PURPOSE is that a service never knows it was called from a CLI. So the rule is now: NEVER in service.py, schemas.py or helpers.py; transport lives in main.py, router.py, and core/cli.py. D-263 amended, and the conformance test (T-1270) enforces the sharpened form including the stricter statement about logic files.
BONUS from having core/cli.py at all: cli.domain(name, help) now builds every domain''s Typer app with the house settings applied — cls=ReachDomainGroup, rich_markup_mode=None, no_args_is_help. Those were previously per-router decisions, and rich_markup_mode=None in particular is load-bearing rather than cosmetic (it keeps rich and pygments off the import path and stops box-art help landing in hook logs). One domain forgetting it would have silently undone that. The check router is converted and dropped its direct typer import.
NOT DONE, deliberately: enum-shaped ARGUMENT values. unknown_choice exists and is ready, but no command takes an enum argument yet. Apply it as T-1250 ports domains that do.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:30.726', '2026-08-31 13:03:02.605', NULL, '06e181ec6e5ea1de0873ce67af7f72b3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC3YK5NDFABTF6P1FQSR28', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The conformance test — the contract becomes a test, not a style guide', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.
DONE 2026-08-31. tooling/test_conformance.py, wired into make test-tooling. Five invariants, each executable.
(1) TRANSPORT ISOLATION — no typer/click import outside main.py, router.py and core/cli.py, with a stricter second assertion naming service.py/schemas.py/helpers.py/dependencies.py as never-permitted. AST-based, not grep: a grep for "import typer" misses "from typer.core import TyperGroup" and hits it inside a docstring.
(2) SINGLE OUTPUT PATH — no print() and no sys.stdout/stderr.write outside core/console.py.
(3) EVERY COMMAND CARRIES @command, read from the MARKER attribute rather than by unwrapping decorator chains.
(4) EVERY COMMAND HAS HELP at its own level.
(5) EVERY ReachError NAMES A REMEDY — a raise without fix= fails the suite. This is the hardest and the most valuable one, and it is why D-263 was amended to state it explicitly.
The import-graph invariant stays in test_lazy_domains.py rather than being duplicated.
SCOPE CORRECTION, found by running it. The first version walked all of tooling/ and produced 122 KB of failures — the ~123 legacy scripts use bare print() throughout. Holding code written years before this contract to that contract means 500 failures on day one and a suite nobody runs. Scoped to the PACKAGE: main.py, __init__.py, core/ and domains/.
That is not a permanent carve-out, and the shape is deliberate: as T-1250 moves each script into domains/<name>/, it lands inside the scope and the invariants start applying automatically. The test''s reach grows with the migration instead of needing to be widened by hand — and a port that leaves print() calls in place will fail the gate at the moment it is cheap to fix.
PROVEN TO FAIL on the two invariants that matter most, then restored:
- Removed @command from the check router: "[commands] `reach check client-version` is missing @command — it would run without the error contract or the invocation record".
- Removed fix= from a ReachError: "[remedy] tooling/domains/check/router.py:46 raises ReachError without fix= — an error that only says ''no'' is what D-263 exists to replace".
Both name the file, the line and the reason.
The suite also fails if NO commands are found, which would otherwise let assertions 3 and 4 pass vacuously.
make test-tooling: PASS end to end with all three reach tests wired in.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:39.673', '2026-08-31 13:03:17.252', NULL, '677362ad7028e90787b900b65e964fb0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FBZENXG6N9BWR1SK9VNMVC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/errors.py — ReachError carries its remedy, @handle_errors preserves the exit code', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and STILL EXITS NON-ZERO. Deliverables: ReachError(message, fix=...) in tooling/core/errors.py; the @handle_errors decorator that catches it, renders through core/console.verdict (never its own print — console is the single output path), and preserves the exit code. Unrecognised exceptions still exit non-zero, with the traceback behind --verbose rather than dumped at a user who cannot act on it. THE TRAP, recorded in D-263 from a real clide failure: a tool that explains itself beautifully and exits 0 has silently disabled its own gate — the explanation makes it look MORE correct, not less, which is why this needs a test and not care. @handle_errors also renders the FINAL VERDICT once, last, after any event stream: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible. First retrofit target: tooling/core/config.py raises RuntimeError with an instructional message pending this ticket — there is a comment at the raise site. One call site to convert.
DONE 2026-08-31.
core/errors.py holds ReachError(message, fix=, exit_code=) and @handle_errors. Verified end to end: `SR_REPO_ROOT=/tmp reach check client-version` now emits a structured verdict carrying its remedy and exits 1.
TWO DESIGN POINTS worth keeping.
NO TYPER IN core/. @handle_errors raises a plain SystemExit rather than typer.Exit, because core/ is transport substrate — a service must be callable from a test, another service, or a future second front end, and an exception type that only makes sense inside a CLI would leak the transport into every layer. Click passes SystemExit through untouched. The conformance test (T-1270) now enforces this.
exit_code CANNOT BE ZERO. ReachError coerces 0 to 1 in its constructor. A ReachError carrying exit_code=0 would be a contradiction, and it is precisely the silent-gate failure D-263 records from clide: a tool that explains itself beautifully and exits 0 has disabled its own gate, and the explanation makes it look MORE correct rather than less.
The unexpected-exception path was tested too, not just the ReachError one: an unhandled ValueError produces "unexpected ValueError: ..." with fix="re-run with --verbose for the traceback" and exit 1. Under --verbose the traceback is emitted and the fix line changes to say the traceback is the whole story and this is a bug in reach rather than in the caller''s input.
RETROFIT DONE: tooling/core/config.py no longer raises RuntimeError. It raises ReachError, and the remedy improved in the process — it now names `make reach-repoint` (which did not exist when the original message was written in T-1258) rather than spelling out a raw uv command.
Also delivered here rather than deferred: errors.unknown_choice(kind, given, accepted), the closed-set helper T-1269 needed.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:02.831', '2026-08-31 13:03:17.396', NULL, '1bfed8d486842ac5e59cef2a4993577d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC15HDP9JRX3S4J0VA33QG', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The composed @command decorator — no command can carry one and not the other', 'The point of the epic. @command composes @logged and @handle_errors into one decorator, so a command author cannot apply half the contract by accident — which is the failure mode of call-site discipline and the reason D-263 makes these decorators rather than conventions. Deliverables: the composed decorator; every existing command wearing it; the check domain retrofitted off its hand-rolled console.verdict-plus-raise-typer.Exit pattern, which is exactly the boilerplate this removes. Watch the ORDER of composition — error handling must wrap logging, or a command that raises will be logged as having no outcome; get it wrong and the record says nothing happened while the user sees a failure. Acceptance: reach check client-version behaves identically before and after the retrofit, verified by test_check_parity.py, which already pins exit codes and message facts on three paths and will catch a regression here without modification.
DONE 2026-08-31. core/command.py holds @command = handle_errors(logged(func)), plus a MARKER attribute the conformance test reads.
COMPOSITION ORDER, which the ticket flagged and which is genuinely load-bearing: handle_errors is OUTER, logged is INNER. The logger''s finally then sees the ORIGINAL exception and records its type as the outcome. Inverted, handle_errors converts everything to SystemExit first, so every failure would be logged as outcome="SystemExit" — the record says nothing about what actually went wrong while still looking like it worked, which is the worst kind of wrong.
The MARKER attribute is set explicitly on the composed wrapper rather than inferred. Unwrapping functools.wraps chains after the fact to prove "this was decorated" is brittle in exactly the way a conformance test must not be.
RETROFIT DONE. The check router no longer hand-rolls console.verdict(ok=False) followed by raise typer.Exit(1) at each failing branch — it raises ReachError with a remedy and the decorator does the rest. Both failure branches converted. The router also dropped its direct typer import entirely, now that cli.domain() builds the app (T-1269).
ACCEPTANCE MET AS SPECIFIED: test_check_parity.py passes UNCHANGED across the retrofit. That test was written in T-1262, before any of these decorators existed, and pins exit codes and message facts on three paths against the old script — so it is genuinely independent evidence that the retrofit is behaviour-preserving rather than a test adjusted to match new behaviour.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:16.875', '2026-08-31 13:03:17.403', NULL, '55b06a2e740ce35c2f916ad6492e98d5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC0EGNR11ACT6B72AJS98R', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'core/logging.py — @logged emits through console, not a second sink', 'One structured record per invocation: command, args, duration, outcome. Deliverables: tooling/core/logging.py plus the @logged decorator. THE AMENDMENT THAT MATTERS: @logged must emit through core/console as a JSONL event, NOT through a separate logging sink. D-263 names console the single output path, and two sinks would drift in format, destination and level handling — the second one always being the one nobody remembers to configure. So this ticket adds a decorator and an event kind, not a logging subsystem. Constraints: stderr never stdout, so machine-readable output stays parseable; QUIET BY DEFAULT so the push hook is not spammed — the invocation record is a debug-level event that --verbose promotes, which means the default push output looks exactly as it does today. Duration is worth capturing even though the timing budget was withdrawn: the value is a per-invocation record for the jobs domain (T-1264) to read back, not a gate to enforce.
DONE 2026-08-31. core/logging.py holds @logged. It is a decorator and an event kind, NOT a logging subsystem — the record goes out through core/console as a JSONL event, per the amendment, so there is exactly one output path and no second sink to drift or forget to configure.
Verified: `reach --verbose check client-version` emits {"level":"debug","command":"client_version","args":{},"duration_ms":0.4,"outcome":"ok"}. Without --verbose it emits nothing, so the push hook''s output is byte-for-byte what it was before this landed — which was the requirement, since a gate that suddenly printed a line per check would train people to stop reading gate output.
The outcome is recorded in a `finally` with the exception type as its value, so a command that raises is reported as (say) outcome="ReachError" rather than vanishing. That is what makes the composition order in T-1267 load-bearing.
ADDED BEYOND THE TICKET: argument redaction. Values under keys matching password/token/secret/api_key are replaced with *** before the record is emitted. Repo tooling handles no credentials today, so this guards nothing yet — but the cost is one frozenset and the cost of discovering it was needed later is a leaked secret in a job log that T-1264 will persist to disk under .cache/reach/jobs/.
OBSERVATION, not a defect, flagged so it is a decision rather than an accident: under --verbose the debug invocation record prints AFTER the verdict, because console.verdict is called in the command body and @logged emits from its finally. D-263 says the verdict prints "once, last, where it cannot be missed". Strictly the verdict is now second-to-last under --verbose. Left as is — the record is telemetry rather than user-facing output, and --verbose is an explicit request for telemetry. Moving the log emission before the verdict would mean not knowing the duration or the outcome yet.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:10.981', '2026-08-31 13:03:17.403', NULL, 'f50ffcdc9aca956067ba7052b536736a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC2VMM5EVM69YBK28MYG1W', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Closed-set enumeration: when the accepted set is known, print it', 'Whenever a value comes from a known finite set and the user supplies something outside it, the error names the accepted values. This is not a nicety — it is the specific gap D-263 measured in pql, where an invalid ticket status is rejected without naming the six valid ones, leaving the caller to grep the source or guess. Deliverables: the helper in core/errors.py that formats a closed-set rejection as a ReachError carrying the accepted values in its fix; applied at the two places that exist today — an unknown DOMAIN at the root (currently a bare Click ''No such command'', which does not list what IS available even though the DOMAINS registry is right there and enumerating it costs nothing since it is already loaded for --help) and an unknown VERB within a domain. Extend to enum-shaped arguments as the ported domains introduce them. Test the failure, not the success: assert the accepted values appear in the message, since a rejection that merely says no is the thing being fixed.
DONE 2026-08-31. Both levels enumerate now.
BEFORE: `reach bogus` -> "Error: No such command ''bogus''." and `reach check bogus` -> the same. Neither said what WAS available, though in both cases the accepted set was already loaded in memory.
AFTER: `reach bogus` -> "unknown domain ''bogus''" followed by "Choose one of: check". `reach check bogus` -> "unknown command ''bogus''" followed by "Choose one of: client-version". Both exit 2, Click''s usage-error convention.
Delivered: errors.unknown_choice(kind, given, accepted) as the ReachError-shaped helper for enum arguments; LazyDomainGroup.resolve_command in main.py for the domain level (free — DOMAINS is already loaded for --help); and a new shared ReachDomainGroup for the verb level.
DECISION THAT NEEDED MAKING, and it amends a D-263 invariant.
Per-domain verb enumeration needs a shared TyperGroup subclass. That collides with "no typer/click outside main.py and router.py": putting it in core/ appeared to break the rule, and the alternatives were worse — importing from main.py into routers is the wrong dependency direction, and copying the subclass into every router guarantees the copies drift so only some domains enumerate.
Resolved by sharpening the invariant rather than breaking it. Its PURPOSE is that a service never knows it was called from a CLI. So the rule is now: NEVER in service.py, schemas.py or helpers.py; transport lives in main.py, router.py, and core/cli.py. D-263 amended, and the conformance test (T-1270) enforces the sharpened form including the stricter statement about logic files.
BONUS from having core/cli.py at all: cli.domain(name, help) now builds every domain''s Typer app with the house settings applied — cls=ReachDomainGroup, rich_markup_mode=None, no_args_is_help. Those were previously per-router decisions, and rich_markup_mode=None in particular is load-bearing rather than cosmetic (it keeps rich and pygments off the import path and stops box-art help landing in hook logs). One domain forgetting it would have silently undone that. The check router is converted and dropped its direct typer import.
NOT DONE, deliberately: enum-shaped ARGUMENT values. unknown_choice exists and is ready, but no command takes an enum argument yet. Apply it as T-1250 ports domains that do.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:30.726', '2026-08-31 13:03:17.404', NULL, '7dd8aa5c1668ea305f984d1fdb295fe0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC27A7ZV3ZEDYDA1R11PNC', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'Global options: --verbose and --no-input, with TTY gating', 'Two flags on the root callback in tooling/main.py, both of which exist because of recorded failures rather than symmetry. --verbose lowers the console level so debug events and the @logged invocation record surface, and promotes an unrecognised exception from a one-line message to a full traceback; console.set_level already exists for this. --no-input asserts that nothing may prompt: hooks and agents pass it unconditionally, because a prompt with no TTY does not wait, it CRASHES — the recorded tea failure in this repo. Pair it with TTY gating so any future interactive path checks both the flag and isatty rather than one or the other. Note the interaction with output format: console already picks JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, so --no-input must not silently become a second, conflicting way to say the same thing. Keep the flags on the ROOT callback so they apply to every domain without each router redeclaring them — a per-domain copy is how the two would drift apart.
DONE 2026-08-31. Both flags live on the ROOT callback in tooling/main.py, so every domain inherits them and no router redeclares them — a per-domain copy is how the two would drift apart.
--verbose lowers the console level to debug, which surfaces both progress events and the @logged invocation record, AND promotes an unexpected exception from a one-line message to a full traceback. Verified: `reach --verbose check client-version` shows the invocation record; without it, nothing.
--no-input is recorded in a new tooling/core/runtime.py. IMPORTANT: the flag alone is not the guard. can_prompt() requires BOTH the absence of --no-input AND an interactive stdin and stderr, because a prompt with no TTY does not wait — it crashes, which is the recorded tea failure in this repo. Hooks and agents pass --no-input explicitly and would still be protected by the TTY check if they forgot. Any future interactive path must call can_prompt(), never isatty alone and never the flag alone; the two guard different failures.
Nothing prompts today. runtime.py exists so the first thing that wants to has an obvious correct answer to hand, rather than inventing its own half-right isatty check.
SCOPE NOTE the ticket asked for explicitly: --no-input is about PROMPTING, not output format. Console already chooses JSONL versus rendered text from isatty with an SR_OUTPUT_FORMAT override, and --no-input deliberately does NOT touch that. Making it a second, conflicting way to say the same thing would leave nobody sure which one wins.
The core/ imports inside the root callback are deferred to the function body rather than module level. main.py stays a router: the callback only runs when a command is actually being executed, and test_lazy_domains confirms --help still imports nothing.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 11:59:25.521', '2026-08-31 13:03:17.404', NULL, '922c8b684276c38518f3593ee947be50', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FC3YK5NDFABTF6P1FQSR28', 'task', '06G1S39T92PW9JNBPWD0F4JFR0', 'The conformance test — the contract becomes a test, not a style guide', 'D-263 lists five invariants and states plainly that a contract nothing checks is a style guide. This makes them executable. Assert: (1) every registered command has help at its own level; (2) every command carries @command — detectable via a marker attribute the decorator sets, since inspecting composition after the fact is brittle; (3) no typer or click import outside main.py and router.py files; (4) no bare print outside core/console.py; (5) no heavy import (numpy, scipy, PIL, pydantic) reachable from main.py — ALREADY LIVE in tooling/test_lazy_domains.py, so fold it in rather than duplicating the assertion; (6) every declared failure path names a remedy, which is the hardest and most valuable: a ReachError raised without fix= should fail the suite. Items 3 and 4 are grep-shaped and can walk the tree; item 2 is import-shaped. Note this test is the thing that keeps the layering real once T-1250 lands ~120 commands — at that scale nobody re-reads D-263 before adding a verb, and this is what tells them. Wire into make test-tooling; T-1257 later folds this and the lazy-domain test into one conformance suite plus the invocation-form greps carried there from T-1261.
DONE 2026-08-31. tooling/test_conformance.py, wired into make test-tooling. Five invariants, each executable.
(1) TRANSPORT ISOLATION — no typer/click import outside main.py, router.py and core/cli.py, with a stricter second assertion naming service.py/schemas.py/helpers.py/dependencies.py as never-permitted. AST-based, not grep: a grep for "import typer" misses "from typer.core import TyperGroup" and hits it inside a docstring.
(2) SINGLE OUTPUT PATH — no print() and no sys.stdout/stderr.write outside core/console.py.
(3) EVERY COMMAND CARRIES @command, read from the MARKER attribute rather than by unwrapping decorator chains.
(4) EVERY COMMAND HAS HELP at its own level.
(5) EVERY ReachError NAMES A REMEDY — a raise without fix= fails the suite. This is the hardest and the most valuable one, and it is why D-263 was amended to state it explicitly.
The import-graph invariant stays in test_lazy_domains.py rather than being duplicated.
SCOPE CORRECTION, found by running it. The first version walked all of tooling/ and produced 122 KB of failures — the ~123 legacy scripts use bare print() throughout. Holding code written years before this contract to that contract means 500 failures on day one and a suite nobody runs. Scoped to the PACKAGE: main.py, __init__.py, core/ and domains/.
That is not a permanent carve-out, and the shape is deliberate: as T-1250 moves each script into domains/<name>/, it lands inside the scope and the invariants start applying automatically. The test''s reach grows with the migration instead of needing to be widened by hand — and a port that leaves print() calls in place will fail the gate at the moment it is cheap to fix.
PROVEN TO FAIL on the two invariants that matter most, then restored:
- Removed @command from the check router: "[commands] `reach check client-version` is missing @command — it would run without the error contract or the invocation record".
- Removed fix= from a ReachError: "[remedy] tooling/domains/check/router.py:46 raises ReachError without fix= — an error that only says ''no'' is what D-263 exists to replace".
Both name the file, the line and the reason.
The suite also fails if NO commands are found, which would otherwise let assertions 3 and 4 pass vacuously.
make test-tooling: PASS end to end with all three reach tests wired in.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 11:59:39.673', '2026-08-31 13:03:17.405', NULL, '409ed433f3fd906ac24ca5a764108690', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S39T92PW9JNBPWD0F4JFR0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E2 — Decorators: shared logging, robust error handling, failures that teach', 'The requirement with the real teeth: every non-zero exit prints the command that would fix it, and still exits non-zero. Cross-cutting concerns are decorators, never call-site discipline. Pieces that become tickets: (1) core/errors.py — ReachError(message, fix=...) plus the @handle_errors decorator that renders message and remedy to stderr and PRESERVES the exit code; unrecognised exceptions still exit non-zero with the traceback behind --verbose; (2) core/logging.py — the shared logger plus @logged, one structured line per invocation (command, args, duration, outcome) to STDERR never stdout, quiet by default so hooks are not spammed; (3) the composed @command decorator so no command can carry one and not the other; (4) core/console.py as the single output path; (5) closed-set enumeration — whenever the accepted set is known, print it, which is the specific gap measured in pql (invalid status without naming the six valid ones); (6) --no-input plus TTY gating, hooks pass it unconditionally, because tea prompts crash in Claude Code with no TTY; (7) the conformance test: every registered command has help at its own level, every command carries @command, no typer/click outside main.py and router.py, no bare print outside core/console.py, no pydantic reachable from main.py. Done when the contract is a test, not a style guide.
AMENDED 2026-08-20 — the streaming model (D-263 amendment, E9/T-1264) touches two things here. (1) The @logged decorator and core/console.py are now the SAME channel, not two: logged emits its one-line-per-invocation record as a JSONL event through console, rather than through a separate logging sink. Two output paths would drift, and console is already specified as the single one. (2) The @handle_errors decorator gains a responsibility — it renders the FINAL VERDICT, once, last, after any stream. That is what keeps streaming additive to the failure contract instead of dissolving it: a remedy emitted mid-stream at line 400 of 900 is technically printed and practically invisible, so the terminal event carrying outcome, exit code and remedy is the decorator job. Nothing else in this epic changes.
Refined 2026-08-31 into six tasks: T-1265 core/errors.py and @handle_errors, T-1266 core/logging.py and @logged, T-1267 the composed @command decorator, T-1268 global --verbose and --no-input, T-1269 closed-set enumeration, T-1270 the conformance test. Item (4) of the description above — core/console.py as the single output path — is ALREADY DONE, delivered by T-1258 and reshaped as the JSONL event emitter by the streaming amendment; no task for it. Sequencing: T-1265 and T-1266 are unblocked and independent; T-1267 needs both; T-1268 needs T-1266; T-1269 needs T-1265; T-1270 needs T-1267. Ordering note recorded during refinement: this epic must land BEFORE T-1250 moves the tree. The check router today hand-rolls console.verdict plus raise typer.Exit at each exit — exactly the call-site discipline these decorators replace — and porting ~120 commands with that pattern means writing it 120 times and then unwinding it.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:23:32.680', '2026-08-31 13:03:37.758', NULL, 'd5794fbe4db3dd48d3b4e218fb323c53', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E3 — domains/: move the 123-file tree into the layered package', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-31 13:20:21.937', NULL, 'd171daf9e0c3cae561e2e737c937e227', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E3 — domains/: move the 123-file tree into the layered package', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.
SURVEY 2026-08-31, before refining. The description above is right about the SHAPE and wrong about three counts. Re-measured:
RUST CRATES: THREE, not one. The description names only tooling/econ-sim. There are also tooling/line-previewer and tooling/test-client — both Cargo.toml + src/ with zero .py files. All three are excluded from the move and from package discovery, and none needs renaming, since a hyphen only matters for something Python has to import.
HYPHENATED PYTHON DIRECTORIES TO RENAME: FIVE, not four. economy-db (17 .py), garment-fit (23), garment-qa (1), planet-gen (30), pql-migrate (5). The description misses garment-qa. Already-importable subdirectories: db (9), wiki (1), plus the new core/ and domains/.
FILE COUNT: ~128 legacy .py files plus ~33 extensionless executables, not 123 total. Top level alone holds 42 .py and ~33 executables; the subdirectories hold 76 more .py.
WHAT THE PREFIX EVIDENCE ACTUALLY SUPPORTS — and this is the part that matters for how this epic is decomposed. Re-counted at the top level: blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2. That is ~51 files with an obvious home, and the description is right that the split is discovered rather than invented for them.
BUT there are 28 SINGLETON prefixes with no group to join: assign, canvas, clerk, convert, fill, generator, glb, inspect, migrate, patch, perf, pql, pr, process, render, schema, sculpt, setup, star, synth, tea, trellis, tune, wipe, worktree. Each needs a judgment call about which domain it belongs to, and several are genuinely ambiguous (canvas_sources.py and generator_sources.py are registries consumed by gates; schema_version.py is consumed by the DB importer; tea-comment and pr-watchlist-diff are PR workflow, which is not in the description''s domain list at all).
CONSEQUENCE FOR REFINEMENT: the description proposes "roughly one ticket per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev". Filing those nine now would be guessing, because ~28 files have no assigned domain and at least one domain (PR/workflow) is missing from the list. THE DOMAIN MAP IS THE BLOCKING DESIGN WORK and it gets its own ticket; per-domain port tickets are filed once it exists, so their boundaries are read off the map rather than invented and then renegotiated mid-move.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-31 13:23:30.519', NULL, '32e72a65f01a6ee07440ed9849a5fe25', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E3 — domains/: move the 123-file tree into the layered package', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.
SURVEY 2026-08-31, before refining. The description above is right about the SHAPE and wrong about three counts. Re-measured:
RUST CRATES: THREE, not one. The description names only tooling/econ-sim. There are also tooling/line-previewer and tooling/test-client — both Cargo.toml + src/ with zero .py files. All three are excluded from the move and from package discovery, and none needs renaming, since a hyphen only matters for something Python has to import.
HYPHENATED PYTHON DIRECTORIES TO RENAME: FIVE, not four. economy-db (17 .py), garment-fit (23), garment-qa (1), planet-gen (30), pql-migrate (5). The description misses garment-qa. Already-importable subdirectories: db (9), wiki (1), plus the new core/ and domains/.
FILE COUNT: ~128 legacy .py files plus ~33 extensionless executables, not 123 total. Top level alone holds 42 .py and ~33 executables; the subdirectories hold 76 more .py.
WHAT THE PREFIX EVIDENCE ACTUALLY SUPPORTS — and this is the part that matters for how this epic is decomposed. Re-counted at the top level: blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2. That is ~51 files with an obvious home, and the description is right that the split is discovered rather than invented for them.
BUT there are 28 SINGLETON prefixes with no group to join: assign, canvas, clerk, convert, fill, generator, glb, inspect, migrate, patch, perf, pql, pr, process, render, schema, sculpt, setup, star, synth, tea, trellis, tune, wipe, worktree. Each needs a judgment call about which domain it belongs to, and several are genuinely ambiguous (canvas_sources.py and generator_sources.py are registries consumed by gates; schema_version.py is consumed by the DB importer; tea-comment and pr-watchlist-diff are PR workflow, which is not in the description''s domain list at all).
CONSEQUENCE FOR REFINEMENT: the description proposes "roughly one ticket per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev". Filing those nine now would be guessing, because ~28 files have no assigned domain and at least one domain (PR/workflow) is missing from the list. THE DOMAIN MAP IS THE BLOCKING DESIGN WORK and it gets its own ticket; per-domain port tickets are filed once it exists, so their boundaries are read off the map rather than invented and then renegotiated mid-move.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-31 13:23:30.670', NULL, 'cc50d81b8581ea2365c1e128350039de', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The domain map — every file assigned before any file moves', 'The blocking design work for this epic, and the reason per-domain port tickets are not filed yet. Produce a single committed artefact — tooling/DOMAINS.md or similar — assigning EVERY one of the ~128 legacy .py files and ~33 extensionless executables to a domain, with its future module path and CLI verb. Until that exists, a per-domain ticket cannot state what it moves. What makes this real work rather than clerical: ~51 top-level files have an obvious home from their prefix (blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2), but 28 SINGLETON prefixes do not, and several are genuinely ambiguous. Worked examples to settle explicitly, since each sets a precedent: canvas_sources.py and generator_sources.py are REGISTRIES consumed by gates, not commands — do they become check-domain helpers, or core, or stay module-level data; schema_version.py is consumed by the DB importer and by a gate, so it is shared between two domains; tea-comment and pr-watchlist-diff are PR workflow, which is NOT in the epic''s proposed domain list at all, so either a workflow domain is added or they go to dev. Also decide: does become a domain, or do the 5 test-prefixed files stay as standalone scripts run by make (they are gate tests, not commands anyone types). Deliverable rule to apply throughout: a domain that has only a router and a service does not get empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota. Acceptance: no file unassigned, every ambiguous case carrying a one-line reason, and the verb list readable as the future output of reach --help.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:23:42.403', '2026-08-31 13:23:42.403', NULL, 'a47392206921fb53cbcf8cef87a20c68', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZCVZXH6M00C57EXMZD0SG', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Rename the five hyphenated Python directories', 'economy-db, garment-fit, garment-qa, planet-gen and pql-migrate are not importable — a hyphen is not a valid Python identifier — so nothing under them can be reached from the package. Rename to economy_db, garment_fit, garment_qa, planet_gen, pql_migrate (final names to be confirmed by the domain map, T-1271, which may fold some of them into a domain rather than keep them as top-level packages). NOT renamed, and this corrects the epic description: tooling/econ-sim, tooling/line-previewer and tooling/test-client are RUST crates — Cargo.toml plus src/, zero .py files. A hyphen only matters for something Python must import, so all three keep their names and simply stay outside package discovery. The epic named only econ-sim; the other two were found in the survey. Use git mv so history follows, and expect the blast radius to be wide: the Makefile references these paths in many targets, the pre-push hook runs tests from planet-gen and economy-db by path, tooling/generator_sources.py registers economy-db source paths for the systems.db stamp, and .claude/settings.json has per-path permission entries. Grep for each old name across Makefile, .config/hooks, .claude, docs and tooling itself before declaring done. VERIFY BY RUNNING, not by grepping: make test-tooling must pass, since it invokes planet-gen and economy-db tests directly and is the only thing that will actually prove the paths still resolve.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:23:53.471', '2026-08-31 13:23:53.471', NULL, '695f033f2837d962c9cf5bd77249496c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The Blender carve-out — payloads that must never import the package', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:23:59.951', '2026-08-31 13:23:59.951', NULL, '708f41a053be2679d4943a7a74c8356c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZEM6JT5KKVE1RZ68DENSC', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Retire the ruff ignores the package makes unnecessary', 'The ruff config currently ignores E402 (module-level import not at top of file), E702 (multiple statements on one line) and F841 (unused local variable). The E402 debt is script-style sys.path.insert-before-imports, which is exactly what a real package removes — so most of it should evaporate once files move under tooling/domains/ and import normally. Deliverable: after the moves land, re-enable E402 and fix or explicitly per-file-ignore whatever remains; then assess E702 and F841 on their own evidence rather than assuming they go the same way. Do this LAST in the epic, since running it mid-move means fixing violations in files that are about to move again. Note the honest possibility that E402 cannot be fully re-enabled: the Blender payloads (T-1273) may legitimately need sys.path manipulation before imports because they run under Blender''s bundled interpreter — if so, a per-file ignore scoped to tooling/scripts/blender/ is the right answer, not carrying a global ignore for the whole tree. A narrow documented exception is worth more than a broad silent one.', 'backlog', 'low', NULL, NULL, 'D-263', '2026-08-31 13:24:07.860', '2026-08-31 13:24:07.860', NULL, '57ab6f39d9ac0de63856097114a169fa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E3 — domains/: move the 123-file tree into the layered package', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.
SURVEY 2026-08-31, before refining. The description above is right about the SHAPE and wrong about three counts. Re-measured:
RUST CRATES: THREE, not one. The description names only tooling/econ-sim. There are also tooling/line-previewer and tooling/test-client — both Cargo.toml + src/ with zero .py files. All three are excluded from the move and from package discovery, and none needs renaming, since a hyphen only matters for something Python has to import.
HYPHENATED PYTHON DIRECTORIES TO RENAME: FIVE, not four. economy-db (17 .py), garment-fit (23), garment-qa (1), planet-gen (30), pql-migrate (5). The description misses garment-qa. Already-importable subdirectories: db (9), wiki (1), plus the new core/ and domains/.
FILE COUNT: ~128 legacy .py files plus ~33 extensionless executables, not 123 total. Top level alone holds 42 .py and ~33 executables; the subdirectories hold 76 more .py.
WHAT THE PREFIX EVIDENCE ACTUALLY SUPPORTS — and this is the part that matters for how this epic is decomposed. Re-counted at the top level: blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2. That is ~51 files with an obvious home, and the description is right that the split is discovered rather than invented for them.
BUT there are 28 SINGLETON prefixes with no group to join: assign, canvas, clerk, convert, fill, generator, glb, inspect, migrate, patch, perf, pql, pr, process, render, schema, sculpt, setup, star, synth, tea, trellis, tune, wipe, worktree. Each needs a judgment call about which domain it belongs to, and several are genuinely ambiguous (canvas_sources.py and generator_sources.py are registries consumed by gates; schema_version.py is consumed by the DB importer; tea-comment and pr-watchlist-diff are PR workflow, which is not in the description''s domain list at all).
CONSEQUENCE FOR REFINEMENT: the description proposes "roughly one ticket per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev". Filing those nine now would be guessing, because ~28 files have no assigned domain and at least one domain (PR/workflow) is missing from the list. THE DOMAIN MAP IS THE BLOCKING DESIGN WORK and it gets its own ticket; per-domain port tickets are filed once it exists, so their boundaries are read off the map rather than invented and then renegotiated mid-move.
REFINED 2026-08-31 — four cross-cutting tasks filed; the per-domain port tickets are DELIBERATELY NOT filed yet. T-1271 the domain map (blocking design — every file assigned before any file moves), T-1272 rename the five hyphenated Python directories, T-1273 the Blender carve-out, T-1274 retire the ruff ignores the package makes unnecessary. T-1272 and T-1273 are blocked on T-1271; T-1274 runs LAST, since re-enabling E402 mid-move means fixing violations in files that are about to move again. The per-domain tickets get filed from the map rather than from the epic''s proposed list, because that list is missing at least one domain (PR/workflow — tea-comment, pr-watchlist-diff) and leaves 28 singleton-prefix files unassigned. Filing nine tickets now and renegotiating their boundaries mid-move is the failure this ordering avoids.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-31 13:24:15.758', NULL, '7f3d4fb68aa7fc76fd25d9a7a21e0ac7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The domain map — every file assigned before any file moves', 'The blocking design work for this epic, and the reason per-domain port tickets are not filed yet. Produce a single committed artefact — tooling/DOMAINS.md or similar — assigning EVERY one of the ~128 legacy .py files and ~33 extensionless executables to a domain, with its future module path and CLI verb. Until that exists, a per-domain ticket cannot state what it moves. What makes this real work rather than clerical: ~51 top-level files have an obvious home from their prefix (blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2), but 28 SINGLETON prefixes do not, and several are genuinely ambiguous. Worked examples to settle explicitly, since each sets a precedent: canvas_sources.py and generator_sources.py are REGISTRIES consumed by gates, not commands — do they become check-domain helpers, or core, or stay module-level data; schema_version.py is consumed by the DB importer and by a gate, so it is shared between two domains; tea-comment and pr-watchlist-diff are PR workflow, which is NOT in the epic''s proposed domain list at all, so either a workflow domain is added or they go to dev. Also decide: does become a domain, or do the 5 test-prefixed files stay as standalone scripts run by make (they are gate tests, not commands anyone types). Deliverable rule to apply throughout: a domain that has only a router and a service does not get empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota. Acceptance: no file unassigned, every ambiguous case carrying a one-line reason, and the verb list readable as the future output of reach --help.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:23:42.403', '2026-08-31 13:24:23.248', NULL, '2d5ac4ad8ae62bd30765d536d14e9fd0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The domain map — every file assigned before any file moves', 'The blocking design work for this epic, and the reason per-domain port tickets are not filed yet. Produce a single committed artefact — tooling/DOMAINS.md or similar — assigning EVERY one of the ~128 legacy .py files and ~33 extensionless executables to a domain, with its future module path and CLI verb. Until that exists, a per-domain ticket cannot state what it moves. What makes this real work rather than clerical: ~51 top-level files have an obvious home from their prefix (blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2), but 28 SINGLETON prefixes do not, and several are genuinely ambiguous. Worked examples to settle explicitly, since each sets a precedent: canvas_sources.py and generator_sources.py are REGISTRIES consumed by gates, not commands — do they become check-domain helpers, or core, or stay module-level data; schema_version.py is consumed by the DB importer and by a gate, so it is shared between two domains; tea-comment and pr-watchlist-diff are PR workflow, which is NOT in the epic''s proposed domain list at all, so either a workflow domain is added or they go to dev. Also decide: does become a domain, or do the 5 test-prefixed files stay as standalone scripts run by make (they are gate tests, not commands anyone types). Deliverable rule to apply throughout: a domain that has only a router and a service does not get empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota. Acceptance: no file unassigned, every ambiguous case carrying a one-line reason, and the verb list readable as the future output of reach --help.
DONE 2026-08-31. tooling/DOMAINS.md committed: 15 domains, every file and executable assigned, judgment calls carrying their reasons.
THREE CORRECTIONS FOUND BY COUNTING RATHER THAN READING, one of which reverses my own earlier survey.
(1) THE BLENDER CARVE-OUT IS 35 FILES, NOT 13 — and the T-1250 description was RIGHT while my survey on this ticket was wrong. 13 blender_*.py sit at top level; 22 more are inside garment-fit/. My survey counted only the top level and told the epic it had overstated the number. It had not.
The consequence is not cosmetic. garment-fit/ is 23 files of which 22 are blender_author_* payloads, so it is a Blender payload directory wearing a domain''s name. `character` is therefore a MUCH smaller domain than directory sizes suggest, and T-1273''s carve-out is much larger. A per-domain ticket written from the directory listing would have been wrong about both.
(2) THE "28 SINGLETON PREFIXES" WERE AN ARTEFACT OF MY OWN MEASUREMENT. Splitting filenames on the first token scattered coherent families: sculpt-star-map, tune-star-map-topology and generate-star-map* are ONE group (starmap), counted as three orphans. Counting families rather than prefixes, the genuinely ambiguous set is small and is now enumerated with reasons instead of being a number.
(3) tooling/db/ IS MISNAMED and naming a domain after it would carry the misnomer forward. It holds the audio/image/Trellis connectors and wiki_sync.py; the actual database work is in economy-db/. Its contents split between a new `assets` domain (the tower-of-joy connectors) and `wiki`.
JUDGMENT CALLS SETTLED, each with a recorded reason since each sets a precedent:
- Registries (canvas_sources.py, generator_sources.py) stay module-level data, NOT verbs. Nothing types them; putting them in reach --help would answer a question nobody has.
- schema_version.py goes to `db` where it is DEFINED, and `check` imports it. Shared but not equally owned — that cross-domain import is what a service layer is for.
- test_*.py do NOT become a domain. They are gate tests run by make, and `reach test ...` would imply a runner that does not exist.
- pql-migrate/ is PROVENANCE, not tooling. Moves to tooling/archive/, excluded from discovery. Deleting destroys migration provenance; keeping it importable implies it still runs.
- `pr` is a domain the epic did not list (tea-comment, pr-watchlist-diff, pql-board-html). Folding them into `dev` would make dev the drawer everything ambiguous goes into — the same rot D-263 warns about for core/.
- `character`, not D-263''s `garment`: the files cover bodies, hair, GLB and Quaternius as well as clothing.
- `atlas` IS OVERLOADED ACROSS THREE PLACES and must not be merged: top-level atlas-* executables (map data), planet-gen/atlas_*.py (terrain quality analysis), economy-db/atlas.py (systems.db index tables). Three concerns sharing a noun. Each stays with its owner.
- economy-db/errors.py predates core/errors.py and is not the same thing. Reconcile explicitly when db is ported; a silent merge or a second ReachError with different semantics is the risk.
PER-DOMAIN PORT TICKETS can now be filed from this map rather than guessed. Recommended order, cheapest and most-verified first: check (4 remaining gates, already has the reference implementation and a parity test to copy), validate, godot, visual, then the large ones — planet, db, character, wiki, assets, atlas, starmap, generate, dev, pr.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:23:42.403', '2026-08-31 13:26:38.138', NULL, '7d9104b1b6b7710a8a5fe7fef483b882', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The Blender carve-out — payloads that must never import the package', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates.
CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:23:59.951', '2026-08-31 13:26:41.808', NULL, '27621bcf97a4f061f3f89f7868004932', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZBGREDJ12W3Q9GNB8C7PC', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The domain map — every file assigned before any file moves', 'The blocking design work for this epic, and the reason per-domain port tickets are not filed yet. Produce a single committed artefact — tooling/DOMAINS.md or similar — assigning EVERY one of the ~128 legacy .py files and ~33 extensionless executables to a domain, with its future module path and CLI verb. Until that exists, a per-domain ticket cannot state what it moves. What makes this real work rather than clerical: ~51 top-level files have an obvious home from their prefix (blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2), but 28 SINGLETON prefixes do not, and several are genuinely ambiguous. Worked examples to settle explicitly, since each sets a precedent: canvas_sources.py and generator_sources.py are REGISTRIES consumed by gates, not commands — do they become check-domain helpers, or core, or stay module-level data; schema_version.py is consumed by the DB importer and by a gate, so it is shared between two domains; tea-comment and pr-watchlist-diff are PR workflow, which is NOT in the epic''s proposed domain list at all, so either a workflow domain is added or they go to dev. Also decide: does become a domain, or do the 5 test-prefixed files stay as standalone scripts run by make (they are gate tests, not commands anyone types). Deliverable rule to apply throughout: a domain that has only a router and a service does not get empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota. Acceptance: no file unassigned, every ambiguous case carrying a one-line reason, and the verb list readable as the future output of reach --help.
DONE 2026-08-31. tooling/DOMAINS.md committed: 15 domains, every file and executable assigned, judgment calls carrying their reasons.
THREE CORRECTIONS FOUND BY COUNTING RATHER THAN READING, one of which reverses my own earlier survey.
(1) THE BLENDER CARVE-OUT IS 35 FILES, NOT 13 — and the T-1250 description was RIGHT while my survey on this ticket was wrong. 13 blender_*.py sit at top level; 22 more are inside garment-fit/. My survey counted only the top level and told the epic it had overstated the number. It had not.
The consequence is not cosmetic. garment-fit/ is 23 files of which 22 are blender_author_* payloads, so it is a Blender payload directory wearing a domain''s name. `character` is therefore a MUCH smaller domain than directory sizes suggest, and T-1273''s carve-out is much larger. A per-domain ticket written from the directory listing would have been wrong about both.
(2) THE "28 SINGLETON PREFIXES" WERE AN ARTEFACT OF MY OWN MEASUREMENT. Splitting filenames on the first token scattered coherent families: sculpt-star-map, tune-star-map-topology and generate-star-map* are ONE group (starmap), counted as three orphans. Counting families rather than prefixes, the genuinely ambiguous set is small and is now enumerated with reasons instead of being a number.
(3) tooling/db/ IS MISNAMED and naming a domain after it would carry the misnomer forward. It holds the audio/image/Trellis connectors and wiki_sync.py; the actual database work is in economy-db/. Its contents split between a new `assets` domain (the tower-of-joy connectors) and `wiki`.
JUDGMENT CALLS SETTLED, each with a recorded reason since each sets a precedent:
- Registries (canvas_sources.py, generator_sources.py) stay module-level data, NOT verbs. Nothing types them; putting them in reach --help would answer a question nobody has.
- schema_version.py goes to `db` where it is DEFINED, and `check` imports it. Shared but not equally owned — that cross-domain import is what a service layer is for.
- test_*.py do NOT become a domain. They are gate tests run by make, and `reach test ...` would imply a runner that does not exist.
- pql-migrate/ is PROVENANCE, not tooling. Moves to tooling/archive/, excluded from discovery. Deleting destroys migration provenance; keeping it importable implies it still runs.
- `pr` is a domain the epic did not list (tea-comment, pr-watchlist-diff, pql-board-html). Folding them into `dev` would make dev the drawer everything ambiguous goes into — the same rot D-263 warns about for core/.
- `character`, not D-263''s `garment`: the files cover bodies, hair, GLB and Quaternius as well as clothing.
- `atlas` IS OVERLOADED ACROSS THREE PLACES and must not be merged: top-level atlas-* executables (map data), planet-gen/atlas_*.py (terrain quality analysis), economy-db/atlas.py (systems.db index tables). Three concerns sharing a noun. Each stays with its owner.
- economy-db/errors.py predates core/errors.py and is not the same thing. Reconcile explicitly when db is ported; a silent merge or a second ReachError with different semantics is the risk.
PER-DOMAIN PORT TICKETS can now be filed from this map rather than guessed. Recommended order, cheapest and most-verified first: check (4 remaining gates, already has the reference implementation and a parity test to copy), validate, godot, visual, then the large ones — planet, db, character, wiki, assets, atlas, starmap, generate, dev, pr.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 13:23:42.403', '2026-08-31 13:26:59.127', NULL, '2bbeba1b0d3aa3fddae88e8f194ed11e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3G1D2828RX2F3ZFNWRJGC', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E6 — Retire the old paths: make targets, docs, skills, permissions', 'The migration is not done when reach works; it is done when nothing calls the old paths. Surface: 84 Makefile targets, 160 markdown files under .claude/ and docs/ that name tooling/ paths, the pre-push hook, and the skills. Pieces that become tickets: (1) make targets become THIN WRAPPERS over reach — make stays the door for zero-argument repo verbs, reach is the door for anything taking arguments; one implementation, and the door holding it is reach; note Bash(make *) is already blanket-allowed so this epic does not claim to make make cheaper; (2) the 10 hand-written Bash(tooling/...) entries in .claude/settings.json are deleted, replaced by the single Bash(reach *); (3) deprecation shims — each retired path leaves a stub that prints the new command and exits NON-ZERO, the failure contract applied to the migration itself, removed after a window rather than deleted under the callers; (4) sweep the 160 markdown callers, including .claude/rules/*.md and every skill that shells out to a tooling path; (5) the reach skill documenting usage AND how to add a domain or command; (6) the CLAUDE.md rule that no new tooling is written outside the CLI, so the tree cannot re-fragment; (7) delete the shims and confirm no tooling/ script path survives outside scripts/blender/.
RESHAPED 2026-08-31 by decision. The description''s plan — 84 make targets become reach wrappers — is REPLACED. make and reach now split BY KIND, not by preference (D-263 amended): make keeps genuine build and test orchestration (cargo, Godot, the test gate, anything sequencing a build), and targets that are really tooling wrappers are RETIRED in favour of reach verbs rather than wrapped. Reason: a wrapper leaves two ways to invoke every tool, and then reach --help is no longer the answer to what tooling exists because the Makefile is still a competing index — two doors is the condition this initiative exists to end, so preserving both would defeat it while looking like caution. Where a target must survive for muscle memory it delegates to reach in one line and says so; the test is whether a reader can tell from the target alone which of the two owns the behaviour. ALSO CHANGED — retirement timing. Old scripts are now retired PER DOMAIN, the moment that domain''s port passes a parity test, rather than all at the end in one sweep here. So this epic shrinks: most retirement happens inside the per-domain port tickets under T-1250, and what remains here is the final sweep for anything not owned by a domain plus the Makefile split above. Rationale: a continuous shrink avoids months where every tool exists twice and edits can land in the dead copy.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:24:23.657', '2026-08-31 13:37:55.588', NULL, '909fda9e752ff283beedc13bd3c52958', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
AMENDED 2026-08-20 — scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time — you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter — JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive — detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.
RESHAPED 2026-08-31 by decision, and the shape is better than what this ticket specified. Jeroen: ''not just like the decorators, but AS decorators.'' Streaming is a THIRD cross-cutting concern alongside logging and error handling, delivered the same way. @command already wraps every invocation — that is exactly the seam where job identity, progress correlation and detach belong. The decorator assigns the job id, tags every event emitted during the invocation with it, and given --detach forks and returns the id immediately. A COMMAND MUST NOT KNOW THAT JOBS EXIST. The alternative in the description above, where a command opens a job and remembers to close it, is call-site discipline wearing a different hat and fails identically: the fortieth command of a porting session forgets, and its failure vanishes from the log with nothing indicating anything is missing. D-263 amended to state this. SEQUENCING ALSO CHANGED: this now lands BEFORE the T-1250 move, not after, for the same reason E2 did — every ported command should arrive already streaming rather than being retrofitted. The blocker on T-1249 is satisfied (E2 closed 2026-08-31), so this is next. Practical consequence for implementation: core/command.py already composes handle_errors(logged(func)) and is the single place to add this; the job id becomes an ambient value that core/console reads when tagging events, so no command signature changes.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-31 13:38:05.851', NULL, '4ba2b178e4e9d406141998eddc45f4b3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S3D0M1TQW0GMFBBPQZG3ZM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E3 — domains/: move the 123-file tree into the layered package', 'The mechanical bulk. Move every Python file into tooling/domains/<name>/ split router/service/schemas/helpers, normalising names on the way in. The split is DISCOVERED not invented — the domains are already encoded as filename prefixes (blender x14, atlas x8, generate x7, check x7, visual/validate/test x3, godot/garment/pql/install x2), which is the main evidence this is safe to do mechanically. Pieces that become tickets, roughly one per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev. Cross-cutting pieces: (1) naming normalisation — modules snake_case, CLI verbs kebab-case, replacing the four conventions currently coexisting (assign-astro-ids.py, canvas_sources.py, check-canvas-version, blender_ prefix-as-namespace); (2) the four hyphenated directories planet-gen, economy-db, garment-fit, pql-migrate are not importable and must be renamed; (3) tooling/econ-sim is a Rust crate — excluded from package discovery, not moved; (4) the Blender carve-out — tooling/blender stays a bash wrapper and the 35 blender_*.py payload files move to tooling/scripts/blender/, executed under Blender bundled Python and never imported, with reach fronting them; (5) ruff clean under the existing config, and the E402/E702/F841 ignores revisited since the script-style sys.path.insert that forced E402 disappears with the package. Watch for: a domain that only has a router and a service does not need empty schemas.py and dependencies.py — the layering is a vocabulary, not a quota.
SURVEY 2026-08-31, before refining. The description above is right about the SHAPE and wrong about three counts. Re-measured:
RUST CRATES: THREE, not one. The description names only tooling/econ-sim. There are also tooling/line-previewer and tooling/test-client — both Cargo.toml + src/ with zero .py files. All three are excluded from the move and from package discovery, and none needs renaming, since a hyphen only matters for something Python has to import.
HYPHENATED PYTHON DIRECTORIES TO RENAME: FIVE, not four. economy-db (17 .py), garment-fit (23), garment-qa (1), planet-gen (30), pql-migrate (5). The description misses garment-qa. Already-importable subdirectories: db (9), wiki (1), plus the new core/ and domains/.
FILE COUNT: ~128 legacy .py files plus ~33 extensionless executables, not 123 total. Top level alone holds 42 .py and ~33 executables; the subdirectories hold 76 more .py.
WHAT THE PREFIX EVIDENCE ACTUALLY SUPPORTS — and this is the part that matters for how this epic is decomposed. Re-counted at the top level: blender 14, atlas 8, generate 7, check 7, test 5, visual 3, validate 3, install 2, godot 2. That is ~51 files with an obvious home, and the description is right that the split is discovered rather than invented for them.
BUT there are 28 SINGLETON prefixes with no group to join: assign, canvas, clerk, convert, fill, generator, glb, inspect, migrate, patch, perf, pql, pr, process, render, schema, sculpt, setup, star, synth, tea, trellis, tune, wipe, worktree. Each needs a judgment call about which domain it belongs to, and several are genuinely ambiguous (canvas_sources.py and generator_sources.py are registries consumed by gates; schema_version.py is consumed by the DB importer; tea-comment and pr-watchlist-diff are PR workflow, which is not in the description''s domain list at all).
CONSEQUENCE FOR REFINEMENT: the description proposes "roughly one ticket per domain: atlas, check, validate, generate, visual, godot, wiki, db, dev". Filing those nine now would be guessing, because ~28 files have no assigned domain and at least one domain (PR/workflow) is missing from the list. THE DOMAIN MAP IS THE BLOCKING DESIGN WORK and it gets its own ticket; per-domain port tickets are filed once it exists, so their boundaries are read off the map rather than invented and then renegotiated mid-move.
REFINED 2026-08-31 — four cross-cutting tasks filed; the per-domain port tickets are DELIBERATELY NOT filed yet. T-1271 the domain map (blocking design — every file assigned before any file moves), T-1272 rename the five hyphenated Python directories, T-1273 the Blender carve-out, T-1274 retire the ruff ignores the package makes unnecessary. T-1272 and T-1273 are blocked on T-1271; T-1274 runs LAST, since re-enabling E402 mid-move means fixing violations in files that are about to move again. The per-domain tickets get filed from the map rather than from the epic''s proposed list, because that list is missing at least one domain (PR/workflow — tea-comment, pr-watchlist-diff) and leaves 28 singleton-prefix files unassigned. Filing nine tickets now and renegotiating their boundaries mid-move is the failure this ordering avoids.
SEQUENCING SET 2026-08-31 by decision. (1) T-1264 (streaming, as decorators) lands BEFORE the per-domain ports, so every ported command arrives already streaming. (2) Old scripts retire PER DOMAIN, the moment that domain''s port passes a parity test — not in one sweep at T-1253. So each per-domain port ticket owns its own retirement, and the tree shrinks continuously rather than existing twice for months while edits risk landing in the dead copy. (3) Domain names as mapped are confirmed — no renames. (4) Per D-263''s amended make/reach split, a per-domain port also RETIRES that domain''s tooling make targets rather than leaving wrappers; build/test orchestration targets stay with make.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-31 13:38:09.189', NULL, 'f4baef3d1ff514ca3ee4823cf2c12043', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7EZ0HZSZT5HYHJ5HPBQ2G', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Permission entry: Bash(reach *) replaces per-script prompting', 'Add Bash(reach *) to .claude/settings.json. This single line is the friction Q-124 was actually filed about — the permission gate prefix-matches whole command strings, a blanket Bash(python3 *) grant is explicitly forbidden as an unbounded write grant, so today ten separate hand-written Bash(tooling/...) entries each cover one script and every unlisted tool prompts. One bare command with subcommands is one entry covering the whole surface, which is exactly why pql is frictionless today. Scope note: ADD the reach entry here, do not remove the ten tooling entries — the old paths stay live through the deprecation window and removing their permissions early would make them prompt during E3 to E5 when they are still the working tools. The removal is E6. Verify the rule actually matches by making a real reach call through the permission gate rather than by reading the JSON, since a rule that looks right and does not match is the failure mode this whole initiative exists to fix.
DONE 2026-08-31, with an honest limit on the verification — read that part before trusting this.
DELIVERED — TWO entries, not one.
Added both Bash(reach) and Bash(reach *) to .claude/settings.json, placed beside the pql pair. The ticket asked only for Bash(reach *), but a rule ending in " *" does NOT match the bare word, and bare `reach` is a real invocation now that it prints the domain list. The established convention here confirms it: pql, make, cargo test and ruff check each carry a bare-form entry alongside the wildcard one, for exactly this reason. Adding only the wildcard would have left `reach` prompting while `reach check ...` did not — a confusing half-fix.
The ten tooling/ entries were left in place per the scope note; removing them is T-1253, and taking their permissions away while they are still the working tools would make them prompt through T-1250 to T-1252.
VERIFICATION — WHAT WAS AND WAS NOT PROVEN. This matters given the ticket''s own warning.
Ran real calls through the gate: `reach check client-version` (exit 0) and `reach --help` (domain list). Both succeeded with no prompt.
BUT THAT IS NOT EVIDENCE THE RULE WORKED. Those same calls succeeded throughout this session BEFORE the rule existed — I checked, and there was no Bash(reach ...) entry in either .claude/settings.json or .claude/settings.local.json, and no blanket Bash(*) grant in either. So something else (session permission mode) was already allowing them, and the observation is confounded. A passing call here cannot distinguish "the rule matched" from "the rule was never consulted".
WHAT WOULD ACTUALLY PROVE IT: a session started AFTER this commit, in a mode that prompts, where `reach check client-version` runs without a prompt. settings.json is read at session start, so this cannot be self-verified from inside the session that wrote it. Flagging rather than claiming a green.
What IS established: the JSON parses, both entries are present in permissions.allow, and the rule shape is byte-identical in form to the pql pair, which is empirically frictionless in this repo. That is inference from a working precedent, not proof.
FINDING — env-prefixed calls will still prompt, and that is accepted.
Permission rules prefix-match the WHOLE command string (root CLAUDE.md: `PATH=/opt/bin make build` does not match `Bash(make *)`). So `SR_REPO_ROOT=... reach ...` and `SR_OUTPUT_FORMAT=text reach ...` do not match Bash(reach *) and will prompt. Deliberately not worked around: an environment override IS a genuine departure from normal invocation, and it is the ordinary form that needs to be frictionless. Recorded in docs/DEVOPS.md with the mitigation for tests — pass overrides through the subprocess environment rather than the command string, as tooling/test_check_parity.py does.
REVISITED 2026-08-31: the env-prefix limitation this ticket recorded as ''accepted'' deserves a second look, because the primary user of reach is an AGENT (D-263, stated 2026-08-31), not a human at a terminal. Permission rules prefix-match the whole command string, so SR_REPO_ROOT=... reach ... and SR_OUTPUT_FORMAT=text reach ... do not match Bash(reach *) and will prompt. I called that acceptable on the grounds that an env override is a departure from normal invocation — which is true for a human and less true for me, since the override forms are exactly what a test harness and a debugging session reach for. NOT changing it here: the ordinary form is frictionless, the override forms are rare in normal use, and a broad Bash(env *) grant would be a much larger permission surface for a small convenience. Recorded so the tradeoff is visible if it starts costing prompts in practice — if it does, the fix is a narrow rule per variable, not a blanket one.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:41:43.428', '2026-08-31 13:41:02.168', NULL, '0d424100c4529a4ad0ac8ca2111edfbd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G433M6K7YSX3ZQFAMW95GC', 'task', '06G1S37Y1ARMV68RJT802Z3VPG', 'Bare reach is an alias for --help, and exits 0', 'Bare and bare currently print help and exit 2 — Click''s no_args_is_help raises a usage error. That was flagged as an open question on T-1260 and is now settled: they exit 0. Reason, and it follows from the primary-user statement in D-263: running with no arguments is the DISCOVERY action, not a mistake. An agent learning what exists should not receive a failure code for succeeding, and one that branches on exit status would treat its own onboarding as an error. Nothing about D-263''s exit-code contract is weakened — that contract governs FAILURES, and printing the domain list is not one. Implementation note: this also removes a duplicated group class. core/cli.py gains ReachGroup as the shared base carrying both behaviours — no-args-prints-help-and-exits-0, and unknown-command-enumerates — and main.py''s LazyDomainGroup extends it rather than reimplementing the enumeration, keeping only the lazy loading and the domain-specific wording.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:44:24.225', '2026-08-31 13:44:24.225', NULL, '9d858d6e3cae73056736dc0ccd6f3997', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G433M6K7YSX3ZQFAMW95GC', 'task', '06G1S37Y1ARMV68RJT802Z3VPG', 'Bare reach is an alias for --help, and exits 0', 'Bare reach and bare ''reach <domain>'' currently print help and exit 2 — Click''s no_args_is_help raises a usage error. That was flagged as an open question on T-1260 and is now settled: they exit 0.
Reason, and it follows from the primary-user statement in D-263: running reach with no arguments is the DISCOVERY action, not a mistake. An agent learning what exists should not receive a failure code for succeeding, and one that branches on exit status would treat its own onboarding as an error. Nothing about D-263''s exit-code contract is weakened — that contract governs FAILURES, and printing the domain list is not one.
Implementation note: this also removes a duplicated group class. core/cli.py gains ReachGroup as the shared base carrying both behaviours — no-args-prints-help-and-exits-0, and unknown-command-enumerates — and main.py''s LazyDomainGroup extends it rather than reimplementing the enumeration, keeping only the lazy loading and the domain-specific wording.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:44:24.225', '2026-08-31 13:44:35.092', NULL, '1f18d7bb6ffe7f17a7cc833c72f56962', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G433M6K7YSX3ZQFAMW95GC', 'task', '06G1S37Y1ARMV68RJT802Z3VPG', 'Bare reach is an alias for --help, and exits 0', 'Bare reach and bare ''reach <domain>'' currently print help and exit 2 — Click''s no_args_is_help raises a usage error. That was flagged as an open question on T-1260 and is now settled: they exit 0.
Reason, and it follows from the primary-user statement in D-263: running reach with no arguments is the DISCOVERY action, not a mistake. An agent learning what exists should not receive a failure code for succeeding, and one that branches on exit status would treat its own onboarding as an error. Nothing about D-263''s exit-code contract is weakened — that contract governs FAILURES, and printing the domain list is not one.
Implementation note: this also removes a duplicated group class. core/cli.py gains ReachGroup as the shared base carrying both behaviours — no-args-prints-help-and-exits-0, and unknown-command-enumerates — and main.py''s LazyDomainGroup extends it rather than reimplementing the enumeration, keeping only the lazy loading and the domain-specific wording.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:44:24.225', '2026-08-31 13:44:35.236', NULL, 'c3a6bef2d40b5a37e992a5944920e6bb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G433M6K7YSX3ZQFAMW95GC', 'task', '06G1S37Y1ARMV68RJT802Z3VPG', 'Bare reach is an alias for --help, and exits 0', 'Bare reach and bare ''reach <domain>'' currently print help and exit 2 — Click''s no_args_is_help raises a usage error. That was flagged as an open question on T-1260 and is now settled: they exit 0.
Reason, and it follows from the primary-user statement in D-263: running reach with no arguments is the DISCOVERY action, not a mistake. An agent learning what exists should not receive a failure code for succeeding, and one that branches on exit status would treat its own onboarding as an error. Nothing about D-263''s exit-code contract is weakened — that contract governs FAILURES, and printing the domain list is not one.
Implementation note: this also removes a duplicated group class. core/cli.py gains ReachGroup as the shared base carrying both behaviours — no-args-prints-help-and-exits-0, and unknown-command-enumerates — and main.py''s LazyDomainGroup extends it rather than reimplementing the enumeration, keeping only the lazy loading and the domain-specific wording.
DONE 2026-08-31. Bare reach and bare ''reach <domain>'' print help and exit 0. Verified across the full matrix, because this change flirts with exactly the trap D-263 opens with — making failures pass: bare reach 0, bare domain 0, --help 0, unknown domain 2, unknown verb 2, real failure (bad SR_REPO_ROOT) 1. All five are now PINNED in tooling/test_conformance.py as a sixth invariant, since an exit code is the kind of thing that regresses silently and no other assertion would notice. Proven to fail: setting the exit back to 2 made the test report both discovery cases by name. IMPLEMENTATION also removed a duplicated class, which is the part worth keeping. core/cli.py now holds ReachGroup carrying both shared behaviours — no-args-prints-help-and-exits-0, and unknown-name-enumerates — and main.py''s LazyDomainGroup EXTENDS it rather than subclassing TyperGroup directly, so it keeps only the lazy loading and the domain-specific wording. Before this the enumeration logic existed twice in slightly different forms, which is the drift that would have left the root and the domains disagreeing about their own conventions. ReachDomainGroup survives as an alias because domain routers read better with the name, but there is no separate behaviour: a domain group IS a reach group.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:44:24.225', '2026-08-31 13:48:56.725', NULL, 'befbb50fcdf4b6b7e53e298311d13539', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G433M6K7YSX3ZQFAMW95GC', 'task', '06G1S37Y1ARMV68RJT802Z3VPG', 'Bare reach is an alias for --help, and exits 0', 'Bare reach and bare ''reach <domain>'' currently print help and exit 2 — Click''s no_args_is_help raises a usage error. That was flagged as an open question on T-1260 and is now settled: they exit 0.
Reason, and it follows from the primary-user statement in D-263: running reach with no arguments is the DISCOVERY action, not a mistake. An agent learning what exists should not receive a failure code for succeeding, and one that branches on exit status would treat its own onboarding as an error. Nothing about D-263''s exit-code contract is weakened — that contract governs FAILURES, and printing the domain list is not one.
Implementation note: this also removes a duplicated group class. core/cli.py gains ReachGroup as the shared base carrying both behaviours — no-args-prints-help-and-exits-0, and unknown-command-enumerates — and main.py''s LazyDomainGroup extends it rather than reimplementing the enumeration, keeping only the lazy loading and the domain-specific wording.
DONE 2026-08-31. Bare reach and bare ''reach <domain>'' print help and exit 0. Verified across the full matrix, because this change flirts with exactly the trap D-263 opens with — making failures pass: bare reach 0, bare domain 0, --help 0, unknown domain 2, unknown verb 2, real failure (bad SR_REPO_ROOT) 1. All five are now PINNED in tooling/test_conformance.py as a sixth invariant, since an exit code is the kind of thing that regresses silently and no other assertion would notice. Proven to fail: setting the exit back to 2 made the test report both discovery cases by name. IMPLEMENTATION also removed a duplicated class, which is the part worth keeping. core/cli.py now holds ReachGroup carrying both shared behaviours — no-args-prints-help-and-exits-0, and unknown-name-enumerates — and main.py''s LazyDomainGroup EXTENDS it rather than subclassing TyperGroup directly, so it keeps only the lazy loading and the domain-specific wording. Before this the enumeration logic existed twice in slightly different forms, which is the drift that would have left the root and the domains disagreeing about their own conventions. ReachDomainGroup survives as an alias because domain routers read better with the name, but there is no separate behaviour: a domain group IS a reach group.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:44:24.225', '2026-08-31 13:49:10.948', NULL, '70975bb01ad76d50ddce27e7bbd3da16', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
AMENDED 2026-08-20 — scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time — you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter — JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive — detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.
RESHAPED 2026-08-31 by decision, and the shape is better than what this ticket specified. Jeroen: ''not just like the decorators, but AS decorators.'' Streaming is a THIRD cross-cutting concern alongside logging and error handling, delivered the same way. @command already wraps every invocation — that is exactly the seam where job identity, progress correlation and detach belong. The decorator assigns the job id, tags every event emitted during the invocation with it, and given --detach forks and returns the id immediately. A COMMAND MUST NOT KNOW THAT JOBS EXIST. The alternative in the description above, where a command opens a job and remembers to close it, is call-site discipline wearing a different hat and fails identically: the fortieth command of a porting session forgets, and its failure vanishes from the log with nothing indicating anything is missing. D-263 amended to state this. SEQUENCING ALSO CHANGED: this now lands BEFORE the T-1250 move, not after, for the same reason E2 did — every ported command should arrive already streaming rather than being retrofitted. The blocker on T-1249 is satisfied (E2 closed 2026-08-31), so this is next. Practical consequence for implementation: core/command.py already composes handle_errors(logged(func)) and is the single place to add this; the job id becomes an ambient value that core/console reads when tagging events, so no command signature changes.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-31 13:51:22.480', NULL, 'b7d7bbf75cc0f627ccaa51c92a7cde47', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G1S7NVJR0GT9KWS9QVYNNFMM', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E9 — Streaming output, job logs, and reattach without a daemon', 'Added 2026-08-20 from a session finding: the gates are milliseconds but the generators are minutes, and the callers that matter have ceilings. An agent Bash call gives up at two minutes and SENDS NOTHING — already a recorded scar here for git push under the full cargo-test hook. make regen-db, the planet-gen pipeline, the Blender batches and the Trellis/audio connectors all sit past that line, so reach needs a detach mode or the CLI is unusable for exactly the work that hurts most today. Pieces that become tickets: (1) core/process.py — the spawn primitive: detach so the child outlives the parent, redirect stdout and stderr to a per-job log under .cache/reach/jobs/ (gitignored), record command, start time, pid; (2) a global --detach flag, because long-running is a property of the INVOCATION not the verb, so it must not become a parallel set of commands; (3) the jobs domain — reach jobs list/status/log/wait — which is a DOMAIN and not core/, because it has logic and state of its own; this is the first real test of the core/ bound in D-263 and it holds; (4) EXIT CODES SURVIVE — reach jobs wait exits with the job exit code, and an unwaited failed job is visible in reach jobs list; a runner that reports started and loses the failure is the exit-0 trap from D-263 moved somewhere nothing is watching, which is worse; (5) log retention and cleanup, so .cache/reach/jobs does not grow without bound. DESIGN QUESTION to settle first, before building: Claude Code Bash already has a background mode that solves the timeout for agents, so scope this to the callers that have no such mode — a human terminal, a Makefile, a git hook — plus durable logs and job history. Do not rebuild what one caller already provides. Blocked on E2, since the error and logging decorators define what a job record has to capture.
AMENDED 2026-08-20 — scope settled, and it grew in the useful direction. The description above is a DETACH-ONLY design; the model is now STREAMING plus detach. Detaching alone fixes the timeout and keeps the silence; streaming fixes the thing that actually costs time — you learn a generator is wedged at minute one instead of minute nine. Settled with Jeroen: streaming plus log tail, explicitly NO DAEMON, and JSONL events rendered for humans. Revised pieces: (1) core/console.py is the event emitter — JSONL to stderr, one object per line, ts/level/phase/message and optional progress; landing in T-1258, not here; (2) core/process.py spawn primitive — detach so the child outlives the parent, stream to a per-job log at .cache/reach/jobs/<id>.jsonl (gitignored), record command, start time, pid; (3) the global --detach flag, since long-running is a property of the invocation not the verb; (4) the jobs domain — list/status/log/wait, with log --follow tailing; REATTACH IS A BYTE OFFSET into an append-only file, which is the whole reason no daemon is needed and a caller can attach, drop off and come back; (5) exit codes survive — unchanged and still non-negotiable; (6) log retention and cleanup. THE TRAP TO WATCH: streaming must stay ADDITIVE to the failure contract. A remedy emitted at line 400 of 900 is technically printed and practically invisible, so the verdict — outcome, exit code, remedy — is still printed once, last. A stream that dissolves the summary quietly undoes the requirement D-263 cares most about. Deferred deliberately: a real session layer that survives between calls. It is to be FOUND necessary, not assumed — an append-only file plus tail buys about 90 percent of the value with no lifecycle, no orphans and no staleness.
RESHAPED 2026-08-31 by decision, and the shape is better than what this ticket specified. Jeroen: ''not just like the decorators, but AS decorators.'' Streaming is a THIRD cross-cutting concern alongside logging and error handling, delivered the same way. @command already wraps every invocation — that is exactly the seam where job identity, progress correlation and detach belong. The decorator assigns the job id, tags every event emitted during the invocation with it, and given --detach forks and returns the id immediately. A COMMAND MUST NOT KNOW THAT JOBS EXIST. The alternative in the description above, where a command opens a job and remembers to close it, is call-site discipline wearing a different hat and fails identically: the fortieth command of a porting session forgets, and its failure vanishes from the log with nothing indicating anything is missing. D-263 amended to state this. SEQUENCING ALSO CHANGED: this now lands BEFORE the T-1250 move, not after, for the same reason E2 did — every ported command should arrive already streaming rather than being retrofitted. The blocker on T-1249 is satisfied (E2 closed 2026-08-31), so this is next. Practical consequence for implementation: core/command.py already composes handle_errors(logged(func)) and is the single place to add this; the job id becomes an ambient value that core/console reads when tagging events, so no command signature changes.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:42:39.894', '2026-08-31 13:51:43.891', NULL, '712b245b166616ac9cd06cbe86864b0b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5SNAJ0B4Z6DE3KWKQX370', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'Job identity as ambient context — the decorator half of streaming', 'The piece that must land before T-1250, so every ported command arrives already streaming rather than being retrofitted. Deliverables: tooling/core/jobs.py holding the CURRENT JOB as ambient state (a ContextVar, not a global, so it is correct if anything ever runs concurrently); core/command.py assigning a job id at the start of every invocation; core/console.py reading that id and tagging every event with it. NO COMMAND SIGNATURE CHANGES and no command imports jobs — that is the whole point, per the D-263 amendment: a command must not know jobs exist, because the alternative is call-site discipline wearing a different hat and it fails the fortieth command into a porting session. Every invocation gets an id, foreground or not, so the two paths are identical and detach is purely a question of where the stream is written. DESIGN CALL to record: only DETACHED runs get a log FILE. A foreground run streams to stderr and writes nothing, because logging all four gate invocations on every push would create retention pressure for output nobody reads. The cost is that a foreground run killed by a timeout loses its output — which is precisely the case that should have used --detach, so the tradeoff points the right way. Acceptance: reach --verbose check client-version shows a job id on its events, and the conformance test proves no domain module imports core.jobs.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:51:51.124', '2026-08-31 13:51:51.124', NULL, '862f8ea6892d5bb7e374b05ad4aea532', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'core/process.py — detach so the child outlives the parent', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:01.128', '2026-08-31 13:52:01.128', NULL, '7f719d3330d36a2429fe5066a6577e5b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5VJ89C7Q4EPR6Q73FSS74', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'The jobs domain — list, status, log --follow, wait', 'The user-facing verbs, and a DOMAIN rather than core/ because they carry logic and state of their own — the first real test of the D-263 core bound, which it passes. Deliverables: reach jobs list (recent jobs with status, command and duration), status <id>, log <id> with --follow to tail, and wait <id>. REATTACH IS A BYTE OFFSET into an append-only file, which is the entire reason no daemon is needed: a caller can attach, drop off, and come back without losing anything, and there is no lifecycle to get wrong, nothing to orphan, and no stale state to reconcile. log --follow is therefore a poll on file length, not a subscription. Render the JSONL through the same path a live terminal uses, so a tailed log and a live run are the same artefact in two presentations rather than two renderers that drift. Note for the port: this domain is the first one written from scratch under the full contract rather than ported, so it doubles as the worked example the reach skill (T-1254) should show.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:06.722', '2026-08-31 13:52:06.722', NULL, '80174d012a18dbc46da3145b10f77283', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5WNGDMENP7AY9VMB5G3XM', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'Exit codes survive a detached run — the non-negotiable', 'A runner that reports started and then loses the failure is the exit-0 trap from the top of D-263 relocated somewhere nothing is watching, which is strictly worse than the original: at least a foreground exit 0 is in front of someone. Requirements: reach jobs wait <id> EXITS WITH THE JOB EXIT CODE, so a Makefile or hook can gate on a detached run exactly as it would on a foreground one; a failed job that nobody waited on is visible as failed in reach jobs list rather than merely absent; and reach --detach itself exits 0 for SUCCESSFULLY STARTING, which is a different claim from the job succeeding, so the output must say so in words rather than leaving a reader to infer it. TEST THE FAILING PATH FIRST and give it a deliberately failing command, since a job runner that has only ever run successful jobs has never been tested — this is the same discipline that caught the gate tests. Also test the case where the job fails BEFORE the parent has exited, and where it fails long AFTER, since those exercise different halves of the recording path.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:15.747', '2026-08-31 13:52:15.747', NULL, 'be0987680480ae25059d8a28cb354112', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5XC1XX5179S0X6VFW0TM0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'Job log retention, so .cache/reach/jobs does not grow forever', 'An append-only log directory with no retention is a disk leak that nobody notices until it matters. Deliverables: a cap by count or age, applied at write time rather than by a separate sweep — a cleanup nothing invokes is a cleanup that does not happen; reach jobs prune as the explicit escape hatch; and a rule that a RUNNING job is never pruned regardless of age, since a long generator can outlive the window. Bound the whole thing to .cache/, which is gitignored and already the repo''s scratch space, so a wrong answer here costs disk rather than data. Deliberately last in the epic: retention pressure is not real until jobs exist and the log sizes are observable, and guessing a cap before seeing a Blender batch''s output volume would just be a number to change later.', 'backlog', 'low', NULL, NULL, 'D-263', '2026-08-31 13:52:21.519', '2026-08-31 13:52:21.519', NULL, '99cfce863916cf9655e365ed4adcf370', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5SNAJ0B4Z6DE3KWKQX370', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'Job identity as ambient context — the decorator half of streaming', 'The piece that must land before T-1250, so every ported command arrives already streaming rather than being retrofitted. Deliverables: tooling/core/jobs.py holding the CURRENT JOB as ambient state (a ContextVar, not a global, so it is correct if anything ever runs concurrently); core/command.py assigning a job id at the start of every invocation; core/console.py reading that id and tagging every event with it. NO COMMAND SIGNATURE CHANGES and no command imports jobs — that is the whole point, per the D-263 amendment: a command must not know jobs exist, because the alternative is call-site discipline wearing a different hat and it fails the fortieth command into a porting session. Every invocation gets an id, foreground or not, so the two paths are identical and detach is purely a question of where the stream is written. DESIGN CALL to record: only DETACHED runs get a log FILE. A foreground run streams to stderr and writes nothing, because logging all four gate invocations on every push would create retention pressure for output nobody reads. The cost is that a foreground run killed by a timeout loses its output — which is precisely the case that should have used --detach, so the tradeoff points the right way. Acceptance: reach --verbose check client-version shows a job id on its events, and the conformance test proves no domain module imports core.jobs.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:51:51.124', '2026-08-31 13:52:48.626', NULL, '40a3358f3a729964918a9df2f7c277f9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5SNAJ0B4Z6DE3KWKQX370', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'Job identity as ambient context — the decorator half of streaming', 'The piece that must land before T-1250, so every ported command arrives already streaming rather than being retrofitted. Deliverables: tooling/core/jobs.py holding the CURRENT JOB as ambient state (a ContextVar, not a global, so it is correct if anything ever runs concurrently); core/command.py assigning a job id at the start of every invocation; core/console.py reading that id and tagging every event with it. NO COMMAND SIGNATURE CHANGES and no command imports jobs — that is the whole point, per the D-263 amendment: a command must not know jobs exist, because the alternative is call-site discipline wearing a different hat and it fails the fortieth command into a porting session. Every invocation gets an id, foreground or not, so the two paths are identical and detach is purely a question of where the stream is written. DESIGN CALL to record: only DETACHED runs get a log FILE. A foreground run streams to stderr and writes nothing, because logging all four gate invocations on every push would create retention pressure for output nobody reads. The cost is that a foreground run killed by a timeout loses its output — which is precisely the case that should have used --detach, so the tradeoff points the right way. Acceptance: reach --verbose check client-version shows a job id on its events, and the conformance test proves no domain module imports core.jobs.
DONE 2026-08-31. Every invocation now carries a job id, and every event it emits is tagged with it. No command signature changed and no command imports core.jobs.
core/jobs.py holds the current job as a ContextVar — not a module global. A global is correct only until something runs two invocations in one process, which a test harness or a future batch verb does immediately, and it would then interleave two jobs'' events under one id with no error anywhere.
ORDERING, which took a moment to get right and is the reason this belongs in the decorator rather than anywhere else. The job context must be the OUTERMOST wrapper: @logged emits from its finally and @handle_errors emits its verdict while unwinding, so a context established inside either would already have been reset by the time the two most important events are written. Those would then be the only untagged lines in the log — and they are precisely the ones a detached run gets read back for. Composition is now jobs-context(handle_errors(logged(func))). Verified on both paths: success emits verdict and debug record sharing one id, and the failure path''s verdict is tagged too.
BUG FOUND AND FIXED IN PASSING: the job id used time.strftime() with LOCAL time while every event''s ts field is UTC, so a job id read 155327 while its own first log line read 13:53:27. Two hours apart, which reads as a logging bug every time someone correlates them by eye. Now time.gmtime().
DESIGN CALL RECORDED: only DETACHED runs will get a log FILE (T-1277). A foreground run streams to stderr and persists nothing — writing a log for all four gate invocations on every push would create retention pressure for output nobody reads. Cost: a foreground run killed by a timeout loses its output, which is exactly the case that should have used --detach, so the tradeoff points the right way.
NEW CONFORMANCE INVARIANT: no module outside core/ may import core.jobs. The moment a domain imports it, ambience has become call-site discipline again and will fail the same way — one command forgets and its output loses correlation silently.
AND THE INVARIANT WAS INITIALLY BROKEN, which is worth recording because it is the exact failure the prove-it-can-fail discipline exists to catch. My first version inspected only node.module, so it missed `from tooling.core import jobs` — where jobs appears in the NAMES, not the module path, and which is the form anyone would actually write. The test passed while checking nothing. Rewritten to catch all three reachable forms (from tooling.core import jobs / from tooling.core.jobs import x / import tooling.core.jobs), then verified by adding a real violating import to the check router: it failed and named the file and line. Reverted, green.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:51:51.124', '2026-08-31 13:59:26.864', NULL, '78a2584293788d032977d9d55f3398bb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5SNAJ0B4Z6DE3KWKQX370', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'Job identity as ambient context — the decorator half of streaming', 'The piece that must land before T-1250, so every ported command arrives already streaming rather than being retrofitted. Deliverables: tooling/core/jobs.py holding the CURRENT JOB as ambient state (a ContextVar, not a global, so it is correct if anything ever runs concurrently); core/command.py assigning a job id at the start of every invocation; core/console.py reading that id and tagging every event with it. NO COMMAND SIGNATURE CHANGES and no command imports jobs — that is the whole point, per the D-263 amendment: a command must not know jobs exist, because the alternative is call-site discipline wearing a different hat and it fails the fortieth command into a porting session. Every invocation gets an id, foreground or not, so the two paths are identical and detach is purely a question of where the stream is written. DESIGN CALL to record: only DETACHED runs get a log FILE. A foreground run streams to stderr and writes nothing, because logging all four gate invocations on every push would create retention pressure for output nobody reads. The cost is that a foreground run killed by a timeout loses its output — which is precisely the case that should have used --detach, so the tradeoff points the right way. Acceptance: reach --verbose check client-version shows a job id on its events, and the conformance test proves no domain module imports core.jobs.
DONE 2026-08-31. Every invocation now carries a job id, and every event it emits is tagged with it. No command signature changed and no command imports core.jobs.
core/jobs.py holds the current job as a ContextVar — not a module global. A global is correct only until something runs two invocations in one process, which a test harness or a future batch verb does immediately, and it would then interleave two jobs'' events under one id with no error anywhere.
ORDERING, which took a moment to get right and is the reason this belongs in the decorator rather than anywhere else. The job context must be the OUTERMOST wrapper: @logged emits from its finally and @handle_errors emits its verdict while unwinding, so a context established inside either would already have been reset by the time the two most important events are written. Those would then be the only untagged lines in the log — and they are precisely the ones a detached run gets read back for. Composition is now jobs-context(handle_errors(logged(func))). Verified on both paths: success emits verdict and debug record sharing one id, and the failure path''s verdict is tagged too.
BUG FOUND AND FIXED IN PASSING: the job id used time.strftime() with LOCAL time while every event''s ts field is UTC, so a job id read 155327 while its own first log line read 13:53:27. Two hours apart, which reads as a logging bug every time someone correlates them by eye. Now time.gmtime().
DESIGN CALL RECORDED: only DETACHED runs will get a log FILE (T-1277). A foreground run streams to stderr and persists nothing — writing a log for all four gate invocations on every push would create retention pressure for output nobody reads. Cost: a foreground run killed by a timeout loses its output, which is exactly the case that should have used --detach, so the tradeoff points the right way.
NEW CONFORMANCE INVARIANT: no module outside core/ may import core.jobs. The moment a domain imports it, ambience has become call-site discipline again and will fail the same way — one command forgets and its output loses correlation silently.
AND THE INVARIANT WAS INITIALLY BROKEN, which is worth recording because it is the exact failure the prove-it-can-fail discipline exists to catch. My first version inspected only node.module, so it missed `from tooling.core import jobs` — where jobs appears in the NAMES, not the module path, and which is the form anyone would actually write. The test passed while checking nothing. Rewritten to catch all three reachable forms (from tooling.core import jobs / from tooling.core.jobs import x / import tooling.core.jobs), then verified by adding a real violating import to the check router: it failed and named the file and line. Reverted, green.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 13:51:51.124', '2026-08-31 13:59:44.052', NULL, '7cdf2b60220de848051b16d2deabdf43', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'core/process.py — detach so the child outlives the parent', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:01.128', '2026-08-31 14:39:00.853', NULL, '2c467f375858784348443ddc46bf8d3e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'core/process.py — detach so the child outlives the parent', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:01.128', '2026-08-31 14:39:40.450', NULL, 'd805b1a9979af2638cd461f44c517cfa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'core/process.py — detach so the child outlives the parent', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.
DONE 2026-08-31. Detached execution works end to end, and testing it found a real hole that the design as written would have shipped.
DELIVERED: tooling/core/process.py (spawn, metadata, liveness), the --detach flag on the root callback, and completion recording. Verified against a real spawn — parent returns the job id and exits 0, child runs on and writes .cache/reach/jobs/<id>.jsonl tagged with that id, plus a .out sibling and a .json metadata record.
THE THREE THINGS THE TICKET FLAGGED, each handled and each verified:
- start_new_session=True, so the child gets its own session and process group and a signal to the parent''s group does not take the work with it.
- The child re-execs `reach` by BARE NAME. An absolute path would freeze it to whichever checkout was current at spawn time, so after make reach-repoint a detached job would silently run the wrong source — the exact failure that command exists to fix.
- The child records its own exit code. Confirmed on the success path (status done, exit_code 0) and on a real failure path (status failed, exit_code 1) using a drift fixture pointed at by SR_REPO_ROOT.
THE HOLE, found only because I tested a THIRD case the ticket did not name.
Recording completion inside @command looked right and was subtly wrong. A child that fails BEFORE any command runs — bad arguments, an unknown verb, an import error — never reaches that decorator. Verified: `reach --detach check bogus` left its metadata reading status "running" FOREVER, with the process long gone.
That is the exit-0 trap wearing a new disguise, and worse than the original: a failed job that looks busy, in a place nobody is watching. A caller polling for completion would wait indefinitely on a job that failed in milliseconds.
FIX: completion is now recorded at the PROCESS''s exit rather than a command''s. tooling/main.py gains main(), which wraps cli() in one try/finally, and [project.scripts] points at main:main instead of main:cli. Every exit path — success, ReachError, usage error, unhandled exception — now passes through a single finally. Re-verified: `reach --detach check bogus` records status failed, exit_code 2.
The recording was REMOVED from @command rather than left in both places; two writers of the same field is how they drift.
CONFORMANCE EXEMPTION ADDED, deliberately narrow. The new no-domain-imports-core.jobs invariant fired on main.py, correctly by its letter and wrongly by its purpose. main.py is not a command — it is the entry point, and it already owns --detach, --verbose and --no-input. Reading a job-id constant there is far less coupled than the --detach flag it already carries. Exempted main.py explicitly, with the reason inline so it does not read as an oversight.
STILL OPEN, and correctly belongs to T-1278: a child killed outright (SIGKILL, OOM, interpreter crash) still cannot record anything, so its metadata stays "running". process.is_alive(pid) exists for exactly this, and jobs list/status must reconcile against it rather than trusting the file.
reach --help is still 73 ms, so the entry-point wrapper costs nothing on the fast path.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:01.128', '2026-08-31 14:45:58.410', NULL, '4e69f4d0ddf1ff636d194e33cd142fd2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5VJ89C7Q4EPR6Q73FSS74', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'The jobs domain — list, status, log --follow, wait', 'The user-facing verbs, and a DOMAIN rather than core/ because they carry logic and state of their own — the first real test of the D-263 core bound, which it passes. Deliverables: reach jobs list (recent jobs with status, command and duration), status <id>, log <id> with --follow to tail, and wait <id>. REATTACH IS A BYTE OFFSET into an append-only file, which is the entire reason no daemon is needed: a caller can attach, drop off, and come back without losing anything, and there is no lifecycle to get wrong, nothing to orphan, and no stale state to reconcile. log --follow is therefore a poll on file length, not a subscription. Render the JSONL through the same path a live terminal uses, so a tailed log and a live run are the same artefact in two presentations rather than two renderers that drift. Note for the port: this domain is the first one written from scratch under the full contract rather than ported, so it doubles as the worked example the reach skill (T-1254) should show.
FROM T-1277 (2026-08-31) — the reconciliation requirement is yours and it is not optional. A child killed outright (SIGKILL, OOM, an interpreter crash) never gets to record its own completion, so its metadata file stays status=running with the process long gone. jobs list and jobs status must therefore RECONCILE against process.is_alive(pid) rather than trusting the file: a job whose pid is dead and whose status still reads running is not running, it died. Report it as such — ''died without recording an exit'' is honest and actionable, whereas showing it as running is the exit-0 trap in a place nobody is watching, and a caller polling for completion would wait forever on something that failed in milliseconds. Note the ordinary failure paths are already covered: T-1277 moved completion recording to the process''s exit (tooling/main.py main()), so bad arguments, unknown verbs, ReachErrors and unhandled exceptions all record correctly. What remains is only the case where the process cannot run code at all.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:06.722', '2026-08-31 14:46:03.939', NULL, 'f9aac99fd6449919ab2d48455a81b6e7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5TWD0VVBM4F2WZYAQFET0', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'core/process.py — detach so the child outlives the parent', 'The spawn primitive, and it is substrate rather than a domain because it has no verbs of its own. Deliverables: spawn a detached child that survives the parent exiting (setsid or equivalent, not just a background shell job, since a killed parent must not take the work with it); redirect the child''s event stream to .cache/reach/jobs/<id>.jsonl and its stdout to a sibling file, keeping the two channels separate exactly as they are in the foreground; write a metadata record carrying command, argv, start time, pid and — on completion — end time and exit code. The metadata file is what makes a finished job readable without re-reading a possibly enormous log. .cache/ is already gitignored. WATCH: the child must re-exec the same reach that was invoked, resolved by bare name per T-1261''s negative criterion, never by an interpreter path or a .venv path — an absolute path here would break the moment the tool is re-pointed at another checkout, and would be a silent wrong-source failure of exactly the kind make reach-repoint exists to fix. Also watch the completion race: the exit code must be recorded by the CHILD as its last act, not polled by a parent that may already be gone.
DONE 2026-08-31. Detached execution works end to end, and testing it found a real hole that the design as written would have shipped.
DELIVERED: tooling/core/process.py (spawn, metadata, liveness), the --detach flag on the root callback, and completion recording. Verified against a real spawn — parent returns the job id and exits 0, child runs on and writes .cache/reach/jobs/<id>.jsonl tagged with that id, plus a .out sibling and a .json metadata record.
THE THREE THINGS THE TICKET FLAGGED, each handled and each verified:
- start_new_session=True, so the child gets its own session and process group and a signal to the parent''s group does not take the work with it.
- The child re-execs `reach` by BARE NAME. An absolute path would freeze it to whichever checkout was current at spawn time, so after make reach-repoint a detached job would silently run the wrong source — the exact failure that command exists to fix.
- The child records its own exit code. Confirmed on the success path (status done, exit_code 0) and on a real failure path (status failed, exit_code 1) using a drift fixture pointed at by SR_REPO_ROOT.
THE HOLE, found only because I tested a THIRD case the ticket did not name.
Recording completion inside @command looked right and was subtly wrong. A child that fails BEFORE any command runs — bad arguments, an unknown verb, an import error — never reaches that decorator. Verified: `reach --detach check bogus` left its metadata reading status "running" FOREVER, with the process long gone.
That is the exit-0 trap wearing a new disguise, and worse than the original: a failed job that looks busy, in a place nobody is watching. A caller polling for completion would wait indefinitely on a job that failed in milliseconds.
FIX: completion is now recorded at the PROCESS''s exit rather than a command''s. tooling/main.py gains main(), which wraps cli() in one try/finally, and [project.scripts] points at main:main instead of main:cli. Every exit path — success, ReachError, usage error, unhandled exception — now passes through a single finally. Re-verified: `reach --detach check bogus` records status failed, exit_code 2.
The recording was REMOVED from @command rather than left in both places; two writers of the same field is how they drift.
CONFORMANCE EXEMPTION ADDED, deliberately narrow. The new no-domain-imports-core.jobs invariant fired on main.py, correctly by its letter and wrongly by its purpose. main.py is not a command — it is the entry point, and it already owns --detach, --verbose and --no-input. Reading a job-id constant there is far less coupled than the --detach flag it already carries. Exempted main.py explicitly, with the reason inline so it does not read as an oversight.
STILL OPEN, and correctly belongs to T-1278: a child killed outright (SIGKILL, OOM, interpreter crash) still cannot record anything, so its metadata stays "running". process.is_alive(pid) exists for exactly this, and jobs list/status must reconcile against it rather than trusting the file.
reach --help is still 73 ms, so the entry-point wrapper costs nothing on the fast path.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:01.128', '2026-08-31 14:46:22.895', NULL, '1d5d86a8353e16e113d1c77588a90e55', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5VJ89C7Q4EPR6Q73FSS74', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'The jobs domain — list, status, log --follow, wait', 'The user-facing verbs, and a DOMAIN rather than core/ because they carry logic and state of their own — the first real test of the D-263 core bound, which it passes. Deliverables: reach jobs list (recent jobs with status, command and duration), status <id>, log <id> with --follow to tail, and wait <id>. REATTACH IS A BYTE OFFSET into an append-only file, which is the entire reason no daemon is needed: a caller can attach, drop off, and come back without losing anything, and there is no lifecycle to get wrong, nothing to orphan, and no stale state to reconcile. log --follow is therefore a poll on file length, not a subscription. Render the JSONL through the same path a live terminal uses, so a tailed log and a live run are the same artefact in two presentations rather than two renderers that drift. Note for the port: this domain is the first one written from scratch under the full contract rather than ported, so it doubles as the worked example the reach skill (T-1254) should show.
FROM T-1277 (2026-08-31) — the reconciliation requirement is yours and it is not optional. A child killed outright (SIGKILL, OOM, an interpreter crash) never gets to record its own completion, so its metadata file stays status=running with the process long gone. jobs list and jobs status must therefore RECONCILE against process.is_alive(pid) rather than trusting the file: a job whose pid is dead and whose status still reads running is not running, it died. Report it as such — ''died without recording an exit'' is honest and actionable, whereas showing it as running is the exit-0 trap in a place nobody is watching, and a caller polling for completion would wait forever on something that failed in milliseconds. Note the ordinary failure paths are already covered: T-1277 moved completion recording to the process''s exit (tooling/main.py main()), so bad arguments, unknown verbs, ReachErrors and unhandled exceptions all record correctly. What remains is only the case where the process cannot run code at all.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:06.722', '2026-08-31 14:52:17.707', NULL, '5c00db52e693d19da200416e4d3901a5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5VJ89C7Q4EPR6Q73FSS74', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'The jobs domain — list, status, log --follow, wait', 'The user-facing verbs, and a DOMAIN rather than core/ because they carry logic and state of their own — the first real test of the D-263 core bound, which it passes. Deliverables: reach jobs list (recent jobs with status, command and duration), status <id>, log <id> with --follow to tail, and wait <id>. REATTACH IS A BYTE OFFSET into an append-only file, which is the entire reason no daemon is needed: a caller can attach, drop off, and come back without losing anything, and there is no lifecycle to get wrong, nothing to orphan, and no stale state to reconcile. log --follow is therefore a poll on file length, not a subscription. Render the JSONL through the same path a live terminal uses, so a tailed log and a live run are the same artefact in two presentations rather than two renderers that drift. Note for the port: this domain is the first one written from scratch under the full contract rather than ported, so it doubles as the worked example the reach skill (T-1254) should show.
FROM T-1277 (2026-08-31) — the reconciliation requirement is yours and it is not optional. A child killed outright (SIGKILL, OOM, an interpreter crash) never gets to record its own completion, so its metadata file stays status=running with the process long gone. jobs list and jobs status must therefore RECONCILE against process.is_alive(pid) rather than trusting the file: a job whose pid is dead and whose status still reads running is not running, it died. Report it as such — ''died without recording an exit'' is honest and actionable, whereas showing it as running is the exit-0 trap in a place nobody is watching, and a caller polling for completion would wait forever on something that failed in milliseconds. Note the ordinary failure paths are already covered: T-1277 moved completion recording to the process''s exit (tooling/main.py main()), so bad arguments, unknown verbs, ReachErrors and unhandled exceptions all record correctly. What remains is only the case where the process cannot run code at all.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:06.722', '2026-08-31 14:52:36.710', NULL, '0a348ae5a631dcd868a7830e0be0de6c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5G5VJ89C7Q4EPR6Q73FSS74', 'task', '06G1S7NVJR0GT9KWS9QVYNNFMM', 'The jobs domain — list, status, log --follow, wait', 'The user-facing verbs, and a DOMAIN rather than core/ because they carry logic and state of their own — the first real test of the D-263 core bound, which it passes. Deliverables: reach jobs list (recent jobs with status, command and duration), status <id>, log <id> with --follow to tail, and wait <id>. REATTACH IS A BYTE OFFSET into an append-only file, which is the entire reason no daemon is needed: a caller can attach, drop off, and come back without losing anything, and there is no lifecycle to get wrong, nothing to orphan, and no stale state to reconcile. log --follow is therefore a poll on file length, not a subscription. Render the JSONL through the same path a live terminal uses, so a tailed log and a live run are the same artefact in two presentations rather than two renderers that drift. Note for the port: this domain is the first one written from scratch under the full contract rather than ported, so it doubles as the worked example the reach skill (T-1254) should show.
FROM T-1277 (2026-08-31) — the reconciliation requirement is yours and it is not optional. A child killed outright (SIGKILL, OOM, an interpreter crash) never gets to record its own completion, so its metadata file stays status=running with the process long gone. jobs list and jobs status must therefore RECONCILE against process.is_alive(pid) rather than trusting the file: a job whose pid is dead and whose status still reads running is not running, it died. Report it as such — ''died without recording an exit'' is honest and actionable, whereas showing it as running is the exit-0 trap in a place nobody is watching, and a caller polling for completion would wait forever on something that failed in milliseconds. Note the ordinary failure paths are already covered: T-1277 moved completion recording to the process''s exit (tooling/main.py main()), so bad arguments, unknown verbs, ReachErrors and unhandled exceptions all record correctly. What remains is only the case where the process cannot run code at all.
DONE 2026-08-31. reach jobs list / status / log --follow / wait, all four working, plus service-level tests.
A LATENT BUG FOUND BEFORE BUILDING ON IT, and it was already committed. typer.Exit is a RuntimeError, NOT a SystemExit — so @handle_errors caught it like any other unexpected exception. Verified: `raise typer.Exit(3)` inside a decorated command printed "unexpected Exit: 3" and exited 1, SILENTLY DISCARDING the requested code. Nothing hit it today because T-1267 had converted the check router to ReachError, but `jobs wait` needs exactly this and it is the natural thing anyone would write.
FIX: core/errors.ReachExit(code) as the sanctioned control-flow exit, passed straight through by handle_errors as SystemExit with no verdict. ReachError would have been wrong twice over for wait — it prints a failure verdict for a command that worked, and demands a fix= for a situation with no remedy. Kept typer out of core/, which the conformance test enforces.
RECONCILIATION WORKS, AND WAS PROVEN ON A REAL CORPSE rather than a simulated one. The job left stranded by the T-1277 bug — status "running", process long gone — now reports as `died` with its true elapsed time. Status DIED is derived, never recorded: a process killed outright cannot write its own ending.
EXIT-CODE RELAY, all three cases: done -> 0, failed -> 2 (the job''s own code), died -> 137. A died job must NEVER relay 0; it has no code of its own and borrowing success is the exit-0 trap pointed at whatever gated on the run.
BYTE-OFFSET REATTACH verified directly: first read consumed one event to offset 169; resuming from 169 returned nothing and left the offset unmoved. That is the whole reason no daemon is needed.
SECOND UTC BUG, same class as T-1276''s. jobs list reported a job started minutes earlier as running for 133m — _parse used time.mktime, which reads a UTC stamp as local time and silently adds the offset to every duration. Now calendar.timegm. Both directions of this conversion have now bitten once each; both have the reason in a comment.
RENDERING: console.render() is now public and jobs log replays stored events through it. A second renderer for stored events would drift from the live one, and the divergence would surface exactly when someone is reading a log to work out what went wrong.
SERVICE-LEVEL TESTS — tooling/test_jobs.py, and these close the gap T-1257 named. D-263 claims a service is transport-agnostic and callable without a CLI round trip; nothing had ever demonstrated it, which made the layering unverified decoration. Every test here imports service and calls a function directly. Covers the partial trailing line (a reader arriving mid-append must leave the incomplete line for next time, not discard the one event being written when it looked), offset stability, dead-pid reconciliation, and died-never-relays-0. Proven to fail: making a died job return 0 tripped the exit-0 assertion by name.
read_events was split into read_events(job_id) and read_events_from(path) so a test can drive it against a temp file — better shape regardless.
NOT YET EXERCISED, and honest about it: `jobs log --follow` against a genuinely LONG-running job. Nothing in reach currently runs long enough to tail. The offset mechanics underneath it are tested directly, and the live loop gets its first real workout when a slow domain lands (planet or db, T-1250).', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 13:52:06.722', '2026-08-31 15:02:20.709', NULL, '1ce1eb66a71783ab5218d27ae143b1aa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;