Retire compound-name metadata addressing — un-mount the three :section arities and unify SDK URL spelling (#12176 stage 3) - #12378
Conversation
Stage 3 of the #12176 maintainer-ruled retirement of slash-bearing metadata item names. Stage 1 (#12194) declared the item-name grammar and refuses every slash-bearing name at the publish door, so the compound arities addressed only names that can no longer be created — this removes them. packages/rest: - Un-mount the three compound arities: GET and PUT /meta/:type/:section/:name and GET /meta/:type/:section/:name/published. Drop their three route-ledger rows and the ordering pins that existed only because the three-segment compound route was a CATCH-ALL shadowing every literal sibling. packages/runtime: - Un-fold the dispatcher: the /published branch requires exactly three segments and the item branch exactly two, instead of re-joining every trailing segment into one slash-bearing key. - Decode the :name segment. This dispatcher splits the RAW path and nothing decodes for it, unlike the Hono routes in packages/rest — measured, not assumed. Without it a pre-grammar residue row would be addressable through REST and not through the dispatcher, breaking #12194's landed acceptance criterion that stored junk names stay listable and clearable. The sibling domains/packages.ts already decodes its own id segments this way. packages/client: - One URL spelling: encodeURIComponent on every /meta item address, closing the 10-unencoded / 4-encoded split. Encoding a name that satisfies #12194's grammar is a no-op, so this is byte-identical for every writable name; a residue name now reaches the single-segment door as %2F rather than forking the request onto a second door. - Correct the docblocks that promised unencoded compound pass-through, and the #11712 mode carve-out, which is closed at the source rather than here. Capability is re-expressed, not removed: %2F matches the single-segment pattern and Hono decodes the parameter back to the stored spelling, so residue rows still read, write and delete through the surviving doors. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9StxQgG2DPA26XzZZqnJB
…oval
Every pin that measured the retired compound-name arities is REWORKED, never
silenced. Two shapes, chosen per file by what is actually still true:
INVERTED — files whose subject was a shadowing or divergence hazard now pin
the ABSENCE of the arity, because a re-mount is how the hazard returns and an
order pin phrased against a retired route would go green while the retirement
is undone:
- meta-route-registration-order: the three-segment catch-all ordering pins
become "no compound `:section` arity is mounted", plus a companion pin that
the literals it used to shadow are all still mounted.
- meta-item-layered-route, meta-plural-i18n, rest-server-meta-write-org-scope,
meta-compound-save-{mode,force}-parity, meta-compound-save-and-reset-
capability-gate, meta-501-envelope: same inversion, each keeping the reason
the original pin existed (a re-mounted door arrives ungated, org-blind, and
reading neither `?force` nor `?mode` until someone re-derives that work).
RE-AIMED at the surviving door — coverage that was never about the spelling:
- the full #11712 `?mode` and #11095 `?force` contracts, the #6877 repeated-
parameter guard, the #7019 capability gate and the #8842 falsy-body hole
now run against `/meta/:type/:name`.
- meta-published-overlay's residue-row read drives the single-segment
`/published` route with the decoded name — the pin that the retirement
costs no capability.
- client URL pins invert to `%2F`, each with a control proving a grammar-legal
name is byte-identical on the wire.
NEW post-removal pins, ADR-0112 code AND status on every one:
- the compound path answers `404 ROUTE_NOT_FOUND` at the dispatcher;
- the ENCODED spelling still reaches the item-name grammar's `400
INVALID_REQUEST`, so "the route is gone" and "the name is illegal" stay
distinguishable;
- `DELETE` on a compound path answers 404, not the 405 its two-segment
address gets — the two refusals are different facts.
Also: the meta domain's tail `{ handled: false }` was unreachable dead code
until this card (0, 1 and — through the fold — every 2+ segment path were all
covered). Un-folding makes it reachable, so it becomes a LOCATED
`routeNotFound` rather than letting the adapter answer an anonymous 404 on the
very shape this retirement newly produces. Same form `domains/ai.ts` and
`domains/share-links.ts` already use.
Changeset: minor on rest/runtime/client with the BREAKING route table and the
FROM → TO spelling, per the launch-window convention.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9StxQgG2DPA26XzZZqnJB
📓 Docs Drift CheckThis PR changes 4 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b98105ea18cfec51ffa26210830e88dcfefe885b && git checkout b98105ea18cfec51ffa26210830e88dcfefe885b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c4a27b7b0e25ead095f7385782e0aab14255f9ee 2f568fd0e9858c54da995a141305a0524d3cdf63 && git checkout -B drift-repro c4a27b7b0e25ead095f7385782e0aab14255f9ee && git merge --no-ff 2f568fd0e9858c54da995a141305a0524d3cdf63
node scripts/docs-audit/affected-docs.mjs --json c4a27b7b0e25ead095f7385782e0aab14255f9ee
|
Contract-review verdict: PASS (#12195, D3 of the #12176 program)Reviewed by the contract-review chain at Reviewed: the PR body, the dev report on #12195, and the load-bearing patches read from the branch ( Conformance to the ruled scope (maintainer 2026-08-25 program, recorded on #12176/#12194): all three limbs delivered — the three The one addition beyond removal is ruled-in by D1's own landed contract, not scope creep: #12194's acceptance criteria require residue rows to stay listable and clearable, and the measured transport asymmetry (Hono decodes route params; the dispatcher splits the raw path undecoded) means un-mounting alone would have broken that on one transport. The contract statements the new pins make are the right ones: "the route is gone" ( Evidence discipline held throughout: removal-first red (80+10+4 tests, every failure naming a retired arity, the client pair failing with the exact predicted Landing completed by the chain in the same stroke per the maintainer's standing instruction (2026-08-25, verbatim: 「审核通过你应该直接负责合并吧,还要等原始的项目经理吗」): clearing Generated by Claude Code |
…ogfood compound-door rows (#12195) The pin rework left four dead constants and two off-type boot calls in the rest test layer — TEST_DEBT measured 161 against the frozen 155; all six are fixed, none ledgered. The dogfood anonymous-deny table drops the retired compound-save row (its registered-door leg asserts .not.toBe(404), which is what a retired route answers) and gains the retired-door case: the compound spelling must 404 for anonymous and member alike, since an auth floor only speaks for a door that exists. The authz-conformance matrix note re-tallies six -> five with the retirement named. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9StxQgG2DPA26XzZZqnJB
Fixes #12195
Part of #12176 · Part of #12106 · Part of #11932
Stage 3 of the maintainer-ruled retirement of compound (slash-bearing) metadata item names. Stage 1 (#12194, merged as #12244) declared the item-name grammar and refuses every slash-bearing name at the publish door — so the routes removed here addressed only names that can no longer be created. This is the dead-addressing removal that ruling planned.
What is removed
Three mounted arities in
packages/rest, with their route-ledger rows and the ordering pins that existed only because of them:GET /api/v1/meta/:type/:section/:nameGET /api/v1/meta/:type/:namePUT /api/v1/meta/:type/:section/:namePUT /api/v1/meta/:type/:nameGET /api/v1/meta/:type/:section/:name/publishedGET /api/v1/meta/:type/:name/publishedEach folded
:sectionand:nameback into one slash-bearing key the protocol layer treated as a single opaque string. The matching folds inpackages/runtime/src/domains/meta.tsgo with them: the item branch requires exactly two segments and the/publishedbranch exactly three, instead of re-joining every trailing segment.packages/clientunifies onencodeURIComponentfor every/metaitem address, closing the split this card was given as 6-unencoded/4-encoded.Measurements that corrected the brief
The census's SDK split re-derived on this ref is 10 unencoded / 4 encoded, not 6/4. Anchored counts on
packages/client/src/index.ts: 8 unscoped${route}/${type}/${name}sites plus 2 scoped/meta/${type}/${name}sites were unencoded;deleteItemandgetHistorywere already encoded on both clients. All 14 now encode.%2Freaches the single-segment door, and Hono decodes it. Measured on the real router rather than assumed:This is what makes the removal capability-preserving, and it is load-bearing for a constraint the brief did not carry: #12194's landed acceptance criteria state that "reads and
deleteMetaItemstill answer for pre-grammar residue rows, so any stored junk name remains listable and clearable." Un-mounting the compound arities does not break that — a residue row is addressed percent-encoded through the surviving door, which is exactly what the SDK now sends.meta-published-overlay.test.tspins it end-to-end on a storedviews/all_leadsrow.The dispatcher needed a decode to keep that true, and this is the one addition beyond un-mounting.
packages/runtimesplits the RAW path (path.split('/')) and nothing decodes its parameters, unlike the Hono routes — measured:c.req.pathreturns/meta/lead/views%2Fall_leadsverbatim whilec.req.param('name')on the same request yieldsviews/all_leads. Un-folding without decoding would have left a residue row addressable throughpackages/restand not through the dispatcher, breaking #12194's criterion on one transport.decodeMetaNameSegmentcloses that; the siblingdomains/packages.tsalready decodes its own id segments the same way, so the meta domain was the outlier.The
>=fold was also answering paths that were never compound names./metadata/object/foo/referencesfolded to namefoo/referencesand was answered as a metadata READ of a name nothing stores, rather than as the not-found it is. Requiring exactly two segments ends that too — visible inmeta-state-plural-tolerance.test.ts, where/meta/objectss/task/state/statusmoves fromRESOURCE_NOT_FOUND(a statement about an item) toROUTE_NOT_FOUND(a statement about the route), which is the accurate one.The meta domain's tail
{ handled: false }was unreachable dead code until this card — 0, 1, and (through the fold) every 2+ segment path were all covered. Un-folding makes it reachable, so it becomes a locatedrouteNotFoundinstead of letting the adapter answer an anonymous404 'Not Found'on the very shape this retirement newly produces. Same formdomains/ai.tsanddomains/share-links.tsalready use.How the pins were reworked
Every pin that measured a retired arity is reworked, never silenced. 14 rest files + 6 runtime files + 2 client files.
Inverted to absence where the file's subject was a shadowing or divergence hazard — because a re-mount is how the hazard returns, and an order pin phrased against a retired route would go green while the retirement is undone. Each inversion keeps the reason the original pin existed: a re-mounted compound door arrives ungated (#6603/#7019), org-blind (#8805), and reading neither
?force(#11095) nor?mode(#11712) until someone re-derives that work.Re-aimed at the surviving door where the coverage was never about the spelling: the full
?modeand?forcecontracts, the #6877 repeated-parameter guard, the #7019 capability gate and the #8842 falsy-body hole.New post-removal pins, ADR-0112
codeANDstatuson every one:404 ROUTE_NOT_FOUNDat the dispatcher;400 INVALID_REQUEST, so "the route is gone" and "the name is illegal" stay distinguishable — they are different facts and they answer differently;DELETEon a compound path answers 404, not the 405 its two-segment address gets;%2F, each beside a control proving a grammar-legal name (crm_lead,crm_lead.pipeline) is byte-identical on the wire.Verification
All runs at
c6c3d98d, heavy runs throughscripts/pm/os-verify-lock.sh; exit codes captured before any pipe.Suites —
os-verify-lock: VERDICT command-exit 0:typecheckgreen on all three (VERDICT command-exit 0 · held the lock 32s).Red-before, direction predicted in writing. The removal was made first and the pins run against it: 14 rest files / 80 tests and 6 runtime files / 10 tests went red in exactly the predicted direction — every failure a pin naming a retired arity — then each was reworked to the statement that is true after the removal. The two client URL pins failed with precisely the predicted diff (
views/all_leads→views%2Fall_leads).Gate family derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(self-derived change set, 27 paths). Every runnable family run, each quoting its own verdict line:check-adr-0087-registration✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.check-changeset-no-major✓ This diff introduces no 'major' bump.check-empty-changeset✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).check:changeset-gate-self-tests✓all three self-tests (118 + 212 + 116 assertions)check:objectui-changeset✓check:authz-resolver✓ single shared authorization resolver intact; both entry points delegate.check:cross-package-test-inputsOK: 18 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check:test-source-aliasOK — 72 packages with tests scanned; 61 registered…check:type-source-resolutionOK — 93 tsc program(s) across 77 packages scanned…check:published-files✓ 69 publishable package(s)… declare a 'files' whitelist…check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new…check:page-declaration-shapeOK — 34 page entries across 2190 sources…check:nul-bytesOK (scanned 6844 text file(s)… no raw ASCII control bytes).release-rehearsal-clone --self-test✓check:skill-examples✅ 256 prose examples type-check across 3 surface(s)check:skill-examplesfirst REFUSED rather than failing —packages/client-react/distheld no declarations, so it would have reached a verdict without reading the artifact under test. Declared here rather than counted: the packages were built and it was re-run, and only the second run is reported.Scope
packages/specis untouched — D1 landed the grammar there and this card treats it as read-only. Nocontent/docs/releases/edit. The changeset isminoron all three packages with the BREAKING route table and the FROM → TO spelling, per the launch-window convention.needs:contract-review— public routes are removed. The contract-review chain reviews and lands this; nothing here flips it ready or arms auto-merge.Generated by Claude Code
Generated by Claude Code