fix(metadata-protocol)!: the ADR-0010 _lock gate refuses on a host-config kernel too, and the diagnostics locked count reads the item envelope derivation (#21694) - #21715
Conversation
…logy, and the diagnostics locked count reads the envelope's derivation Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…e diagnostics count agreeing on both topologies Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…fault past the lock gate; add the changeset Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…etired _lock bypass Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…ck-door-read-agree
…le ledger Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…ck-door-read-agree
📓 Docs Drift CheckThis PR changes 1 package(s): 6 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 540569d3a37990b588cb17efff34b4ebb9306013 && git checkout 540569d3a37990b588cb17efff34b4ebb9306013
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d91f583dfa7a0655ff0f5f00564b09a0c481d55 4265c6bc044b05ae8be1c669f3458da30c01df42 && git checkout -B drift-repro 9d91f583dfa7a0655ff0f5f00564b09a0c481d55 && git merge --no-ff 4265c6bc044b05ae8be1c669f3458da30c01df42
node scripts/docs-audit/affected-docs.mjs --json 9d91f583dfa7a0655ff0f5f00564b09a0c481d55
|
ACCEPT — PR #21715 at head
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37196374827 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #21694
Clause-②: no (narrowing)
Arm 1a: no recorded reason exempts a host-config kernel
I measured H1 before writing any code.
if (this.environmentId === undefined) return null;opened bothlockWriteRefusalandassertLockAllowsDelete. It arrived in 8eca8f3 (2026-05-29, "Update files", then inpackages/objectql/src/protocol.ts), and neither docblock gave a reason. The only rationale ever written is the title of the test that pinned it: "environmentId=undefined bypasses L3 (control-plane bootstrap)" (packages/objectql/src/protocol-lock-enforcement.test.ts). 2796a1f (fix(objectql): artifact lock envelope survives overlay hydration and reset (ADR-0010 §3.3) #1775) later called the bypass "intentional" in the registry-shadow suite's docblock, again without a reason._lockenforcement in save, publish, delete and rollback. The "single-process dev / bootstrap" sentence in §3.8 is about theOS_METADATA_WRITABLEtype hatch, which this gate never read.MetadataAuthoringChanneldocblock and the comment inassertRuntimeAuthoringRulesrecord three facts.environmentIdis a row-scoping key. The CLI's host-config assembler (serve.ts,new ObjectQLPlugin()with no options) leaves it undefined while it serves end-userPUT /api/v1/meta/*. That assembler is the showcase's own boot shape. #4463 运行时发布门在 host-config 拓扑(environmentId 未绑定)上整体不跑——#5086 只把 code-only 拒绝移出了同一条短路 #6710 re-keyed the ADR-0005 carve-out to a declaredauthoringChannel, and metadata: allowRuntimeCreate:false is not enforced — PUT /meta creates job and agent items the registry declares code-only #5086, owd-save-gate: the ADR-0090 D11 authoring gate never runs on any host-config deployment (keyed on environmentId, not authoringChannel) #7674 and The threekernel:readymigrations inassembleMetadataProtocolnever arm on a self-hosted boot — the standalone stack stampsenvironmentId = 'proj_local', and the gate asks forundefined#9380 retired the same proxy elsewhere.getEffectiveLockhas no topology term. Its artifact limb reads the registry, and its overlay limb readssys_metadatathrough the engine. It does resolve a lock on a host-config kernel: pin 1 is refused there with bothsource=overlayandsource=artifact.So the gate refuses on every topology, as
packagedBaseRefusaldoes. I kept no exemption for the declaredpackage-authorchannel either. No ADR or comment records one for L3, and no assembly in this repository declares that channel (git grepfinds 0 non-test declarations). H3 (arm 1b) is not taken.What changed
Only
packages/metadata-protocol/src/protocol.tschanges at runtime.lockWriteRefusalandassertLockAllowsDeletelose the short-circuit.publishMetaItem(throughpromoteDraftForPublish) androllbackMetaItemalready call them unconditionally, so those two verbs now refuse on a host-config kernel too.if (this.environmentId !== undefined).saveMetaItemanddeleteMetaItemasked the gate inside that block, behind their package doors, so removing the short-circuit alone would not reach them. The gate moves out of the block, and it keeps its rank on both kernels: it runs only whenpackagedBaseRefusaladmits the write.SysMetadataRepository.assertAllowed, at the write), so a packaged base it refuses keeps that answer._lockwould answerITEM_LOCKEDon a host-config kernel andNOT_OVERRIDABLEon an environment kernel. That would be a new topology split in the refusal code. Ablation 2 below pins it.getMetaDiagnostics().stats[type].lockednow counts items whoseservedLockState(...)reports a lock other than'none'. That is the same derivationgetMetaItemandgetMetaItemLayeredpublish. The field's docblock says so.No
packages/spec/src/**file is touched, so no contract review is owed on that ground. No governed surface is touched.Reach: correcting the card's "no shipped producer"
Triage graded p3 on the premise that "every
protection.lockinplatform-objectsis onobject". Three apps carry one too:setup,studioandaccount(packages/platform-objects/src/apps/*.app.ts,protection.lock: 'full').apphassupportsOverlay: true, so the #6960 carve-out lets a removal past the package door, and only the_lockgate stood behind it.ObjectStackProtocolImplementationover an in-memory double, without anenvironmentId. Deleting a packaged app that declares_lock: 'full'answered success. An environment kernel answered403 ITEM_LOCKED.pnpm dev -- --fresh -p 38694) and signed in as the seeded admin.DELETE /api/v1/meta/object/sys_organizationanswered200("No customization overlay found"), where an environment kernel answers403 NOT_OVERRIDABLE, so the server is host-config.GET /api/v1/meta/app/setupanswerslock: full,editable: false,deletable: false.DELETE /api/v1/meta/app/setupanswers403 ITEM_LOCKED("app/setup is locked (_lock=full, source=artifact)").The grade is the seat's call. This corrects the premise behind it.
What moves (H2)
ITEM_LOCKED, no "is locked (_lock=" and no "metadata store could not be read" (0 hits in 72 lines). Nothing the boot writes is refused.examples/**. No example declares_lockorprotection:(git grep: 0 hits).os migrate meta --stored --apply(migrateStoredMetadata): a row whose effective lock refuses the write is reported failed instead of rewritten.deletePackageanddiscardPackageDrafts: a locked item becomes afailed[]row.duplicatePackage./automationdoors, which write throughsaveMetaItemanddeleteMetaItem._locked item. Its saves, publishes, rollbacks and deletes are refused now, as on an environment kernel. A stored row that declaresfullcan no longer be written or removed through/metaon any kernel. The ADR-0010 §3.8 override path is not implemented, and that is unchanged here.sys_metadataread now runs first. A failed read is answered503 SERVICE_UNAVAILABLE, with the driver error oncause(getEffectiveLock 的 overlay 读用裸 catch,sys_metadata 读失败时保护闸门 fail-open(_lock 落成 'none',写/删被放行) #5706), before anything is written. A delete used to reach the store and answer with the driver's code or a500.package-authorand writes_locked items throughsaveMetaItem, it now meets the gate.metadata-protocol/protocol.delete-rewrap-envelope.test.ts(4 cases). The fault was injected into the firstsys_metadataread, which is now the gate's own read and fails closed with a 503. The fault now arms once the lock verdict is in, so it lands on the probe read this file pins.objectql/protocol-lock-enforcement.test.ts(1 case). It pinned the bypass itself. It now pinsITEM_LOCKEDon save and delete, and asserts noupdateordeletereached the engine.objectql/plugin.authoring-channel.test.ts(1 case). The barenew ObjectQLPlugin()kernel had no driver, so the gate's read failed closed with a 503 before the authoring gate could answer. It now registers a memory driver, asserve.tsdoes, and also asserts that nothing is stored.objectql/protocol-meta.test.ts(1 case). "Fail fast when findOne is unavailable" now asserts the gate's503 SERVICE_UNAVAILABLE, withcausebeing the driver error. The test title loses its "500".objectql/protocol-publish-package-drafts.test.ts(3 cases). The double stubbedassertLockAllowsWrite, but since The lock/conflict denial audit rows roll back with the batch on publishPackageDrafts — a refused item in a package publish still leaves no trail #8594 the publish path askslockWriteRefusal. The stub moves tolockWriteRefusal.objectql/protocol-registry-shadow.test.ts(4 cases). The suite wrote its overlay row through aPUTthat the bypass admitted. The row is now written before the package's lock arrives, which is the pre-dating overlay that the envelope graft exists for. The two removal cases useno-overlay, becausefullnow refuses removal on every kernel, and the first case also asserts that a furtherPUTis refused.The count's cost (H4)
servedLockStatemakes no store read. It isresolveLockState(pure), plus twopackagedBaseRefusalcalls (registry lookups, which build anErroronly for a refused item), plusisArtifactBacked(registry). The list items already carry the merged artifact protection (mergeArtifactProtectioningetMetaItems), which is the same document shape the item read derives from. So the sweep stays bounded at one registry walk per item, and the store reads are unchanged.One authority (H5)
I grepped
packages/metadata-protocol/srcandpackages/rest/src(non-test) for_lockreaders.getEffectiveLockremains for the doors,servedLockStatefor the read and now for the count, andmergeArtifactProtectioncopies the envelope._lockcount was the only second predicate, and it is gone.rest. It has none.runtime. The/automationdoors askpackagedBaseRefusal, then write throughsaveMetaItemanddeleteMetaItem, so the_lockgate covers them.Pins
packages/metadata-protocol/src/protocol.lock-door-read-agree.test.tsruns the real doors and the real reads. Each refusal assertscodeandstatus(ADR-0112).viewrow is tried with each of the four_lockstates. Save and delete do whateditableanddeletablesay:ITEM_LOCKED/403 where the read says no, and admitted past the gate where it says yes. Admission is proved by spying on the gate (reached, answerednull).fullrow is refused.appdeclaringfullis refused on delete withITEM_LOCKED/403. Its save keepsNOT_OVERRIDABLE/403, and the gate is not reached.stats[type].lockedequals the number of items whosegetMetaItemenvelope reads locked, forflow,action,appandview.{flow: 1, action: 1, app: 2, view: 2}._lockwould give{0, 0, 1, 2}. The flow, action and second app are locked by the package door alone.fullrow is refusedITEM_LOCKEDon save and delete, and ano-deleterow passes the gate on save and is refused on delete. A packaged app keepsNOT_OVERRIDABLEon save andITEM_LOCKEDon delete, the same codes the host-config kernel now gives.Reverse verification
Both ablations ran from the committed fix (HEAD 2ec0a2c), through
scripts/ablation-replace.mjs, inside a script with anEXIT INT TERMtrap that restores fromHEAD. The pin file imports./protocol.js(source), so no rebuild is in the path. The predicted direction was declared before running._lockcount._lock=nonecontrol stayed green. Both pin 2 cases went red. All 3 pin 3 cases stayed green. Total: 7 failed, 4 passed (11).true ||beforepackagedBaseRefusal).ITEM_LOCKEDinstead ofNOT_OVERRIDABLE. Pin 3's environment twin stayed green. Total: 1 failed, 10 passed.Restore. After each ablation the blob equals the
HEADblob 185d1380a0d0,git diff HEADon the file is empty andgit status --porcelainis empty. The tool proved this on each leg, and so did the trap.Tests
On the merged head 4265c6b (after #21706):
@objectstack/metadata-protocol:vitest run, 211 files passed and 3 skipped, 3589 tests passed and 19 skipped.typecheck(tsc --noEmit) is green, and--listFilesshows it compiles 214 test files, including both test files touched here.@objectstack/objectql:vitest run --project local, 372 files and 7458 tests passed.typecheck(tsc, tsconfig.scripts,check:test-typecheck) is green.On 2ec0a2c (before the second merge of main, which touched neither package's lock path):
@objectstack/rest(--project local): 260 files, 4897 tests passed, 326 skipped.@objectstack/runtime(--project local): 321 files, 4563 tests passed, 19 skipped.@objectstack/plugin-security(the 7 files that call the write verbs): 150 tests passed.@objectstack/service-automation(2 files): 24 tests passed.These four were run because the order asked to measure which tests move. They are downstream consumers, and this is not a public-surface change.
Gates
Run on head 4265c6b, after the final commit. Each command's exit code was captured before any pipe.
node scripts/pm/dispatch-gates.mjs --commands(no paths) derives 74 families. All 74 exit 0, and the--ranreconciliation reads "74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN". These includecheck:adr-0087-registration(theno-migration-prescriptiondisposition is accepted),check:empty-changeset,check:changeset-no-major(its level axis is PR-scoped and reads this body in CI),check:engine-double-contract,check:nul-bytes,check:doc-authoring,check:cross-package-test-inputs,check:test-source-alias,check:published-filesandcheck:dts-closure.check:engine-double-contract. At first it asked for the new pin file'sfindOnedouble to be recorded.--writeadded one row toscripts/engine-double-contract.pinned.json, with "0 added or grown, 0 lost", and that row is committed here.check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-pathsexit 2 NOT WIRED, because they read a pull request (PR_NUMBER/PR_BODY). I ran the body gate locally against this body before opening the PR, and the other two run in CI on this PR.check:adr-symbol-anchors: 2167 anchors across 140 records resolve.check:scripts-symbol-anchors: 3760 anchors across 282 scripts resolve.check:spec-docblock-symbol-anchors: 4877 anchors across 1865 spec sources resolve.check:adr-anchors: green.NOT MEASURED locally, owned by CI:
pnpm lint.Acceptance notes
viewrow declares_lock: 'full'. An org-scoped read (getMetaItemwithorganizationId: 'org_a') serves that row and reportslock: full,editable: false. The_lockgate for anorg_asave admits, becausegetEffectiveLock's overlay limb looks uporganization_id = org_aonly. Measured on both kernels with the protocol over a double: the save went on to validation. This is the door and the read disagreeing on the org axis, and ADR-0010 §3.3 rejects overlay writes underfull. The report names it for the seat.deletable: false. That is the package door [finding] The layered metadata read reports lock none, editable true and deletable true for packaged flows and actions that the write doors refuse with NOT_OVERRIDABLE #21670 accepted, not the_lockgate, which ranks below it._locknow answersITEM_LOCKED, where an environment kernel answersNOT_CREATABLE. The host-config protocol has noNOT_CREATABLEdoor. No producer writes such a row.lockSourcevocabularies. The door's sentence names the limb (source=artifactorsource=overlay), while the envelope carries the declaredMetadataLockSource(packagefor the setup app). This is pre-existing._lock, so it stays valid. The_locklimb on that kernel is covered by pin 1 here.Generated by Claude Code