Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): the dispatcher /metadata transport folds the URL segment before the org-scope decision - #11553
Conversation
…t before the org-scope decision The dispatcher took `type` verbatim from the path and handed it RAW to `organizationIdForMetaWrite`, while `protocol.saveMetaItem` folds the same string through `canonicalizeMetaRequestType` for storage. Two maps that must agree did not: storage folds through `META_URL_TO_SINGULAR` (every spelling), `declaresOrgOverride` tolerates only the manifest-collection spellings. For the two URL-only spellings of `allowOrgOverride: true` types — `translations` and `email_templates` — an org-active caller's write landed env-wide where the singular twin landed org-scoped: one item, two partitions, addressed by spelling. The `/published` branch is the smaller second site: after the layered consult misses, the code/package store is keyed by canonical type, so a raw plural answered 404 for an item the singular answered 200 for. Both sites now fold through `canonicalMetaUrlType` at the boundary — the correction #10340 landed for REST, and the one metadata-url-spelling.ts mandates. Not by widening `declaresOrgOverride`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 23 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 300665170c7dd054fe1f73f3f7c2c34deb1e6c1a && git checkout 300665170c7dd054fe1f73f3f7c2c34deb1e6c1a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c179dd7832dac8a32192435df849cc4eec218699 71e04b898a044536fc4de572cb9e3d291d791baf && git checkout -B drift-repro c179dd7832dac8a32192435df849cc4eec218699 && git merge --no-ff 71e04b898a044536fc4de572cb9e3d291d791baf
node scripts/docs-audit/affected-docs.mjs --json c179dd7832dac8a32192435df849cc4eec218699 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10503
needs:contract-reviewrides this PR. Clause-②: yes — this changes which rawspellings the transport folds before its accept/reject-shaped scope decision on the
write path. ⛔ Keep it draft; ⛔ no ready-flip, no auto-merge, no enqueue, and this
seat does not clear the label — the independent review chain does.
The defect
Two maps that must agree did not.
protocol.saveMetaItemfolds the path segmentthrough
canonicalizeMetaRequestType→META_URL_TO_SINGULAR, the completespelling map, for storage.
packages/runtime/src/domains/meta.tshanded the samestring raw to
organizationIdForMetaWrite, whosedeclaresOrgOverridetoleratesonly the manifest-collection spellings — incomplete by design.
For the two URL-only spellings of
allowOrgOverride: truetypes the two answersdiverged.
translationhas no manifest collection key at all;email_template's isthe camelCase
emailTemplates, so the snake_case plural the registry derivation addsis URL-only too:
Storage folded both spellings to the same canonical type, so the rows differed in
organization_idalone: one item in two partitions, addressed by spelling. Measuredend to end through the real dispatcher, protocol and repository — writing an item
under both spellings left two
sys_metadatarows where there should be one(
expected [ { id: 'r_1', …(10) }, …(1) ] to have a length of 1 but got 2), and theenv-wide one is shadowed by every read the org-active author makes.
GET /metadata/:type/:name/publishedis the smaller second site of the same class:after the layered overlay consult misses, the fallback reads the code/package store,
which is keyed by canonical type. Handed the raw segment it answered 404 under a
recognised plural for an item the singular twin answered 200 for.
Same class as #10340's REST limb, one transport over.
Anchors, re-derived — every line number on the card is stale
PR #11392 rewrote
packages/runtime/src/domains/meta.tshours before this branch wascut, so both anchors were found by shape, not by line number. Branched from
origin/mainatdaacc107(newer than thed2b2381athe claim comment names). Atthat commit the two sites were, as it happens, still at the numbers the card cites —
stated because it was verified, not because it was assumed:
daacc107PUT /metadata/:type/:namescope decisionorganizationIdForMetaWrite(type, activeOrganizationId)withtype = parts[0]meta.ts:416/publishedcode-store fallbackgetPublished(type, name)after the layered consult missesmeta.ts:305(and itsmetaSvctwin at:313)The fix
Fold the segment through
canonicalMetaUrlTypeat the boundary, before the scopedecision — the correction #10340 landed for REST, and the one
packages/spec/src/meta-spelling/metadata-url-spelling.tsmandates ("folding happensat the boundary and only there; the layers below keep reading the single canonical
singular").
⛔ Not by widening
declaresOrgOverride.packages/metadata-core/src/meta-write-org-scope.tsdocuments and pins that limit in its
ORG_OVERRIDABLE_TYPESheader; neither it normeta-write-org-scope.test.tsis touched here.Only the scope argument is folded. The request
typestays the raw segment,exactly as the REST doors leave it — the protocol boundary folds it itself, and two
pre-folds would hide a drift between them from the protocol's own tests. A type the
contract does not map (a plugin-registered kind such as
webhook) still reaches thestore verbatim: the fold is a lookup, never a spelling guesser. Both are pinned.
Pins assert the STORED SCOPE, not a 200
New
#10503block inpackages/runtime/src/meta-write-org-scope.test.ts, mirroringpackages/rest/src/rest-server-meta-org-scope-url-spelling.test.tscase for casewhere the transports share a door. 12 new cases; the file now runs 20.
The two measured members are pinned end to end on
sys_metadata.organization_id,through the real dispatcher, the real
ObjectStackProtocolImplementationand the realSysMetadataRepository— each with a schema-valid body, because a minimal one 422sbefore the scope decision is ever reached and would pass for the wrong reason. Each
plural is pinned against its singular twin as a live control in the same file, both
directions, plus a "one partition, not two" row-count pin and a no-active-org
control.
/publishedpins that both spellings return the same status and body andthat the store was asked for the canonical key under both.
The class-closing sweep over every key of
META_URL_TO_SINGULARis necessarilyargument-level — a schema-valid body for each of ~40 metadata types is not obtainable
— and says so in place; the argument→partition link it stands for is what the two
end-to-end specimens pin.
Anti-vacuity — every pin shown failing without the fix
Reverting the fold in
meta.ts(three call sites), direction predicted beforerunning: 8 red / 12 green. Measured: 8 red / 12 green, exactly the predicted
cases.
The mutation was proven on disk by grepping for both the removed and the injected
text, anchored at the text meant to change — an editor's exit code proves nothing and
a zero-hit substitution exits 0:
The script carried
trap '…git checkout <branch> -- …' EXIT INT TERM, so a killed runcould not leave the tree mutated (the trap fired and logged
TRAP: restored).Rebuild not required, and the claim is evidenced rather than assumed. The mutated
subject is the runtime package's own source: the suite imports it relatively
(
import { HttpDispatcher } from './http-dispatcher.js', which pulls./domains/meta.js), andpackages/runtime/distdid not exist at ablation time(
ls: cannot access 'packages/runtime/dist': No such file or directory, printed by theablation script itself). There is no artifact between the edit and the run, so no leg
could read a stale one.
Measured red limbs:
Green throughout both legs, as predicted: all 8
#7018cases, both no-active-orgcontrols, the
objectsover-folding guard (aallowOrgOverride: falsetype must stayenv-wide under every spelling — a "fix" that org-scoped everything would pass the
red cases and fail here, re-minting the #6190 phantom rows), and the unmapped-segment
pass-through.
Verification
All readings below were taken with the tree at
71e04b89(this PR's head), exceptthe one
--re-measurerun noted as byte-identical.Package suites:
Gate set derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack,which asserted the answer belongs to this repo and read the change set from the
merge base itself (
3 path(s) vs merge base daacc1071). Every gate below is quoted byits own verdict line, never a bare
$?:check:changeset-gate-self-tests✓ check-changeset-no-major --self-test: 116 assertions …check:cross-package-test-inputsOK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check:objectui-changeset✓ objectui-range --self-test: all checks passedcheck:published-files✓ … 69 publishable package(s) of 78 workspace member(s) declare a files whitelist …check:route-envelope✓ Plugin-mounted Hono routes — 12 module(s) audited … 8 conformant, 0 ratcheted, 3 exempt, 1 vendor-wirecheck:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new …check:test-source-aliascheck-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/check:type-source-resolutioncheck-type-source-resolution OK — 77 packages with a tsconfig.json scannedcheck-adr-0087-registration.mjs✓ … this PR adds no declared-breaking changeset (0 non-breaking changeset(s) seen).check-changeset-no-major.mjs✓ This diff introduces no major bump.check-ci-filter-parity.mjsOK: all 89 declared cross-package glob(s) (77 unique) are covered …check-empty-changeset.mjs✓ No empty-frontmatter changeset introduced by this diffcheck-plugin-teardown-shape.mjs✓ … 63 Plugin implementation(s) across 4566 source(s) … baseline fully burned downdocs-audit/check-affected-docs.mjs✓ affected-docs self-test: 366 cases pass.check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none newcheck:type-check-coveragecheck-type-check-coverage: OK — 65/78 workspace packages type-checked (plus the root)check:engine-double-contractOK self-test: …(whole-tree run, no new double introduced)check:where-matcher✓ where-matcher conformance holds: 290 matcher(s) discovered, 290 answer the combinator battery correctly or refuse it loudlycheck:nul-bytes✓ check-nul-bytes --self-test: 75 assertions over a temp git repo (real scan() path)check:type-check-debt --re-measurefirst refused —--re-measure cannot run: 27 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk. Refused is NOT MEASURED, so the closure was built exactly aslint.ymldoes(
turbo run build --filter='./packages/*' --filter='./packages/*/*'→ 70/70successful) and it was re-run to a real verdict:
That run was taken at
a81e8fa9with the changeset already staged on disk — the sametracked bytes as
71e04b89, whose only delta is that changeset file (git diff a81e8fa9 71e04b89 --stat→1 file changed, 56 insertions(+)), so the ledger readingis a reading of this head's tree. The run reports three pre-existing surplus
entries it explicitly calls "not an error", including
@objectstack/runtimeat −1;adding test code cannot lower a count, and two of the three (
plugin-approvals,plugin-auth) are untouched by this diff, so the surplus is pre-existing drift and isdeliberately left for its own card rather than ratcheted here.
Repo-wide lint: a declared, measured narrowing
pnpm lint(eslint . --no-inline-config) is CI's run. Locally it was narrowed tothe changed files, and the narrowing is measured rather than asserted:
changed paths; eslint itself declared the changeset markdown out of population
(
File ignored because no matching configuration was supplied.) and the twoTypeScript files in.
--format json— 3 entries returned, 2 linted, 0 errorsand 0 rule warnings between them; process exit
0.eslint.config.mjs, which "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file, test ornot" (its own header, measured there with a positive control). With no cross-file
type information in play, this diff cannot move the verdict on any file it does not
contain.
All three hold, so the narrowing is a measurement rather than a skip. CI runs the full
farm regardless.
One thing deliberately not overstated
The card's "Reachability caveat" was never measured and is not restated here as
though it were: whether real callers reach this transport with plural spellings is
unknown, and the SDK item routes (#10078) are a plausible, unverified producer. It is
a grading input, and it did not narrow the fix — the class is closed on both transports
either way.
Deliberately out of scope
The
/publishedlayered consult threads the active organization unconditionally,without passing through
organizationIdForMetaWriteat all. That is a differentquestion from this card's raw-vs-folded disagreement (there is no fold/no-fold
divergence there to repair), it is a read path, and touching it would be a behaviour
change beyond the declared file surface. Recorded here, not changed.
Generated by Claude Code