fix(metadata-protocol): one item-lock resolution for the _lock gate, both reads and the served body (#21738) - #21759
Conversation
…both reads and the served body (#21738) getMetaItem, getMetaItemLayered, the getMetaDiagnostics locked count and mergeArtifactProtection now take the item's ADR-0010 lock from resolveItemLock (new item-lock.ts), the rule getEffectiveLock already enforced: the first layer in ITEM_LOCK_LAYERS order (artifact, overlay) whose declared _lock is not 'none' binds. The door reads its layers through resolveItemLockLazily, so it still reads the store only when the artifact does not bind. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…from the item-lock resolution's layers (#21738) protocol.lock-one-resolution.test.ts: 1632 generated rows (artifact x stored row x request scope x topology x request spelling x operation), a completeness check keyed by ITEM_LOCK_LAYERS, PR #21737's 64 rows checked as a subset (moved out of protocol.lock-org-axis-agree.test.ts), and positions 1 and 2 as named cases. The #5840 outage pin's healthy leg now declares its locked artifact in the registry, the layer the gate reads. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…ution (#21738) Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…ine-double ledger (check:engine-double-contract --write) (#21738) Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 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 d69cfde9b9560e3f49783f102469425467e3d196 && git checkout d69cfde9b9560e3f49783f102469425467e3d196
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c7a60e1c3fba0f429fa9fcb0d440da1751bdd011 60232c3d179a1892b82daae80bb6011e0e78c8d9 && git checkout -B drift-repro c7a60e1c3fba0f429fa9fcb0d440da1751bdd011 && git merge --no-ff 60232c3d179a1892b82daae80bb6011e0e78c8d9
node scripts/docs-audit/affected-docs.mjs --json c7a60e1c3fba0f429fa9fcb0d440da1751bdd011
|
ACCEPT — PR #21759 at head
|
Fixes #21738
Clause-②: no
What was wrong
The ADR-0010
_lockgate of the write doors (getEffectiveLock, behind save, publish, rollback and delete) resolves an item's lock from two layers: the packaged artifact's_lockunless it is'none', then the storedsys_metadatarow's. The two item reads did not use that resolution. Each derived the lock its own way, and neither matched the door on the artifact layer:mergeArtifactProtectioncopied any declared artifact_lockover the stored row's,'none'included. A packaged view whose artifact declares'none', under a stored row declaring'full', readeditable: trueon both reads while the door refused the save withITEM_LOCKED.getMetaItemLayered(GET /api/v1/meta/:type/:name/layers) read the lock offcode ?? overlay. A packaged view with no_lockunder a stored'full'readlock: 'none'there, whilegetMetaItemand the door said locked.Triage's direction 5980131521 (verbatim in the dispatch): one resolution, three callers. The door's semantics stand, and the reads follow the door.
H1: every place an item's lock was derived, at
ff29410ed2getEffectiveLock_lockgate (save, publish, rollback, delete, package publish's promotion)lookupArtifactItem(canonical type, name), no packagefindServedOverlayRow(canonical spelling, no package, org-gated)'none', else row, else'none'mergeArtifactProtectiongetMetaItem,getMetaItemslist items, registry hydration, runtime view-container expansion_lock_lockwhenever defined ('none'too), else the item'sgetMetaItemenvelopeservedLockState(document)_lockgetMetaItemLayeredenvelopeservedLockState(code ?? overlay ?? {})_lockgetMetaDiagnosticslockedcountservedLockState(list item)_lockservedLockStatepackagedBaseRefusalpackages/rest/srcandpackages/runtime/srcderive no lock: both pass the protocol's envelope through (git grepofresolveLockState,extractProtection,_lockand the envelope flags). The one out-of-package consumer of a read's lock-adjacent fields,plugin-security's packaged permission-set gate, readsgetMetaItemLayered'scode._packageId, not its lock.What changed
Landing spot:
packages/metadata-protocol/src/protocol.ts, as the claim predicted, plus a new module beside it. Nopackages/spec/src/**path is touched.item-lock.ts(new, not exported from the package entry).ITEM_LOCK_LAYERS = ['artifact', 'overlay']is the precedence and the parameter set.resolveItemLock(layers)returns the first layer whose declared_lockis not'none', with that layer's prose, else'none'with no prose.resolveItemLockLazily(readers)asks the same function after each layer is read and stops at the first binding layer.getEffectiveLock) reads its two layers throughresolveItemLockLazily, so the store read still happens only when the artifact does not bind. Its overlay read moved, unchanged, intoreadLockGateOverlayLayer. Refusal code, status, text andsource=are byte-identical.getMetaItemhands the resolver the artifact it already looked up and the stored body of the rowfindServedOverlayRowalready served. It keeps that body even when the row is not adopted (a shipped flow name, metadata: the by-name flow read serves a stored row's body under the shipping package's provenance for a shipped flow name, so it disagrees with the flow list, which serves the loader's body #20946), because the gate binds it. No extra store read.getMetaItemLayeredhands itlookupArtifactItem(a registry read) and the served row's stored body.code ?? overlay ?? {}is no longer a lock source. It still feedsprovenance/packageId/packageVersion, unchanged. A row-less name that a stored container expands contributes no overlay layer, because the gate does not read the container's row.servedLockStatetakes the resolver's answer and deriveseditable/deletablefrom it, joined withpackagedBaseRefusalas before. It reads onlyprovenance/packageId/packageVersionoff the document. No third derivation.mergeArtifactProtectioncopies the artifact's_lock*family only whenresolveItemLocksays the artifact's lock binds. Its_packageId/_packageVersion/_provenancecopies are unchanged (H3).Measured: the door does not move, and the reads now follow it
The census used the real
ObjectStackProtocolImplementationover an engine double, on aview. It covered 216 cells: 2 topologies × 6 artifact states (absent, no_lock, each of the 4 levels) × 9 stored states (none, or an env-wide / org-scoped row at each level) × 2 request scopes. Each cell readgetMetaItem,getMetaItemLayered, the save door and the delete door.ff29410ed2code): 36 split cells, 18 per topology. 9 are position 1 (artifact'none'under a binding stored lock), and 9 are position 2 (artifact with no_lockunder one).getMetaItemchanged in 18 cells (position 1 only).getMetaItemLayeredchanged in 36 (positions 1 and 2).H3, every other field. A second probe ran at the merge base and at this branch on the same 216 cells. It dumped
getMetaItem's envelope and served body,getMetaItemLayered's envelope and itscode/overlay/effective/overlayScope/_diagnostics, and thegetMetaItemslist item. With the lock family masked (lock,lockReason,lockSource,lockDocsUrl,editable,deletableand the body's_lock*), all 216 dumps are byte-identical. The lock family differs in 54 cells:getMetaItemLayeredonly);'none'-artifact cells where nothing binds. There the explicit'none'artifact's prose is no longer reported on the envelope, and its_lock*fields are no longer copied onto a stored row's body.H4: residue census (position 3), and why the fallback stays
@objectstack/metadata-protocol@16.1.0: itsSysMetadataRepository.whereForkeyedtype: ref.typeraw. The fold (meta overlays: unnormalized type segment creates phantom rows that shadow the code-authored listing and cannot be deleted #4432, commit63b33e6a18) first shipped in17.0.0-rc.2. Ancestry was read in both directions on a full (non-shallow) clone:63b33e6a18is not an ancestor of the16.1.0tag (exit 1); the control16.0.0is (exit 0).MIGRATION_SUPPORT_FLOORis 16, so 16.x deployments are on a supported upgrade path.os migrate meta --stored(migrateStoredMetadata) rewrites bodies, never a stored type spelling. It reports the six URL-only plural spellingsskipped(migrateStoredMetadata reports a row stored under a non-canonicaltypeascanonical— the stored migration has no finish line for the second-namespace residue #8957), and it leaves a manifest-plural row at rest, re-saving a canonical row only when the body needs a conversion.findServedOverlayRow'sotherSpelling; answer A on PR fix(metadata-protocol)!: the ADR-0010 _lock gate reads the row the read serves for the request's organization (#21716) #21737). By the triage's own condition, no separate retirement card follows from this census.H5: the acceptance pin, generated from the resolver's inputs
protocol.lock-one-resolution.test.ts:view/views) × operation (save / delete). The axes are grouped underLAYER_AXES, keyed byItemLockLayer, so a layer added toITEM_LOCK_LAYERSfails the typecheck. The completeness check also fails the run, naming the layer, and it assertsresolveItemLock.length === 1and that every axis is used exactly once. Each row runs on one protocol instance. Both reads must agree, both must equal an oracle written from the rule (iteratingITEM_LOCK_LAYERS), and the door must admit exactly when the envelope sayseditable/deletable. Rows stored under the other spelling assert the declared difference instead (the reads see the residue row and the door does not), so they flip by name when the fallback retires.protocol.lock-org-axis-agree.test.ts; that file keeps its pins 2 to 4.full, witheditableanddeletablefalse andlockReasonfrom the stored row. Provenance is still the artifact's. The served body, the list item and the diagnostics tile sayfull. Save and delete are refusedITEM_LOCKED/ 403 withlock: 'full'.getMetaItemLayeredsaysfulllikegetMetaItem, itscodelayer is still the package's (no_lock), and the door refuses.The #5840 outage pin (
protocol.metadata-store-outage.test.ts) had a "healthy" leg. That leg's locked artifact existed only in the MetadataService double, never in the registry the gate reads. It now declares the artifact in the registry, package-stamped, as a loader does. Its outage leg is unchanged and still answers 503.Reverse verification
Both legs ran from committed HEAD
db49dc4d2fab(theprotocol.tsblob), withscripts/ablation-replace.mjsin wrap mode. They ran inside a script carrying an absolute-pathtraprestore fromHEAD. The subject resolves from source (./protocol.js), so no rebuild was in the path. Each direction was declared first.mergeArtifactProtectionrestored as a lock source. The copy became unconditional, andgetMetaItemtook its lock from the merged document again. Anchors 1 to 0 and markers 0 to 1, twice; blobdb49dc4d2fabbecamecb753a719d8b, thendecca0d87278. Predicted 146 red / 1494 green. Measured 146 failed / 1494 passed: pin 2 on both topologies, plus all 144 pin-1 rows with an explicit'none'artifact under a served stored lock. Pin 3 stayed green.code ?? overlay ?? {}restored. Anchor 1 to 0 and marker 0 to 1; blobdb49dc4d2fabbecamebdd63140c660. Predicted 292 red / 1348 green. Measured 292 failed / 1348 passed: pin 3 on both topologies, plus the 288 pin-1 rows whose artifact declares no_lockor'none'under a served stored lock. Pin 2 went red too, because its layered half was the same derivation.db49dc4d2fab,git diff HEADempty andgit status --porcelainempty, checked by the tool and by the trap.Tests (all at
60232c3d17, after mergingorigin/mainea7ff394b6)@objectstack/metadata-protocol:vitest run. 213 files passed, 3 skipped. 5249 tests passed, 19 skipped. That is base 3675, plus 1640 new, minus 66 moved.tsc --noEmitis green, and--listFilescompilesitem-lock.tsand the new pin.@objectstack/objectqlagainst the rebuiltmetadata-protocoldist:--project localgives 372 files, 7458 tests passed, and--project repogives 1 file, 5 tests passed. The dist was built fromae121eac22;protocol.tsanditem-lock.tsare unchanged since.eslint --no-inline-config --format jsonover the 5 changed TS files gives 5 file results, 0 errors and 0 warnings. The population was read from eslint's own config:isPathIgnoredis false for all 5, and each computed config has 5 or 6 rules. Invariance: no computed config setsparserOptions.projectorprojectService, andeslint.config.mjsnever enables type-aware linting, so this diff cannot move a verdict on an untouched file.rest/runtime/plugin-security/plugin-email/clientsuites, which are consumers with no export or wire-shape change (their lock-field assertions were grepped and use fakes); CI-owned Dogfood, Temporal Conformance, type-check lanes and the fullpnpm lint.Gates (at
60232c3d17; exit codes captured before any pipe)node scripts/pm/dispatch-gates.mjs --commands(no paths) derived 72 families: 72 run, all exit 0.--ranreports "72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED".check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET) and exited 0 after a full turbo build.check-closing-target-claim,check-partof-closing-keyword,check-single-claim-paths) exited 2, NOT WIRED, before this PR existed; they are re-run with this PR's context in the report.adr-symbol-anchors2167 anchors across 140 records;scripts-symbol-anchors3760 across 282 scripts;spec-docblock-symbol-anchors4950 across 1868 sources;adr-anchorsOK.check:engine-double-contractasked for the new pin'sfindOnedouble, and its own--writerecorded it: the ledger gained onefindOneentry for the new file, and lost none.check:durability-log-level: 70 read seams before and after. The--depth-costcensus is identical apart from line numbers and one enclosing function's name (getEffectiveLockbecamereadLockGateOverlayLayer).Acceptance notes (not filed here)
?package=, ADR-0048 prefer-local) serves that package's row, while the gate asks package-agnostic (findOnewith nopackage_id). The gate therefore binds whichever row the driver returns first. With a package-less row and a package A row of one(type, name, scope), 2 of the 3 arrangements probed (which row declares the lock, and which row the driver returns first) split read from door. This is row selection, not the resolution, and this PR leaves it unchanged; it is reported to the seat.Generated by Claude Code