Skip to content

fix(metadata-protocol): fold the ADR-0010 lock gate's type key at its producer - #9161

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-9009-geteffectivelock-raw-type
Aug 16, 2026
Merged

fix(metadata-protocol): fold the ADR-0010 lock gate's type key at its producer#9161
os-zhuang merged 2 commits into
mainfrom
claude/issue-9009-geteffectivelock-raw-type

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#9009

Route 1 of the ruling — file the fold, at the producer. getEffectiveLock now folds its own type key once with canonicalMetaType and uses that one key for both limbs.

The seam

getEffectiveLock handed type to two limbs that did not read it the same way:

limbkey it usedconsequence
artifactlookupArtifactItemPLURAL_TO_SINGULAR[type] ?? type, then the raw spelling as a second lookupfolded — a packaged _lock resolved under a plural all along
overlayengine.findOne('sys_metadata', { where: { type, … } })raw — and SysMetadataRepository.whereFor emits the canonical spelling with no at-rest fallback, so the stored active row lives under a type this query never asked for

A miss on that query falls through to lock: 'none', which is not a neutral placeholder: it is the verdict "the author declared no protection" (#5706), and evaluateLockForWrite / evaluateLockForDelete turn it straight into "allow". #8769 closed that for publishMetaItem and #8819 for rollbackMetaItem, each by folding its own request — and #8819's comment said so, then promised a follow-up card for the producer that was never filed. This is that fold.

Reachability, measured before the fix was written

The card's own framing said reachability was not measured. It is now, and it holds — this is a Task, not a Bug. getEffectiveLock has exactly two callers, and the whole call graph above them is five sites, every one of which folds first:

getEffectiveLock
├─ lockWriteRefusal
│ ├─ assertLockAllowsWrite
│ │ ├─ saveMetaItem canonicalizeMetaRequestType (protocol.ts:12073)
│ │ └─ rollbackMetaItem canonicalizeMetaRequestType (protocol.ts:16346)
│ └─ promoteDraftForPublish
│ ├─ publishMetaItem canonicalizeMetaRequestType (protocol.ts:13496)
│ └─ publishPackageDrafts canonicalMetaType(d.type) (protocol.ts:14468)
└─ assertLockAllowsDelete
└─ deleteMetaItem canonicalizeMetaRequestType (protocol.ts:16760)

So canonical === type at every live site, both limbs already agreed, and no behaviour on any reachable path changes. What the fold removes is the unstated "callers must fold" invariant that lived in no type, no signature and no assertion — the shape that has now cost this family several cards.

The limbs can disagree, which is why this is worth closing: hand the gate views and the artifact limb finds the packaged lock while the overlay limb asks for a type no row carries.

Two deliberate non-changes

#9111 is NOT absorbed

Measured, because the dispatch asked for it plainly either way: hydrateOverlayIntoRegistry is not on getEffectiveLock's call graph in either direction — it is reached from applyRegistryWriteThrough and the read-side hydration paths, and it mints registry entries rather than reading a lock. This fold does not touch it. #9111 still needs its own card.

Tests

New: packages/metadata-protocol/src/protocol.lock-gate-canonical-type.test.ts (9 cases). They address the gate directly rather than through a route, and the file's header says why: every wire path folds at the boundary, and a plural is refused there by metaUrlSpellingRefusal (400) long before it reaches the gate — so driving this through a route would measure the boundary fold a second time and say nothing about the producer.

Reverse verification, direction predicted before running. Ordinary red. The fix was committed first, then ablated (both raw type reads restored), then restored with git checkout claude/issue-9009-geteffectivelock-raw-type -- packages/metadata-protocol/src/protocol.ts.

Predicted 5 red / 4 green; measured 5 red / 4 green, each red for its predicted reason rather than merely in the predicted count:

 ❯ src/protocol.lock-gate-canonical-type.test.ts (9 tests | 5 failed) 30ms
× finds the lock when handed a manifest-PRESENT plural (`views`) 13ms
× addresses `sys_metadata` by the canonical key, not the caller spelling 7ms
× finds the lock for a manifest-ABSENT plural (`translations`) too 1ms
× answers the ADR-0112 `ITEM_LOCKED` envelope for a plural-addressed write 1ms
× the delete gate now fails LOUDLY on an unfolded caller, not silently open 1ms
AssertionError: expected 'none' to be 'full' // Object.is equality
AssertionError: expected { type: 'views', name: 'v1', …(2) } to match object { type: 'view', name: 'v1', …(2) }
AssertionError: expected 'none' to be 'full' // Object.is equality
AssertionError: expected null not to be null
AssertionError: expected a rejection, but the call resolved with null

The four greens under the ablation are green for reasons, not by vacuum: the canonical spelling is unchanged, a genuine miss still answers none (so "fail closed" is not satisfiable by locking everything), a canonical locked delete is refused exactly as before, and the artifact limb answers the same in both directions — it already folded, so it was never the hole, and a fix that moved it would be moving something already right.

Green runs, all at fd564e494 (the head this PR is opened on; the union was run after the final commit):

  • pnpm --filter '@objectstack/metadata-protocol' testTest Files 113 passed (113) · Tests 1576 passed (1576)
  • Downstream sweep, prefix filter = consumers: metadata-protocol rebuilt to dist/, then packages/objectql's four protocol suites that resolve it through that distTest Files 4 passed (4) · Tests 49 passed (49) (protocol-publish-canonical-fold, protocol-registry-shadow, protocol-delete-object-registry-heal, protocol-publish-package-drafts).
  • @objectstack/metadata-protocol has no typecheck script — it is one of the 13 packages in the type-check DEBT ledger, and check:type-check-coverage is green over that state.
  • Gate union, re-derived against the actual changed paths with scripts/pm/dispatch-gates.mjs, all OK at fd564e494: check:nul-bytes, check:where-matcher, check:engine-double-contract, check:query-options-erasure, check:cross-package-test-inputs, check:type-check-coverage, check:changeset-gate-self-tests, check:durability-log-level, check:filter-alias-parity, check:objectui-changeset.

Hot-file coordination

protocol.ts also carries #9066 this round. Changed line ranges, both taken from the real diffs rather than from a symbol's address:

No overlap.

Out of scope, filed

#9157historyMetaItem, auditMetaItem and findReferencesToMeta are three live /meta/:type/:name routes that never reach canonicalizeMetaRequestType; they still derive their key from the manifest map. Found while measuring this card's reachability. None of the three reaches getEffectiveLock, so it does not affect anything here — but it does falsify, as literally stated, the assumption that every /meta entry point folds. #9157 is not addressed here. #9111 remains open.

Backlinks: #8862 · #8819 · #8769 · #8908 · #5706.


Generated by Claude Code

…cer (#9009)
`getEffectiveLock` handed `type` to its two limbs verbatim and they did not
read it the same way: the artifact limb folded, the overlay limb queried
`sys_metadata` with the raw spelling. A miss there falls through to
`lock: 'none'` — the verdict "the author declared no protection" — so an
ADR-0010 `_lock` was addressable around from the wire.
Fold once with `canonicalMetaType` and use that key for both limbs. No
reachable caller changes behaviour; a future unfolded one is now refused
instead of admitted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

Docs-drift advisory — read independently, not inherited

Read all three pages myself at origin/main (verified the shared checkout's copies are byte-identical to origin/main for each, so the reads are on the right ref). Probes were the ones this change needs — lock / _lock / ADR-0010 / item_locked / readonly / canonical / singular / plural / spelling / refus / metaUrlSpellingRefusal / meta/(views|objects|fields|translations) / history / audit / references — not the revert / rollback / commitId set.

Result: no drift from this PR, and nothing to attach to #9157. The hypothesis survives an attempt to falsify it.

  • content/docs/protocol/kernel/http-protocol.mdx — zero hits for _lock, ADR-0010, ITEM_LOCKED, metaUrlSpellingRefusal, or the lock gate in any spelling. The Lock at line 6 is a lucide-react icon import; the one "readonly": true (line 935) is a field attribute inside a response example, not metadata protection.
  • The one spelling statement on the page, lines 877-879: "The metadata API is keyed by metadata type … Types are singular (object, view, app, …), so objects are listed at /api/v1/meta/object." It is scoped to GET /api/v1/meta/{type} and it is true of that route both before and after this PRgetMetaItems folds, so GET /meta/views is refused 400 by metaUrlSpellingRefusal. Unchanged by me either way.
  • It is not a cross-verb rule, which is the question you actually asked. The page documents exactly two metadata routes — GET /meta/{type} and GET /meta/{type}/{name}. /history, /audit, /references, /publish, /rollback, /diff and /published do not appear on it at all. So the page cannot be false about Three read-side /meta verbs (history, audit, references) never reach canonicalizeMetaRequestType — they still derive their key from the MANIFEST map #9157's territory: it never makes a claim covering the diverging verbs. (The history grep hits are the sys_metadata_historytable and a trackHistory field flag, not the route.)
  • content/docs/concepts/metadata-lifecycle.mdx — overlay precedence, the two-tier allowOrgOverride / allowRuntimeCreate gate, and the 409 METADATA_CONFLICT path. No lock gate, no type-spelling claim, no per-verb route contract.
  • content/docs/kernel/services-checklist.mdx — two hits, both unrelated: a retired GetViewResponseSchema block and the word "singular" describing the notification slot name.

No page edited; content/docs/releases/v9.mdx not touched or read for edit. Nothing here changes the PR's head — still fd564e494.


Generated by Claude Code


Generated by Claude Code

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.

getEffectiveLock's overlay limb still queries the raw type — the follow-up card protocol.ts promises does not exist

2 participants

@os-zhuang@claude