Twelve scripts retired, three domains registered. `reach` now covers nine.
generate: `generate-brands` and `generate-corporations` were the second and
third copies of the same 24-line build-if-missing-then-exec bash `tooling/atlas`
carried, so they collapsed into `core.process.cargo_binary` rather than being
ported. `import_economics` shelled out to the first of those, so it now calls
that helper — `generated_brands.toml` comes back byte-identical, and the stamp
registry swaps the retired wrapper for `core/process.py`.
pr: `watchlist-diff` derives its watched set from `generator_sources.py` instead
of restating it, so it cannot drift from the stamp check.
dev: the environment scripts split decision from performing, per D-263's
guarded-exec rule. `godot_plan()` and `worktree_plan()` decide what would
happen; `install_godot()`, `install_rust()` and `setup_worktree()` do it.
`tooling/test_environment.py` pins the version pin, both override precedences,
the already-current skip, the platform refusal and both worktree refusals —
none of them performed. `make setup` now installs reach first, since the
targets that install rust and godot are reach verbs.
Two live bugs found while porting:
- The clerk read its decision index from `decisions/README.md`, a path that
stopped existing when the DQR tree moved to `governance/`. Every clerk agent
has been grepping blind; its prompt pointed at the same dead directory.
- The conformance exec-check matched any `x.system()` regardless of receiver,
so `platform.system()` read as `os.system()`. Narrowed and re-proved against
a real mutant.
`process.run` gains `input=`, `timeout=` and a `ProcessTimeout` subclass so a
killed run stays distinguishable from a verdict. The pre-push hook no longer
merges the clerk's stderr into its stdout — under streaming the last merged
line is a JSONL event, which would read as an unrecognised verdict and block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reach's domain names should not be a fresh taxonomy. Where the game already
presents something to the player, the CLI takes that name and that shape: what
you browse in-game is what you generate and inspect from the terminal.
That splits domains in two. atlas, ledger and wiki mirror implant apps and
follow their structure. check, validate, godot, visual, jobs and dev mirror
nothing — no app exists for a lint gate, and inventing a player-facing framing
for one would be worse than having none.
The first consequence corrects a contradiction rather than a preference. D-191
already says "Atlas is the star map extended downward, not a separate app —
implant/map at different zoom levels", four rungs from Reach map to regional.
The domain map had atlas, starmap and planet as peers, which would have
presented as three unrelated things what the game presents as one descent.
Generation now nests by rung; authoring and inspection verbs stay flat on
atlas, because they act on the whole thing rather than a rung.
The second is a rename with the same reasoning: db becomes ledger, after the UI
component that will aggregate economics — markets, wealth, transactions, the
economic counterpart to what the Atlas offers for topography. db named a
storage layer nobody looks at.
One caution recorded because the words collide. D-191's MVP criterion 7 says
"Atlas is read-only (no verbs execute from map)". That governs the app. The
atlas tooling writes — it commits proposals, mutates fields, syncs the wiki —
and a later reader must not take the app's constraint as licence to delete the
authoring verbs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
reach godot parse-sweep / cold-parse, reach visual diff / blank-check /
thumbnail. Five scripts retired, and the callers rewired — tests/run-visual
invoked three of them by path at four sites, which is a wider blast radius than
the make targets were.
The godot pair were grep pipelines encoding five hard-won lessons as comments
nobody could test. They are Python filters now, with the reasons attached, and
the engine invocation is a guarded exec. Verified on the real client: 229
scripts, clean.
Their three not-ok states stay distinct, because only one is a verdict about
the code. An engine that crashed or is missing is not a parse failure —
reporting it as one blames the tree for a broken toolchain. A sweep that
emitted no completion marker checked nothing, and zero errors from a check that
never ran reads as clean, which is the false-green the sweep exists to close.
The deliberate asymmetry between the two checks is preserved and documented:
cold-parse filters "Cannot infer the type", the sweep does not, because that
suppression is why cold-parse stayed silent about a helper that genuinely does
not parse.
All three visual scripts carried the same root bug as validate-checklist:
Path(__file__).parent.parent, correct at tooling/ and two levels too deep at
tooling/domains/visual. Fixed during the move rather than after, having learned
that it fails silently — paths resolve to nothing, the work appears to have
nothing to do, and the tool reports success. Three domains now where that would
have shipped a false pass.
Two bugs my own transformation introduced, both found by running rather than
reading. Multi-line print(..., file=sys.stderr) became console.event(...,
file=sys.stderr), and console puts unknown kwargs into the payload — a file
object would have reached json.dumps at the exact moment something was already
being reported as an error. And the replacement script wrote escaped quotes
into three files. Mechanical transformations need mechanical verification.
sys.exit removed from four sites: a service must not end the process.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found on starting the first port: 17 of the 33 tooling executables are bash,
about 900 lines. Both this record and the domain map had assumed a Python tree,
so those are rewrites rather than moves — a materially larger epic than T-1250
was written for.
Decided: rewrite them, do not wrap them. Wrapping would achieve one door while
leaving half the CLI surface outside the contract — no @command, no remedy on
failure, no streaming, no testable service. reach --help would then list verbs
that behave differently from the ones beside them, which is worse than two
doors, because the inconsistency is invisible until something fails.
The cost lands unevenly and the record says where. The grep-pipeline scripts
compute verdicts and gain most from becoming services. The environment scripts
— install-godot, install-rust, worktree-setup — gain least and carry the most
regression risk, because downloading a specific Godot build or driving rustup
is awkward to exercise in a gate. For those, port the decision logic into a
testable service and keep the irreducible external calls behind core/process: a
rewrite that cannot be tested has to be trusted instead, and trusting an
installer is how a working environment becomes an unreproducible one.
The domain map gains the inventory by shape, and a rule that every per-domain
ticket states which of its sources are bash — since that is what turns a port
from mechanical into a rewrite needing its own parity evidence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Streaming as a decorator, first half. Each invocation of reach gets an id and
every event it emits is tagged with it, which is what will let a detached run's
log be read back and what correlates the lines of a run that streamed for nine
minutes. No command signature changed and no command imports core.jobs — that
is the point, per the D-263 amendment: a command must not know jobs exist,
because the alternative is call-site discipline wearing a different hat.
A ContextVar rather than 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 which would then interleave two jobs'
events under one id with nothing reporting an error.
The job context is the OUTERMOST wrapper, and it has to be. @logged emits from
its finally and @handle_errors emits its verdict while unwinding, so a context
established inside either would already be reset by the time the two most
important events are written — leaving them the only untagged lines in the log,
and they are precisely the ones a detached run gets read back for.
Fixed in passing: the job id used local time while every event's ts is UTC, so
an id read 155327 beside its own first log line reading 13:53:27. Two hours
apart reads as a logging bug every time someone correlates them by eye.
New conformance invariant — nothing outside core/ may import core.jobs. My
first version of it inspected only the module path, so it missed
`from tooling.core import jobs`, where the name is in the import LIST and which
is the form anyone would actually write. It passed while checking nothing.
Rewritten to catch all three reachable forms and then verified by committing a
real violation, which it named by file and line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bare `reach` and bare `reach <domain>` printed help and exited 2, Click's
usage-error convention. Running reach with no arguments is the DISCOVERY
action — it is how the tool gets learned from nothing — and a caller that
branches on exit status would read its own onboarding as a failure. Now they
exit 0.
D-263's exit-code contract is untouched: it governs failures, and printing a
command list is not one. Verified across the whole matrix, because this change
flirts with the exit-0 trap that record opens with — bare 0, bare domain 0,
--help 0, unknown domain 2, unknown verb 2, real failure 1. All five are now
pinned as a sixth conformance invariant, since an exit code regresses silently
and nothing else would notice. Proven to fail by putting the 2 back.
The implementation also collapses a duplicated class. core/cli.py holds
ReachGroup with both shared behaviours — no-args-prints-help-and-exits-0, and
unknown-name-enumerates — and LazyDomainGroup now extends it instead of
subclassing TyperGroup directly, keeping only the laziness and the
domain-specific wording. The enumeration logic previously existed twice in
slightly different forms, which is how the root and the domains would have
drifted into disagreeing about their own conventions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Python file and executable in tooling/ assigned to one of 15 domains,
with the ambiguous cases carrying their reasoning. The per-domain port tickets
are written from this rather than guessed, so their boundaries do not have to
be renegotiated halfway through a 160-file move.
Three things counting turned up that reading would not have.
The Blender carve-out is 35 files, not the 13 visible at top level — 22 more
are inside garment-fit/, which turns out to be a payload directory wearing a
domain's name. The epic said 35 and an earlier survey of mine said 14; the
epic was right. That is not cosmetic: `character` is a far smaller domain than
directory sizes imply, and a port ticket written from the listing would have
been wrong about both it and the carve-out.
The "28 singleton prefixes" were an artefact of splitting filenames on the
first token, which scattered coherent families — sculpt-star-map,
tune-star-map-topology and generate-star-map* are one group counted as three
orphans. Counting families instead, the genuinely ambiguous set is small
enough to enumerate with reasons.
And tooling/db/ is misnamed: it holds the audio/image/Trellis connectors and
wiki_sync, while the actual database work is in economy-db/. Naming a domain
after that directory would have carried the misnomer forward.
Judgment calls settled with reasons, since each sets a precedent. Registries
stay data rather than becoming verbs nobody would type. Gate tests do not
become a `test` domain implying a runner that does not exist. pql-migrate is
provenance — archived, not deleted and not importable. `pr` is a domain the
epic omitted, kept out of `dev` so dev does not become the drawer everything
ambiguous goes into. And `atlas` is overloaded across three unrelated places —
map data, terrain quality analysis, and systems.db index tables — which stay
with their owners rather than being collected into a domain whose only common
thread is a noun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every non-zero exit names the command that would fix it, and still exits
non-zero. Both halves matter; the second is the one that gets lost, because a
tool that explains itself beautifully and exits 0 looks MORE correct while
having silently disabled its own gate.
core/errors.py holds ReachError(message, fix=) and @handle_errors.
core/logging.py holds @logged, emitting through console rather than a second
sink — one output path, so there is nothing to drift. core/command.py composes
them, and the order is load-bearing: handle_errors wraps logged, so the logger
sees the original exception. Inverted, every failure would be recorded as
"SystemExit" and the log would say nothing about what went wrong while looking
like it worked.
core/ raises SystemExit, not typer.Exit. 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 leaks the transport into every layer.
The check router is retrofitted off its hand-rolled verdict-and-exit pattern —
exactly the boilerplate this removes — and test_check_parity.py passes
unchanged across the retrofit. That test predates the decorators and pins exit
codes against the old script, so it is independent evidence, not a test tuned
to match new behaviour.
Unknown domains and unknown verbs now enumerate what exists instead of only
saying no. That needed a shared group class, which collided with "no typer
outside main.py and router.py" — resolved by sharpening the invariant rather
than breaking it, since its purpose is that a SERVICE never knows it was called
from a CLI. Transport now lives in main.py, router.py and core/cli.py; never in
service.py, schemas.py or helpers.py. The upside is that cli.domain() carries
the settings that were previously per-router decisions, including the
load-bearing rich_markup_mode=None that one forgetful domain could have undone.
test_conformance.py makes five invariants executable, AST-based rather than
grep. Scoped to the package, not the 123 legacy scripts — and deliberately so:
as T-1250 moves each script into domains/, it lands inside the scope and the
rules start applying automatically, so the test's reach grows with the
migration.
Proven to fail before being trusted: removing @command and removing a fix= each
produced a failure naming the file, the line and the reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three amendments, all from pressure-testing the record against how the CLI
will actually be used.
The ~104 ms push-gate ceiling is withdrawn. It was the summed cost of three
single-sample timings, imported as a requirement without asking who pays —
and who pays is the pre-push hook, which already runs cargo test or the
gdUnit4 suite on any code push. A few hundred milliseconds is invisible
there, and on a governance-only push the whole hook is about a second. The
criterion is OUTPUT parity: a ported check must produce the same output and
the same exit code as the script it replaces, and is not required to be as
fast. What replaces the ratchet is a ceiling with headroom — under ~250 ms to
feel instant. Lazy registration stays mandatory, justified by the real
threat rather than by parity: scipy.ndimage alone is 275 ms, and an eager
entrypoint would pay ~460 ms before executing a line of its own.
That budget change removed the only argument for keeping pydantic out of the
gate domain, so the carve-out goes with it. One fewer exception, and the
reference implementation is now the normal pattern rather than a footnote.
Commands also stream. The gates are milliseconds but the generators are
minutes, and an agent Bash call gives up at two and sends nothing. Detaching
alone would fix the timeout and keep the silence; streaming fixes the part
that costs real time — you learn a generator is wedged at minute one instead
of minute nine. JSONL events on stderr, stdout reserved for actual output,
rendering at the sink so a job log and a live terminal are one artefact in
two presentations. Reattach is a byte offset into an append-only file, which
is why there is deliberately no daemon.
The trap, recorded because it would quietly undo the thing this record cares
most about: streaming is ADDITIVE to the failure contract. A remedy emitted
at line 400 of 900 is printed and invisible, so the verdict still prints
once, last.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Q-124 asked whether the 123-file Python tooling should be retooled into a
Rust CLI. The answer is no, and it is a costing rather than a preference.
All three frictions it names — per-script permission prompts, the venv/PATH
split between interactive and non-interactive shells, and interpreter
startup paid four times per push — are packaging problems, and one bare
command on PATH with lazy subcommand loading fixes all three. Rust would
additionally owe a numerical-equivalence proof on the planet-gen path,
whose heightmaps are committed build artefacts with goldens standing on
them: a large one-time cost to avoid a small recurring one, paid in the
currency the project can least afford to spend.
D-263 fixes the shape. tooling/ becomes an installable package behind the
`reach` command: a routing-only main.py, every domain under domains/<name>/
split router/service/schemas/helpers, a core/ bounded on day one to what
has no domain, logging and error handling attached as decorators rather
than call-site discipline, and pydantic confined to domain schemas —
measured at 87 ms against a whole gate check of 20-46 ms, which is why it
must never reach the push path. Failures carry the command that fixes them
and keep their exit code; a tool that explains itself and exits 0 silently
disables its own gate.
R-014 records the Rust option as costed down, not argued down, with the
condition under which it is worth reopening. T-1247 files the work as
eight dependency-ordered epics; only the skeleton is unblocked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The relationship between wiki/, the generators, systems.db and the runtime is
a directed graph with two edges running opposite to the obvious direction and
one running backwards into its own producer. Prose renders that badly: every
document that has described it states a single ownership direction and is
therefore wrong about part of the tree. D-262 makes the diagram the source of
truth and points CLAUDE.md, Skill(wiki), project-structure.md and
wiki/GOVERNANCE.md at it.
The correction that matters most: body pages were described everywhere as
machine-owned and reverted on sync. They are not. scaffold_bodies.py writes
one once and never overwrites it, and import_economics then reads that
frontmatter directly as input — so a hand-edit is not reverted, it is obeyed,
and silently changes world generation. Worse than being overwritten, and the
actual reason GOVERNANCE.md forbids the edit.
New: tooling/check-dataflow-graph.py, wired into the Makefile and the pre-push
hook. It asserts every repo path named in a hand-authored diagram still
resolves — and its docstring states plainly what it cannot do: verify that an
edge still MEANS what it says. If wiki_sync.py stopped writing body pages
tomorrow, every path would still exist and the check would still pass. Edge
semantics stay a human check against the tool's source, so nobody reads a green
gate as a verified map.
Verified by breaking it: pointing one label at a moved path fails with exit 1
naming that path; restoring it passes. Building the checker also caught two
real vaguenesses in the diagram — "GJ-*/index.md" and "bodies/{id}/index.md"
were written without their wiki/star-systems/ prefix, which is precisely the
ambiguity this map exists to remove. Generated star-map .d2 files are excluded
by name; their correctness belongs to their generator under D-223.
Also files Q-124 + T-1246 (tooling): whether the 123 Python files under
tooling/ should become one Rust CLI of pql's calibre. The friction is real and
mostly not about the language — the permission gate prefix-matches whole
command strings and a blanket Bash(python3 *) grant is forbidden, so each tool
prompts near-individually, while a single binary is one allowlist entry. The
record requires pricing the cheap alternative (a Python dispatcher entrypoint)
before recommending Rust, and flags the hard constraint: import_economics is
stamped by source SHA, so any port must keep that contract intact through the
transition rather than disabled during it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cascade owns order, the ticket tree owns decomposition, the DQR tree owns individual rulings; none answer what the game is going to be. Evidence it is a real gap: three roadmap-level facts surfaced in one conversation on 2026-08-20 that exist in no artefact, and two of them were written up as suspected defects by an agent reading carefully, because nothing recorded them as intent. Pickup instructions make epics an OUTPUT of a harvest/interview/investigate-form/propose pass, explicitly not an input.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An experiment, at Jeroen's request: predict how the wiki seed data is structured
WITHOUT reading it, seal the prediction, then score it. The prediction is
a1addf7e2, committed before wiki/ was opened so it could not be retrofitted.
The score, against a rule fixed in advance:
RIGHT — markdown + YAML frontmatter, TOML for economics tables, the body path
shape, more trees than the three I had seen.
WRONG — "a source, never an output". That holds for 253 pages and is backwards
for 3,262: star-systems/ is GENERATED from systems.db by tooling/db/wiki_sync.py,
its <!-- READ-ONLY --> blocks are renders, and body frontmatter IS the body
definition rather than a description of one. Also wrong: "probably no schema
docs" — there are 17 templates, ten authoring guides, economics/schema.md and a
GOVERNANCE.md that states the ownership models plainly.
ABSENT (the expensive bucket) — the two ownership models running in OPPOSITE
directions; the GTTR prose channel; terrain.npz/globe.png; that stations and
districts have NO wiki directories; that `description` frontmatter exists so
agents can filter before loading; and the scale, 11,864 files.
ROOT CAUSE, and it is not missing documentation. The wiki documents itself well.
It was unreachable: wiki/ appears in NEITHER CLAUDE.md's Project Structure block
NOR .claude/rules/project-structure.md, the annotated tree whose entire job is
orienting an agent. The largest tree in the repo — the seed for the whole Reach —
was invisible from both files a session reads first. Every item in the absent
bucket follows from that one omission. The proof is this session: it spent three
days fixing Ferrath's terrain rendering and never once saw
wiki/star-systems/GJ-820B/bodies/GJ820Bc/index.md, the file that defines Ferrath.
Fixed here: wiki/ enters both structure documents with the ownership split stated
where it will be read, and Skill(wiki) carries the traps — never hand-edit a
READ-ONLY block or body frontmatter, stations have no directories, the id is
spelled two ways, editing corp PROSE stales systems.db, and absent variance is
often deliberate rather than a gap.
That last point cost two false findings in one measurement and is worth the
warning: chemosynthetic:false on every body is a namespace reservation for
dextro-DNA-style biochemistry once geology and nature spawn to the 1x1m pixel,
and enabled:false on ~65% is staged rollout — clean planet types first, generator
scripts for the rest after. Both read as defects without the roadmap.
Also measured, since the seed's job is to supply variance: continuous axes are
rich (unique seed per body, 460-716 distinct values across orbit/tilt/ice/land)
while the categoricals that gate morphology are concentrated (68% tectonics low,
51% planet_class frozen). Filed as T-1244 with the design question stated first —
whether the distribution is intended — rather than as a defect.
Method caveat recorded in the findings: the aggregator reads scalar frontmatter
only, and atmosphere_color's "100% null" was a parser artefact, not a finding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pre-push gate rejected the T-1213 push on a wall-clock fog budget (0.606 vs 0.5 ms) that passes 23/23 in isolation on the same build. Second hardening cycle for the same failure mode: min-of-7 defends against one slow sample, not the sustained core saturation the gate itself creates by running cargo and tooling suites immediately before it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ferrath's Global map drew no rivers at native resolution: 375 courses arrived
and 0 were drawn. The report suspected the D-261 length cull or the water
truncation. Both were innocent, and so was the renderer.
The client served the canvas from its own disk cache (T-1183). Every payload
for GJ820Bc predated T-1237 (4e503c356), which replaced one-course-per-D8-hop
with one-course-per-river -- so the map was drawing 375 hop fragments whose
longest run was 106 km, all of them under D-261's read-as-a-line floor. Same
build, same scenario, same 3440x1440, cache the only difference:
stale courses=375 runs=180 longest=6.0px (~106 km) drawn=0
cold courses=73 runs=23 longest=93.2px (~1,644 km) drawn=18
It looked resolution-dependent because it wasn't a resolution at all: 960x540
resolves to an 814x407 canvas, a key never cached, so it missed and re-derived
correctly. 3440x1440 resolves to 1080x540, which had an entry from 2026-08-06.
During the stale capture the server logged no course production whatsoever --
the canvas never came from it.
The cache's only invalidation signal is project.yaml's version, and 4e503c356
changed how canvases are generated without touching it, so hop-shaped entries
stayed valid. All 13 stale entries are stamped 0.4.5. 0.4.6 forces them to miss;
that, not clearing a local directory, is what repairs a player's Atlas.
The harness let this hide for eight days, in two ways now fixed. It ran against
the developer's persistent user:// cache, so a capture could render a canvas
built by a build that no longer existed -- and any golden shot in that window
silently inherited it; user:// is now isolated per run. And it sent server
stderr to /dev/null via an already-unlinked mktemp file, so no tracing from a
capture was ever reachable; the log now lives at .cache/visual-server.log.
The capture readout gained runs= and longest= between courses= and drawn=,
because "375 arrived, 0 drawn" is not one fact but three stages, and telling
them apart is what turned a guess between two suspects into a measurement.
Follow-ups filed: T-1241 (current_schema_version() returns its ?.?.? fallback in
an exported build, so a shipped game never invalidates on version at all) and
T-1242 (nothing enforces the generation-change/version-bump pairing -- this is
the fourth bump forced after the fact).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pql 2.0.0 versions the changelog file format and carries older ones
forward. The rewrite touches only the inline conflict guard on each
line, which moved from a content-hash tiebreak to append position
(3992 lines in, 3992 out — no row data altered).
This repo carried real damage from the old rule. A ticket created and
appended to within one wall-clock second produced two changelog rows
tied on updated_at, and the hash decided the winner — arbitrarily, and
on every replay, so the loss reappeared on each fresh clone and branch
switch. Replaying the pre-upgrade changelog and diffing all 1232
tickets against the repaired state: 69 tickets gained description text,
none lost any, 13270 characters recovered in total. Six had no
description at all.
T-1057, where this was first noticed, keeps the description a session
hand-recovered from ticket_history in July; its later updated_at means
the tie no longer decides it. The workaround scaffolding in that field
can be tidied whenever convenient.
plan rebuild --verify reports zero rows lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The District and Quarter rungs rendered as flat colour, and the cause was not
the biome work everyone assumed. Measured on Ferrath through the production
canvas builder: at District the mean |elev_q delta| between neighbouring
gridunits is 0.02, and NOT ONE PAIR in a 1290x540 frame differs by 2.
elev_q spans 0-100 across the body's whole 8 km elevation range, so ONE STEP IS
80 METRES. A District canvas covers 2,048 m of ground, where the rolling relief
a walker navigates by is metres to tens of metres -- a fraction of a single
step. The sub-district detail IS generated (invent_primitives' scatter and
relief bands compute it) and then rounded away. Confirmed by running the
diagnostic with the octave cutoff disabled: still 0.02.
relief_q carries that same invented fine component against a scale chosen to
resolve it: 0-100 about a flat 50, RELIEF_FULL_SCALE_M = 400 m either side, so
8 m per step -- ten times finer than elev_q. elev_q keeps its body-absolute
meaning and the Atlas legend stays true.
Measured effect, elev_q vs relief_q (distinct values / mean 4-cell delta):
Region 49 / 2.38 -> 101 / 21.86
District 10 / 0.08 -> 35 / 0.35
Quarter 8 / 0.02 -> 19 / 0.06
FIXED metre scale, never per-canvas normalization: the value for a piece of
ground must not depend on what else is in frame, or the same hillside changes
tone as the viewer pans. And it excludes elev_pct deliberately -- this is the
departure from the surrounding land, not height above sea level; including the
base would re-introduce the body-scale dominance that makes elev_q unusable
down here.
50 at the orbital rungs, which skip invent_primitives by design. Nothing is
lost: Global and Region still have varied elev_q (101 and 49 distinct values),
and the client takes whichever field carries signal via a max, with no
rung-name branching.
The client's ruggedness driver changes with it. It was an elev_q GRADIENT,
which cannot work across rungs -- the same 4-cell delta reads 21.86 at Region
and 0.08 at District, so any single full-scale constant either saturates one or
vanishes on the other. relief_q states relief outright, so |relief_q - 50| is
the answer directly and a fixed metre scale is immune to that by construction.
An absent plane reads FLAT, not zero -- 0 on this field means maximum relief
BELOW flat, so a payload without it would have stippled the entire map. That is
reachable: the field is #[serde(default)] so old-shape payloads decode. Two
colorize tests whose fixtures predate the plane caught it.
2004 server tests, 1838 client tests, 0 failed. clippy clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two faults, one fix.
Captures were stealing the desktop. Only the golden path even tried to go
offscreen, via xvfb-run -- which is not installed here, so it took the
"using visible window" fallback; --screenshot and --movie never wrapped at
all. Every capture opened a Godot window on the machine Jeroen is working
and gaming on. Now a single wrapper covers all three paths.
gamescope, not the alternatives, for two independent reasons. It renders on
the real AMD GPU, and the goldens are pinned to this box's Mesa/AMD output
(T-1121 -- they do not port across rendering stacks), so xvfb-run's llvmpipe
would shift every pixel: offscreen must not silently mean a different
renderer. And it is the only installed option that lets the output size be
set. cage is also present and also GPU-backed, but it is a kiosk compositor
and forces its client to the headless output's default -- measured, a
960x540 request produced a 1280x720 PNG. A wrapper that quietly changes
resolution is worse than none here.
Worse, because resolution is not cosmetic on this map. D-255's extent
inversion makes the shorter viewport axis span exactly one cell of the rung,
so the viewport decides how much world a rung shows and at what cell count
-- a small capture is a DIFFERENT map, not a scaled one. 960x540 was also
16:9, so it never exercised the ultrawide aspect added in 21e263d0a, which
is the aspect actually in daily use. Raised to the panel's native 3440x1440.
It paid for itself immediately: at native, Ferrath Global reports
courses=375 drawn=0 -- every river culled, where the same build drew them at
960x540. Filed as T-1239. That is exactly the class of bug a too-small
capture hides.
Goldens are NOT regenerated here. They are stale across 15 commits already,
and blessing the current look before it has been reviewed is the trap this
suite just spent a day proving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A river is sub-pixel at almost every Atlas scale (a 100 m channel against
Global's ~17.6 km per screen pixel), so drawing it to scale draws nothing.
The line becomes a symbol whose job is legibility: a fixed 5 px screen-space
stroke, contiguous through the river's own cell centres, never drawn over
water, and culled when too small to read.
The cull threshold derives from the stroke rather than being stipulated. A
mark reads as a line at roughly 3x its own width, so the minimum is 15 px of
on-screen length — and the kilometre thresholds then fall out of each rung's
scale: 264 km at Global, 2.8 km at Region, 28 m at District. A level-of-detail
ladder with no hand-tuned constants, which self-corrects if the width changes.
Measured on the visible extent, not total river length: a course crossing the
window always spans it and passes, so only a course wholly inside the view and
small is culled. Correct at both ends of the ladder, and no new wire field.
Records a tension that had to be resolved rather than split. Jeroen proposed a
flat 100 km cutoff, then noted that 5x5 px still reads as a dot and asked for
15-20. Those are incompatible: the km threshold depends only on the pixel
length, so 100 km implies ~6 px, which is dot-shaped at a 5 px stroke.
Admitting 100 km rivers AND keeping them line-shaped needs a ~2 px stroke.
Thick lines mean fewer rivers; thin lines mean more. Ruled for the bold stroke.
Client-side, and provably so: the cull is measured in screen pixels, and the
display ratio is a client-side viewport-dependent parameter that never reaches
a wire request or cache key, so the server cannot know how many pixels a
course occupies. Water-clipping needs nothing new either — the per-cell
classification is already in the adopted canvas.
Built on the 2026-07-27 measurement in D-258's amendment: 375 courses present,
458 of 518,400 pixels different. The rivers were not failing to render, they
were correctly beneath notice; the defect was drawing specks instead of
drawing properly or not at all.
T-1237 implements. T-1238 restores size-varying width as polish, deliberately
deferred — noting width and cull are coupled, since a thinner stroke lowers
its own visibility threshold.
Co-Authored-By: Claude <noreply@anthropic.com>
Jeroen: 'still not filling the screen... maybe we should calculate the depth
that needs to be invented based on the canvas size somehow.' That is the fix.
The old flow picked cells from the viewport, let the SERVER impose Global's
2:1 aspect, then fitted the returned extent into the drawable area with an
INTEGER pixels-per-gridunit ratio. When that extent did not divide the area
evenly, floor() dropped a whole step — and at ratio 2 the only step below is
1, i.e. half size. Worse, because the client fitted an extent it had not
chosen, the result could fill NEITHER axis.
Inverted: global_fill_extent() chooses the cell count FROM the ratio, already
shaped 2:1, so cells * RATIO is the drawn size by construction and lands
exactly on the binding axis. Verified across window shapes — 1920x1080 fills
width, 2560x1080 fills height, 2560x1440 width, 3440x1440 height, 1280x720
width. Exactly one axis fills at every shape, which is the most a 2:1
equirectangular canvas can do in an arbitrary viewport; the other letterboxes.
Global keeps its 2:1 aspect because it is 360 degrees of longitude by 180 of
latitude — the aspect cannot follow the viewport without shearing the map.
Tests pin the invariant directly: 2:1 preserved, never overflowing the
drawable area, and never leaving slack on BOTH axes.
Client suite 1832 / 1806 passed / 0 failed / 26 skipped.
Co-Authored-By: Claude <noreply@anthropic.com>
Everything else from the pair session was either shipped or ticketed with its
rationale in git. These two were design decisions Jeroen made, living only in
a scratchpad under /tmp, one cleanup away from gone.
D-259 — ENCLOSED SETTLEMENTS. Open-air requires atmosphere == standard;
everything else is enclosed, as point locations on the Atlas with interiors
deferred to a separate generator and handled like embedded stations. Branch on
an explicit `enclosed` property rather than the atmosphere string, so enclosed
settlements on breathable worlds (hostile-biome posts, undersea, arcologies)
need no second code path. NULL defaults to enclosed, which fails safe.
The load-bearing claim is that D-220's density model is PHYSICALLY
INAPPLICABLE to a pressure vessel, not mistuned: it measures people per km2 of
footprint driven by utility cost per metre of street, so run airless it yields
1,500 ppl/km2 of open-air streets in vacuum. The handoff mechanism was already
planned — D-166's door boundary contract, where a dome's airlock IS that door.
Atmosphere keeps its full range for the surface-expedition layer; the binary
collapse applies to settlement layout only, and the record says so to stop the
collapse being read as atmosphere not mattering.
D-260 — GENERATOR SCOPE. Sol is encoded as Authored { deepest_rung: Global }
rather than excluded by convention. The DLC gate is a DEPTH, not a boolean, so
a Sol expansion changes one value instead of unpicking an exclusion. No new
wire status: "viewable at Global, no deeper" IS the existing rung-liveness
path. Sol art is an authored data canvas, not a finished image, so it rides
the existing wire and is drawn by the same map-art function as all 269
procedural bodies.
The hazard that made encoding necessary is recorded: systems.db still carries
terrain_reference rows for Sol bodies pointing at heightmaps deliberately never
baked, so a real Earth DEM dropped at that exact path would silently start
generating a procedural homeworld. The importer comment said Sol was excluded;
nothing enforced it.
Also files the last three parked items, which were likewise /tmp-only:
T-1233 tiled/interlaced map loading (workshop-sized D-255 revision, blocked in
spirit on the rung-0.5 cost numbers), T-1234 enumerate the flow tweaks the
fetch panel exposed but which were never written down, T-1235 zoom
discoverability — the person who specified wheel-only zoom could not find it.
T-1231 closed.
Co-Authored-By: Claude <noreply@anthropic.com>
T-1222 and T-1223 shipped today but were still sitting in backlog. Closed,
with what actually landed recorded on each — including that T-1223's title
premise was wrong: godot-cold-parse does not miss client/tests specifically,
it only ever sees the startup path, so the fix was a new tool rather than a
widened filter.
Three gaps opened after the reconciliation pass and had no ticket:
T-1230 — re-enable test_character_visual_sprint28 and fix the per-test
compositor rebuild that made it 37% of the client suite. Skipping it bought
39s; the skip must not become permanent, and the ticket says so with the
deadline (Phase 5 player rendering) and the better fix to prefer.
T-1231 — the enclosed-settlement and Sol GeneratorScope rulings still exist
only in a scratchlog under /tmp with no D-record behind them. Qatux flagged
this and correctly refused to invent the governance itself.
T-1232 — scene_helper.gd turns out to have no importers at all, which is why
its five-month parse breakage cost nothing. Delete or adopt: user's call.
Co-Authored-By: Claude <noreply@anthropic.com>
CLAUDE.md's Phase-4 row described the pre-inversion ladder — "every step a
server-derived data canvas at its native gridunit spacing" — which the D-255
amendment reversed. Corrected, with rung 0.5 noted as ruled (D-258) but not
implemented rather than restated there. The D-243 scale-ladder section is
deliberately untouched: scale.rs still holds the old constants, so it is
still accurate, and amending it now would make it wrong in the other
direction.
CHANGELOG gains three player-facing entries for today's shipped work, with
the whole-body-map fix carrying an explicit "still open: no rivers or lakes
yet" caveat so it does not read as finished.
Tickets T-1211..T-1229 filed: the rung-0.5 epic with its cost measurement
gating every child, the scale-constant change, Sol's GeneratorScope, the two
test-harness false greens, the make-atlas shutdown bug, two data gaps and
three cleanups. Golden regeneration is blocked on both the rung-0.5 epic and
the scale-constant change so the revalidation is paid once.
Review corrections applied to the delegated pass:
- The blocker graph was reported but never created — all 8 claimed edges were
absent. Added. `pql ticket list --under T-1211 --unblocked` now correctly
returns only the measurement, which was the structural point of the epic.
- A changelog entry credited T-1206, which is an unrelated open bug about
synthetic settlements landing in open water. Re-attributed to the D-255
amendment.
- Tickets have no --decision link to D-258/D-255. Not repairable: --decision
exists only on `ticket new` and `refine write` rejects it. Filed upstream as
pql FR-5 rather than worked around; the descriptions reference the records
in prose meanwhile.
Pair session with Jeroen, 2026-07-27.
Co-Authored-By: Claude <noreply@anthropic.com>
T-1194 (biome/relief stipple layer, from T-1175's assessment) and T-1195
(river+mountain label positions, the T-1169 scope adjudication target)
now have their write-through rows in git — tyre's PR #208 review caught
the branch-side store unable to resolve the T-1195 reference because
these rows were awaiting this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
T-1191: atlas edge-scroll pans on unfocused mouse motion (eyeball nit).
T-1192: Global rung canvas fit/center presentation.
T-1189: raised to high — Region-rung extent exceeds body on both axes at
standard viewports (continent repeats + south-pole stripe smear), per the
T-1183 eyeball captures Jeroen flagged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The T-1177/T-1178/T-1154/T-1179/T-1180 in_progress transition wrote through
to the changelog after the sign-off commit; landing it per the
ticket-mutating-turn persistence rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tyre's carrier ruling: rivers ride the EXISTING whole-body layer1 field
(no new wire field, neither ceiling touched); per-rung refinement is
client-side class filtering (river_class quantized from flow
accumulation — the one additive server change); discrete-geometry-vs-
continuous-field carrier rule established for wave 2. D-226 note added
on the rivers-ladder branch. Si's audit: skeleton is 512x256 heightmap-
resolution dot data (~76 km/cell) — planetary courses real now, sub-
heightmap course geometry named as T-1170, labels split to T-1169
(three dormant pieces, not free).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>