Skip to content

fix(runtime): the dispatcher /metadata transport folds the URL segment before the org-scope decision - #11553

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-10503-dispatcher-org-scope-canonical
Aug 24, 2026
Merged

fix(runtime): the dispatcher /metadata transport folds the URL segment before the org-scope decision#11553
os-zhuang merged 2 commits into
mainfrom
claude/issue-10503-dispatcher-org-scope-canonical

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#10503

needs:contract-review rides this PR. Clause-②: yes — this changes which raw
spellings 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.saveMetaItem folds the path segment
through canonicalizeMetaRequestTypeMETA_URL_TO_SINGULAR, the complete
spelling map, for storage. packages/runtime/src/domains/meta.ts handed the same
string raw to organizationIdForMetaWrite, whose declaresOrgOverride tolerates
only the manifest-collection spellings — incomplete by design.

For the two URL-only spellings of allowOrgOverride: true types the two answers
diverged. translation has no manifest collection key at all; email_template's is
the camelCase emailTemplates, so the snake_case plural the registry derivation adds
is URL-only too:

PUT /metadata/translation/:name → org-scoped row (correct)
PUT /metadata/translations/:name → env-wide row (the defect)
PUT /metadata/email_template/:name → org-scoped row (correct)
PUT /metadata/email_templates/:name → env-wide row (the defect)

Storage folded both spellings to the same canonical type, so the rows differed in
organization_id alone: one item in two partitions, addressed by spelling. Measured
end to end through the real dispatcher, protocol and repository — writing an item
under both spellings left twosys_metadata rows where there should be one
(expected [ { id: 'r_1', …(10) }, …(1) ] to have a length of 1 but got 2), and the
env-wide one is shadowed by every read the org-active author makes.

GET /metadata/:type/:name/published is 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.ts hours before this branch was
cut, so both anchors were found by shape, not by line number. Branched from
origin/main at daacc107 (newer than the d2b2381a the claim comment names). At
that 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:

siteshapeline at daacc107
PUT /metadata/:type/:name scope decisionorganizationIdForMetaWrite(type, activeOrganizationId) with type = parts[0]meta.ts:416
/published code-store fallbackgetPublished(type, name) after the layered consult missesmeta.ts:305 (and its metaSvc twin at :313)

The fix

Fold the segment through canonicalMetaUrlTypeat the boundary, before the scope
decision
— the correction #10340 landed for REST, and the one
packages/spec/src/meta-spelling/metadata-url-spelling.ts mandates ("folding happens
at 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.ts
documents and pins that limit in its ORG_OVERRIDABLE_TYPES header; neither it nor
meta-write-org-scope.test.ts is touched here.

Only the scope argument is folded. The request type stays 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 the
store verbatim: the fold is a lookup, never a spelling guesser. Both are pinned.

Pins assert the STORED SCOPE, not a 200

New #10503 block in packages/runtime/src/meta-write-org-scope.test.ts, mirroring
packages/rest/src/rest-server-meta-org-scope-url-spelling.test.ts case for case
where 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 ObjectStackProtocolImplementation and the real
SysMetadataRepository — each with a schema-valid body, because a minimal one 422s
before 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. /published pins that both spellings return the same status and body and
that the store was asked for the canonical key under both.

The class-closing sweep over every key of META_URL_TO_SINGULAR is necessarily
argument-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 before
running
: 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:

leg 1 (mutated): 'canonicalMetaUrlType(type)' occurrences : 0 (expect 0)
'ABLATED_10503' occurrences : 3 (expect 3)
leg 2 (restored): 'canonicalMetaUrlType(type)' occurrences : 3 (expect 3)
'ABLATED_10503' occurrences : 0 (expect 0)
git status --porcelain: empty → tree matches the committed fix

The script carried trap '…git checkout <branch> -- …' EXIT INT TERM, so a killed run
could 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), and packages/runtime/distdid not exist at ablation time
(ls: cannot access 'packages/runtime/dist': No such file or directory, printed by the
ablation script itself). There is no artifact between the edit and the run, so no leg
could read a stale one.

Measured red limbs:

× translations lands ORG-SCOPED → expected null to be 'org_alpha'
× translation: ONE partition → expected […] to have a length of 1 but got 2
× email_templates lands ORG-SCOPED → expected null to be 'org_alpha'
× email_template: ONE partition → expected […] to have a length of 1 but got 2
× every spelling in the URL contract → PUT /metadata/translations scope disagreed
with its fold 'translation': expected
undefined to be 'org_alpha'
× raw segment as the request type → expected undefined to be 'org_alpha'
× /translations/…/published → expected 404 to be 200
× /email_templates/…/published → expected 404 to be 200

Green throughout both legs, as predicted: all 8 #7018 cases, both no-active-org
controls, the objects over-folding guard (a allowOrgOverride: false type must stay
env-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), except
the one --re-measure run noted as byte-identical.

Package suites:

pnpm --filter @objectstack/runtime test -- --maxWorkers=2
→ Test Files 186 passed (186) · Tests 2744 passed (2744)
pnpm --filter @objectstack/runtime typecheck
→ tsc --noEmit, VERDICT command-exit 0
pnpm --filter '@objectstack/runtime^...' build (first command in the worktree)
→ VERDICT command-exit 0

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 by
its own verdict line, never a bare $?:

gateits own verdict
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 passed
check: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-wire
check: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 scanned
check-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 diff
check-plugin-teardown-shape.mjs✓ … 63 Plugin implementation(s) across 4566 source(s) … baseline fully burned down
docs-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 new
check: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 loudly
check: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 as lint.yml does
(turbo run build --filter='./packages/*' --filter='./packages/*/*' → 70/70
successful) and it was re-run to a real verdict:

check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 343.2s,
1897 raw tsc error(s) total, none above its recorded number.

That run was taken at a81e8fa9 with the changeset already staged on disk — the same
tracked bytes as 71e04b89, whose only delta is that changeset file (git diff a81e8fa9 71e04b89 --stat1 file changed, 56 insertions(+)), so the ledger reading
is a reading of this head's tree. The run reports three pre-existing surplus
entries it explicitly calls "not an error", including @objectstack/runtime at −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 is
deliberately 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 to
the changed files, and the narrowing is measured rather than asserted
:

  1. Population read from eslint's own config, not guessed — invoked on all three
    changed paths; eslint itself declared the changeset markdown out of population
    (File ignored because no matching configuration was supplied.) and the two
    TypeScript files in.
  2. File count read from --format json — 3 entries returned, 2 linted, 0 errors
    and 0 rule warnings between them; process exit 0.
  3. Config invariance over untouched files — this repo runs one
    eslint.config.mjs, which "never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or
    not" (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 /published layered consult threads the active organization unconditionally,
without passing through organizationIdForMetaWrite at all. That is a different
question 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

…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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c179dd7832dac8a32192435df849cc4eec218699packageMentionDocs.

Which tree this was computed on

This run read content/docs from 300665170c7dd054fe1f73f3f7c2c34deb1e6c1a — the merge of head 71e04b898a044536fc4de572cb9e3d291d791baf into base c179dd7832dac8a32192435df849cc4eec218699, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The dispatcher /metadata transport decides org scope from the RAW path segment — the #10340 defect on the second transport

2 participants

@os-zhuang@claude