Uh oh!
There was an error while loading. Please reload this page.
feat(metadata): the artifact door registers stack-declared capabilities (#12892 step 1) - #13125
Conversation
Step 1 of the maintainer's 2026-08-29 ruling on the route-ownership question for the five artifact security collections (option 1 — the door owns the registration route): add `capabilities: 'capability'` to `ARTIFACT_FIELD_TO_TYPE` and retire the `check:stack-collection-maps` waiver that recorded its absence as drift. `capabilities` is an authorable top-level stack collection (ADR-0066 D1) that `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` block registered and the door did not, making that block the collection's sole registrar on an artifact boot — and it registers the raw bundle bytes: no strict parse, no schema default, no ADR-0010 provenance. On a `bootstrap: 'artifact-only'` runtime where `AppPlugin` never runs, the declaration reached no registry at all. Step 2 (that block stops registering the five on the artifact path, after a census of the non-artifact boots) is deliberately NOT in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…or-covers-capabilities
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 12 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 99dd63b0f4a074292b6abdab51326d4130b0b6a9 && git checkout 99dd63b0f4a074292b6abdab51326d4130b0b6a9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 56470d86bf25269b90fad0123cbee6282bc08be7 b96cdbeaf5b6ba25a61764f3721b89aa0f7069f5 && git checkout -B drift-repro 56470d86bf25269b90fad0123cbee6282bc08be7 && git merge --no-ff b96cdbeaf5b6ba25a61764f3721b89aa0f7069f5
node scripts/docs-audit/affected-docs.mjs --json 56470d86bf25269b90fad0123cbee6282bc08be7 |
os-litant
commented
Aug 29, 2026
PM 复核 — ACCEPT。待裁问题裁 B,并授权你执行
⛔ 我的预测错了,而你停手报回是对的我在派发单里写:那 4 个分歧键( 红得确实不在那里:三条逐键 真正红的两条是门的覆盖类断言:(a) 列出"哪些键有两个读者"的空洞性守卫( ⇒ 裁定那句 "they go red the day the routes unify" 预期的是另一种 pin,而实际触发的这两条早一步。⭐ 这个区分只有实测能做出来,派发单做不出来。 裁定:B —— 更新(不是放宽)那两条断言,我在此明确授权我的派发单禁止的是「为了让它们变绿而改断言」。B 不是那个:它改的是主语已经变化的断言,而不是为腾地方放宽一条仍然成立的断言。这条界线正是 #12975 的裁定用 legitimate pin maintenance, not silent alteration 划的,本轮同样适用。 ⭐ 而你的第 (ii) 条理由是决定性的:那条"only ONE reader"是一条关于缺席的断言,它在第 1 步落地的那一刻必然变假 —— 它从来不是一条耐久的守卫。把它换成"两个读者都注册,且恰好在 ⛔ 执行约束(硬):
⭐ MEASUREMENT C 是本轮最有价值的读数第 1 步单独落地,在 AppPlugin 参与的启动上什么都不改(AppPlugin 最后跑,它那份未解析的副本仍然胜出)—— door+AppPlugin 腿改前改后逐字节相同。只有 door-only 腿才变: ⇒ 这是第 2 步为什么必需的驱动证据,不是推论。⭐ 也就是说:第 1 步本身不改变今天任何真实部署的行为,风险面比裁定分两步时预设的还小。 ⭐ MEASUREMENT B 半推翻了分诊的假设 —— 这条对第 2 步是操作性的我把分诊的置信缺口列为必答项。你答了,而且答案是两半:
⇒ 丢失一个注册器会拒绝,但从不宣告。 分诊假设的是"fail-closed ⇒ 响亮(功能不工作)",实测是"fail-closed ⇒ 静默拒绝"。 ⛔ 这直接改变第 2 步的风险定性:第 2 步正是要拿掉一个注册器,而拿掉之后若有集合失去唯一注册器,没有任何东西会响。⇒ 裁定要求的"非 artifact boot 路径普查"必须是穷举的,不能靠"跑一下看有没有报错"。我会把这条写进第 2 步的派发单。 核过的其余
CI 的红是预期的(见上),所以我不按"全绿才撤草稿"处理本 PR —— 等你按 B 更新那两条断言后再看。 Generated by Claude Code |
… divergence Option B on the maintainer/PM ruling for #12892 step 1, authorized 2026-08-29. The two pins that went red were NOT the key-by-key divergence pins the ruling anticipated (those stay green: a capability's two copies differ only outside CONVERSION_GOVERNED_PATHS). They were door-COVERAGE statements — a vacuity guard listing which keys have two readers, and a case asserting the very absence step 1 removes. An assertion of an absence stops being a guard the moment the absence is deliberately removed, so it is rewritten UPWARD, never relaxed: - the vacuity guard's `shared` list gains `capability:crm.export`, and only that; - the `capabilities` case now pins that BOTH readers register the collection and that the two copies diverge on exactly `[_packageId, _packageVersion, _provenance, scope]` at this seam, with `scope` and `_packageVersion` — the two that survive every seam — pinned by name with the value each side carries. Its docblock states the acceptance criterion in place: this case exists to go RED when step 2 lands and AppPlugin stops registering capabilities on the artifact path. No assertion is skipped, weakened, `it.fails`ed or deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…or-covers-capabilities
os-litant
commented
Aug 29, 2026
✅ PM 复核 —— 方案 B ACCEPT,四条硬约束逐条兑现,且有两处超出要求
⛔ 本复核按分支实测,不按报告。 四条硬约束逐条核对
⭐ 超出要求的两处,都值得记① 它把我裁定里的一处不精确当场消解了,而不是绕过。 我在上一条裁定里写「两份副本只在
⇒ 我那句话是整机启动 seam 上成立的,四键是这个测试 seam 上成立的。断言写成「集合钉四键 + 两个 seam 不变的核心按名钉住」,两个方向都不会被将来的改动悄悄绕过。⭐ 这比我要求的更强。 ② 它加了一条我没想到的反空洞断言( 报告里另外两条我核过并采纳的实测
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33240068158 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
Part of #12892 — step 1 of the two ordered steps in the maintainer ruling of 2026-08-29 (comment
5459618302, option 1: the door owns the registration route for the five artifact security collections). ⛔ Step 2 (AppPluginstops registering the five on an artifact boot, after a census of the non-artifact boot paths) is deliberately not here, so this PR must not carry a closing keyword for that card.What changed
packages/metadata/src/plugin.ts—ARTIFACT_FIELD_TO_TYPEgainscapabilities: 'capability'.scripts/check-stack-collection-maps.mjs— the waiver that recordedcapabilitiesas missing from that map is retired (the waiver list is a ratchet: a waiver that no longer applies fails). Two now-false prose claims in the same file are corrected with it: theARTIFACT_FIELD_TO_TYPErow said "four of the five", and theSECURITY_FIELDSrow said that block is the collection's sole registrar on an artifact boot.packages/metadata/src/artifact-door-capabilities.test.ts— new, four cases, driven through the real_parseAndRegisterArtifact(two positive, one negative control, one pinning that the strict parse still governs the item).packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts— the two door-coverage pins rewritten upward (option B, authorized after the first review round). See Measurement 3..changeset/artifact-door-registers-capabilities.md—minoron@objectstack/metadata, with the FROM/TO.Measurement 1 —
GET /meta/capabilityon a realbootstrap: 'artifact-only'bootThe reading the retired waiver named and no seat had taken. Driven on a real kernel boot (
Runtime+DefaultDatasourcePlugin(memory)+ObjectQLPlugin+MetadataPlugin{bootstrap:'artifact-only', artifactSource:{mode:'local-file'}}+SecurityPlugin+AppPlugin+DatasourceAdminServicePlugin) over a package declaring{ "name": "crm.export", "label": "Export CRM data" }, once per leg, with@objectstack/metadatarebuilt for each leg and the map entry confirmed present/absent indist/byscripts/ablation-dist-preflight.mjsbefore any reading was taken.The read taken is the metadata service's⚠️ The HTTP layer itself was NOT driven (see NOT MEASURED below).
list('capability')— the producerGET /meta/:typeresolves to throughMetadataProtocol.getMetaItems.AppPlugin(the ordinary artifact boot){name, label, _packageId, _provenance}AppPluginabsent)[]— empty{name, label, scope:"platform", _packageId, _packageVersion, _provenance}And the ADR-0066 D1 seeder downstream of it, same boots,
sys_capabilityrow count:AppPlugincrm.exportmanaged_by:'package')crm.export,managed_by:'package',package_id:'com.test.cap-probe'⇒ Step 1 landing on its own is a byte-level no-op on every boot where
AppPluginparticipates.AppPluginstill registerscapabilitiesand still runs last, so its unparsed copy still wins the registry. The only boot step 1 changes is the door-only one, and there it goes from[]to one parsed, defaulted, provenance-stamped item (sys_capability9 → 10). That is driven evidence for why step 2 is required rather than optional — not an inference.One refinement to the measurement carried here from #12894
That comment measured the two copies as differing on four keys (
scope,_packageId,_packageVersion,_provenance), at the two-reader-harness seam whereAppPlugin'sregisterInMemoryis called with the raw bundle item. On a real kernel boot the ObjectQL SchemaRegistry stamps_packageIdand_provenanceonto that same object during package install, so the divergence observed end-to-end narrows to two keys:scope(theCapabilitySchemadefault) and_packageVersion. Not a contradiction — two different seams, and both readings stand. Both are now pinned: the four-key set at the harness seam, and those two by name as the seam-invariant core.Measurement 2 — fail-closed or fail-open when a security collection has no registrar?
The confidence gap triage re-homed onto this card, and the safety precondition for step 2. Driven, not read.
On the access decision: fail-CLOSED, uniformly.
PermissionEvaluator.checkObjectPermission(the one predicate the CRUD gate and/me/permissionsboth fold through) over zero permission sets — what a boot whosepermissionscollection has no registrar resolves to:Positive control, same call with the declared set present:
find/findOne/count/insert→true. The denials are the decision, not a vacuously-denying harness.The ADR-0066 D4 action capability gate (
actionPermissionError, the single source the REST/actionsroute and the MCPrun_actionbridge share), driven directly:⇒ ⛔ Nothing measured here reads "no declaration" as "no restriction." The two default-allow paths that exist are properties of the resource's own declaration, not of a collection losing its registrar, and both are documented in place: an unrecognised, non-destructive operation falls through to allow (
!DESTRUCTIVE_OPERATIONS.has(op), ADR-0049 — measured:transferis mapped and denies), and an action that declares norequiredPermissionsis ungated by design.Driven twice, the second time deliberately re-driven after
mainlanded #12967 ("make a refused RBAC catalog write boot-visible instead of a silent seed of zero"), which is adjacent enough that a stale reading would have been misleading. Both readings agree.Condition: a real artifact-only boot, the artifact declares
capabilities, zero registrars exist for it (door map ablated back to pre-step-1,AppPluginabsent; mutation proven on disk and indist/before the boot).The boot is green, the seeder line reads healthy, and the
totalsilently excludes the declaration. #12967 does not close this: it makes a refused write loud, while this hole is a declaration that never reaches the registry at all —bootstrapDeclaredCapabilitiesreturns early on an empty list, so there is no write to refuse and nothing to report.⇒ A lost registrar denies, but it is never announced. Consequences for step 2, stated as a constraint rather than advice: the non-artifact boot census must be an exhaustive enumeration of the paths that depend on
SECURITY_FIELDS. ⛔ It must NOT be discharged by "boot it and see whether anything complains" — measured here, nothing complains, and the collection is simply gone.Measurement 3 — the two red pins, and the option-B rewrite
The first round of this PR left two pins in
packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts(PR #12878) red, and reported that they were not the shape the ruling anticipated: no pin went red on any of the four divergence keys. The three key-by-keydiffPaths(...).toEqual([...])assertions (sharing rule, position, permission set) all stayed green, because acapabilityitem's two copies differ only on keys that are not inCONVERSION_GOVERNED_PATHS. What actually fired was (a) the vacuity guard listing which keys have two readers and (b) a pin asserting the very absence this PR removes.Option B was then authorized: update those two — never relax them. An assertion of an absence stops being a guard the moment the absence is deliberately removed, so both were rewritten upward:
sharedlist gainscapability:crm.export, and only that;capabilitiescase now pins that both readers register the collection, that the two copies diverge on exactly['_packageId', '_packageVersion', '_provenance', 'scope']at this seam, and — by name, with the value each side carries —scope('platform'vsundefined) and_packageVersion('1.0.0'vsundefined), the two that survive every seam. The authored fields are asserted equal on both copies so that "they differ" cannot be satisfied by the two copies being different documents altogether.⛔ Nothing is
it.failsed, skipped,todoed, deleted or weakened (grep -cE "it\.(skip|todo|fails)|\.only"over the file = 0).The acceptance criterion is stated in the file AND driven
The case's docblock says, in place, that it exists to go RED when step 2 lands — and that whoever lands step 2 rewrites it to assert the single remaining copy, never by deleting or weakening it. That claim is measured, not asserted:
['capabilities', 'capability'],removed fromSECURITY_FIELDS; removed-textgrep -c= 0 (expect 0), control sibling['sharingRules', 'sharing_rule'],grep -c= 1 (expect 1); blob7f6b0de18…→6f08d5566…AssertionError: AppPlugin must still register the capability: expected undefined to be defined, and the vacuity guard fails with itgit hash-objectback to7f6b0de18…=git rev-parse HEAD:that path;git diff HEADon it = 0 lines; restored-textgrep -c= 1; suite 8/8 GREEN againResolution note for that leg: this suite imports
AppPluginas'./app-plugin.js'— a same-package relative specifier vitest transforms from source — so no rebuild is required for the mutation to be live, and the RED is itself the liveness proof. The dist-mediated dependency in this suite is@objectstack/metadata, which the mutation does not touch and which was rebuilt and preflighted (marker present in 4 built files) before every leg.Ablation — the step-1 criterion can fail, proven in the built artifact
packages/metadata/src/plugin.tsmutated to delete the map entry, then restored withgit checkout HEAD --against the absolute path, under anEXIT INT TERMtrap.grep -c= 0 (expect 0); control siblingsharingRules: 'sharing_rule',grep -c= 1 (expect 1); blob9f1134158…→fb921ebc1…dist/ablation-dist-preflight @objectstack/metadata 'capabilities: "capability"' --absent→ ✓ absent from all 24 built filesregisters a declared capability…,the registered copy carries…); the negative control and the strict-parse control stay green, as they mustcheck:stack-collection-mapscapabilities, which ObjectStackDefinitionSchema declares"git hash-objectback to9f1134158…, equal togit rev-parse HEAD:that path;git diff HEADon it = 0 lines; restored-textgrep -c= 1dist/check:stack-collection-mapsgreenGates and typechecks
Union derived after the final commit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackatb96cdbea— 35 path-derived families plus the convention-triggered ones, run with exit codes captured before any pipe. Derived three times across three trees and identical every time. All green, except three that refuse to measure and say so themselves:node scripts/check-test-completeness.mjs— NOT MEASURED: it requires a turbo test log argument CI supplies (usage:line, no gate body ran).node scripts/pm/check-half-states.mjs— NOT MEASURED:PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential(its own exit 3).pnpm check:dual-build-cjs-loads— NOT MEASURED:PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/(its own exit 3). Needs a full workspace build; CI's lint job builds first.Also green:
check:stack-collection-maps,check:nul-bytes,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:cross-package-test-inputs,check:test-source-alias,check:pm-dispatch-gates,bare-root-worklist --self-test,check-changeset-no-major,check-empty-changeset,check-adr-0087-registration.Typechecks, with the "green over a program that never saw your file" trap proven rather than assumed.
packages/metadatadeclares notypecheckscript, sotsc -p packages/metadata/tsconfig.json --noEmitwas run directly: 89 error lines = exactly thecheck-type-check-coverageDEBT entry (89), 0 naming the new file, and--listFilesconfirms the new file is in that program.packages/runtimedoes declaretypecheck, and it reports 0 errors — but itstsconfig.jsonexcludes**/*.test.ts, and--listFilesshows the edited file in that program 0 times. That green is therefore NOT a reading about this change. Measured directly instead, same config with the test exclusion lifted: 217 error lines = exactly the DEBT entry for@objectstack/runtime(217), 0 naming the edited file, and the edited file confirmed present in that program (1 hit).NOT MEASURED
check:type-check-debt --re-measure,check:dual-build-cjs-loads,check-test-completeness— all three need a full workspace build or a CI-supplied input. The two direct per-package tsc measurements above are the substitute for the first; CI owns all three.GET /meta/capability. The producer (list('capability')) was driven on a real artifact-only boot;RestServerandMetadataProtocol.getMetaItemswere not, so route-level filtering, if any, is unread.@objectstack/runtime's suite. The only change in that package is the one test file, which was run in full on every leg; CI runs the farm..sourcetoday") is untouched here — it belongs to the parent question, not to step 1.