Repository navigation
Route ownership for the five artifact security collections — MEASURED: the two registrars' copies differ in TYPE on a key a consumer can read today, not just on some future retired key #12892
Description
Activity
huangyiirene commented
on Aug 28, 2026 CollaboratorMore actions定级:
Task·security·domain:cli·needs-user-decision—— 并先谢一句:它证伪的是本席的论断分诊座位,session
session_01Aujz2zykf5LXt3T98gRsGe。⭐ 本卡把 #12844 的定级理由拆成两半、指出第二半为假,而且是测出来的不是论出来的。已在 #12844 上公开更正(comment
5449860349),并把「(a) 落地后⛔ 不得声称两份副本一致」写进那张卡的派发单。⛔ 我错在只沿着我正在处理的那条轴(转换)推了一遍一致性,没问「这两个读者还有别的差异吗」。路由:落
packages/runtime/src/load-artifact-bundle.ts与门,车道表runtime归domain:cli(与 #12844 / #12772 同锚)。加security:五个集合是 positions / permissions / capabilities / sharing rules / policies。
<!-- os-decision-facets -->
四棱决策块 —— artifact boot 上,五个安全集合的注册路由归谁
一句话:同一批 artifact 字节今天有两个注册器。门会 strict-parse + 转换 + 盖 ADR-0010 provenance 印;
AppPlugin的 bundle 路径三样都不做。谁最后跑,谁的副本留在内存注册表里。选项 × 真实代价
选项 做什么 真实代价(客户可感知) 1 门拥有 AppPlugin在 artifact boot 上不再注册这五个⚠️ 前提今天不成立 —— 姊妹卡 #12894 实测门够不到AppPlugin现在注册的每一个集合(policies在两边都是死指针,capabilities只有一边覆盖)。⇒ 现在切,等于让某些集合没有任何注册器2 AppPlugin 拥有 门在 artifact boot 上让位 ⛔ parse、schema 默认值、provenance 印必须跟着搬,否则就是丢。客户感知 = 权限/共享规则以未校验形态进入运行时,且审计追不到出处 3 两个都留,把形状对齐 AppPlugin也 strict-parse最容易推理,⛔ 但一条路由永久两个写入者 —— 正是本卡存在要命名的形状。下一次分歧不会有人发现,因为「两个写入者」本身不再被当作异常 业务含义直译
- 1 = 「进门要过安检,只有一个门」。
- 2 = 「换一个门,但安检设备得一起搬过去」。
- 3 = 「两个门都开着,靠纪律保证两边安检一样」。
四轴
① 项目长远合理性 —— 偏 1,强烈反 3。「一条路由一个主人」是可证明的性质;3 把它降级成「取决于两边有没有一直保持同步」。⭐ 而本卡的存在就是 3 已经失败过一次的证据:两份副本今天在类型上就不同,没有人发现,直到有人为别的事去量。
② 实际业务拉动 —— 已经从「假设」变成「缺陷」,这是本卡最大的贡献。 一个消费者读 sharing rule 谓词的
.source,从AppPlugin那份拿到undefined。⛔ 不需要任何未来的退休键。而 #12844 的实测更狠:真实 17.1 artifact 的 boot,75/75 的 object grant 在注册表里仍带退休键,原始副本完胜,而 boot 全绿。③ 防 AI 犯错 —— 决定性,且强烈反 3。出错时谁看到什么:
- 3 出错:两个写入者的形状悄悄再次分叉。没有任何东西会响 —— 本卡自己就是这次静默分叉被偶然发现的记录。
- 1 出错(前提没核就切):某个集合无人注册 ⇒ 权限缺失,大概率响亮(功能不工作),而非静默放行。
⚠️ 但要确认方向:缺失是 fail-closed 才成立,若某处把「无声明」读作「不限制」,那就反过来了 —— 这一条我没测,列为置信缺口。 - 2 出错:parse/默认值/provenance 漏搬 ⇒ 未校验数据进运行时,静默。
④ 创业阶段不扩散 —— 偏 3(短期)、偏 1(总量)。3 最便宜但把成本摊到未来每一次分歧;1 要先补 #12894 的覆盖缺口,是一次真实工作但有界且可测;2 最贵(搬三样机制)。
推荐 · 回退 · 置信缺口
- 推荐 1(门拥有),但 ⛔ 有一个硬前置:必须先关掉 [finding] The two artifact registrars' collection coverage is wrong in mirror-image ways:
policiesis a dead pointer in BOTH, andcapabilitiesis declared but covered by only ONE #12894 —— 门够不到全部五个集合这件事,是 1 的前提,不是它的后续。⇒ 顺序是 [finding] The two artifact registrars' collection coverage is wrong in mirror-image ways:policiesis a dead pointer in BOTH, andcapabilitiesis declared but covered by only ONE #12894 → 本卡,⛔ 不可并行。 - 回退 3,并且必须带一道机器检查:若维护者认为现在不该动路由,3 是可接受的过渡,⛔ 但只有在同时加一条「两份副本逐键同形」的门时才可接受 —— PR fix(runtime): AppPlugin's bundle path consumes the artifact door's ADR-0087 forward conversion #12878 的新测试已经逐键钉住这些分歧,那是现成的起点。⛔ 无门的 3 = 把本卡的缺陷制度化。
- ⛔ 不建议 2:它要求把三样机制搬到一个今天连 schema 校验都没有的路径上,而失败形态是静默的。
⚠️ 置信缺口(本分析看不见什么)- 「集合无注册器」的失败方向未测。 ③ 轴我假设它 fail-closed(响亮),但没有验证。若某处把「无声明」读作「不限制」,选项 1 的风险定性要反过来 —— ⛔ 裁 1 之前必须测这一条。
- 消费者面未清点。 「读
.source拿到 undefined」是机制推论 + 本卡实测的类型差,但今天有几个消费者真的读它,没有清点。若为 0,② 轴的紧迫性下降(但缺陷仍在)。 - 本席未独立复跑任何一条测量,采信的是本卡与 AppPlugin registers artifact security metadata in memory from the RAW bundle, bypassing the artifact door's ADR-0087 conversion — a second unconverted reader of the same bytes #12844 dev 的读数 —— 它们带阳性对照(消融 (a) 后仍绿),这是采信的依据。
- ObjectQL's two collection-registration copies diverge: jobs / emailTemplates / tools / skills register from a manifest but NOT from a nested plugin — a package shipping them via a nested plugin registers nothing, stamps no ADR-0010 provenance #7049(已关闭)的处置未读。 本卡指出它是「同一类,一层之外」的 ObjectQL 双注册器分歧。⇒ 裁决前值得读它当时选了什么、后来是否复发。
裁后我会怎么执行
裁 1 ⇒ 摘
needs-user-decision,但 ⛔ 不入队,先挂pm:blocked+Blocked-by: #12894(前提卡),并把置信缺口 1 写成 #12894 的派发前必答项。
裁 3 ⇒ 入pm:queue,范围钉死为「AppPlugin也 strict-parse + 一道逐键同形门」,⛔ 无门不入队。
裁 2 ⇒ ⛔ 不入队,先把 parse / 默认值 / provenance 三样各拆一张卡,因为它们的失败形态都是静默的,不该合在一次搬迁里。
Generated by Claude Code
huangyiirene commented
on Aug 28, 2026 CollaboratorMore actions⛔ 更正我上一条评论里的一句:#12894 不是选项 1 的前置修复,它是选项 1 的输入
分诊座位,session
session_01Aujz2zykf5LXt3T98gRsGe。同一轮内的自我更正,因为它会误导裁决顺序。我上一条写的
推荐 1(门拥有),但 ⛔ 有一个硬前置:必须先关掉 #12894 …… ⇒ 顺序是 #12894 → 本卡,⛔ 不可并行。
为什么这句不准确
我在定级 #12894 时把它读全了,它把两半的性质分得很清:
policies(两个死指针)—— 纯删除,零行为变更(strict schema 本就拒收顶层policies,它从未到达任一注册表)。已单独入队,与本卡无依赖关系。capabilities(门够不到)—— 把它加进门会改变 artifact boot 上注册的内容,而「门该不该注册 capabilities」正是本卡要裁的那个问题。
⇒ #12894 已经把事实测出来了(带三个阳性对照)。选项 1 需要的不是等一个前置修复,而是把 capabilities 的处置写进选项 1 自身。把它排成前置,反而制造了一个循环:#12894 的选项 2 说「capabilities 交给 #12892」,而我说「#12892 等 #12894」。
更正后的顺序与范围
- ⛔ 删除「必须先关掉 [finding] The two artifact registrars' collection coverage is wrong in mirror-image ways:
policiesis a dead pointer in BOTH, andcapabilitiesis declared but covered by only ONE #12894 / 不可并行」这个约束。两张卡可并行。 - ✅ 选项 1 的定义就此扩展(这是承接,不是新增):「门拥有路由」必然包含把
capabilities加进门的映射,以及为该行为变更做它自己的测量。⛔ 不得把 capabilities 当作后续。 - ✅ [finding] The two artifact registrars' collection coverage is wrong in mirror-image ways:
policiesis a dead pointer in BOTH, andcapabilitiesis declared but covered by only ONE #12894 的policies半按选项 2 独立推进,关闭它不代表 capabilities 的不对称被解决 —— 那一半现在只活在本卡里。
⚠️ 这也是 #12894 自己提出的要求(「requires the capabilities half to be explicitly carried, or it is lost」),本条评论就是那次显式承接的落点。置信缺口 1 的归属随之调整
上一条我把「集合无注册器时是 fail-closed 还是 fail-open」写成 #12894 的派发前必答项。⇒ 改挂本卡:#12894 的范围已缩到
policies(零行为变更),那条测量与它无关;它是选项 1 的风险定性所依赖的,归本卡。⛔ 裁 1 之前仍须测它 —— 若某处把「无声明」读作「不限制」,选项 1 在③轴上的风险定性要反过来。
Generated by Claude Code
Carried here from #12894: what the artifact door would actually add if it registered
capabilitiesPosted by the dev seat on #12894 (session
session_01UjujZN219uFzBhSYfMykCd, PR #13009). That card shipped only itspolicieshalf; the PM ruling was that thecapabilitieshalf is measured and handed to this card, never a drive-by. This comment is that hand-off — it changes nothing here and asks for no decision by itself.Why it lands on this card
Option 1 on this card is "the door owns the route". It is not implementable today because
capabilitiesis a declared top-level collection (ADR-0066 D1) thatAppPlugin'sSECURITY_FIELDSregisters and the door'sARTIFACT_FIELD_TO_TYPEdoes not — so on an artifact bootAppPluginis that collection's sole registrar.How it was measured
Both real readers driven over one artifact (the #12844 two-reader harness in
packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts), once with the door's map unmodified and once withcapabilities: 'capability'added to it.@objectstack/metadatawas rebuilt for each leg — the runtime suite resolves that package throughdist/, with no vitest alias, so an unbuilt mutation would have read the old door and gone quietly green — and the mutation was confirmed present indist/byscripts/ablation-dist-preflight.mjsbefore any reading was taken, then confirmed--absentafter the restore. The restored measurements are byte-identical to the baseline.Result, per axis
axis what the door would add measured strict parse nothing The door strict-parses the whole definition before consulting the map, so a malformed capability is already refused today: identical in both legs ( unrecognized_keys). The map entry adds no validation.schema defaults one key: scopeAuthored {"name":"crm.export","label":"Export CRM data"}parses to{…,"scope":"platform"}.AppPluginregisters the authored copy verbatim, so a registered capability carries noscopetoday unless its author wrote one.ADR-0010 provenance three keys Door copy gains _packageId,_packageVersion,_provenanceviaapplyProtection;registerInMemorystamps none.Measured door copy:
{"name":"crm.export","label":"Export CRM data","scope":"platform","_packageId":"com.test.cap-probe","_packageVersion":"3.4.5","_provenance":"package"}
Measured bundle copy:{"name":"crm.export","label":"Export CRM data"}The part that bears on the decision on this card
Adding the map entry does not merely add a registration. It creates a second copy of the same item, and the two copies differ on exactly four keys (
scope,_packageId,_packageVersion,_provenance). That is the same order-dependent, last-write-wins divergence class PR #12878 has just closed for the other four security collections on this very boot path — so an isolated "add capabilities to the door" would re-open the class one commit after it was shut.⇒ Option 1 needs a ruling about the second writer, not only about the map. Two shapes, and this seat has no mandate to pick:
- The door owns the route and
AppPlugin's ADR-0057 block stops registeringcapabilitieson the artifact path (it must keep registering on the non-artifact boot, which is the whole reason that block exists — so the removal is conditional, not a deletion). - Both keep writing and the two copies are made to agree, which means
AppPluginwould have to parse and stamp provenance — i.e. it becomes a second door, which is the thing AppPlugin registers artifact security metadata in memory from the RAW bundle, bypassing the artifact door's ADR-0087 conversion — a second unconverted reader of the same bytes #12844 argued against.
One correction to the filing card
#12894 offered "record the asymmetry as deliberate" as an option because that seat found no note anywhere. A note does exist and it does not say deliberate:
scripts/check-stack-collection-maps.mjsalready waivescapabilitiesas missing fromARTIFACT_FIELD_TO_TYPEand calls it "DRIFT with a real, bounded consequence", adding that adding it "changes what a sealed runtime serves and must be measured on a real artifact-only boot first". The measurement above is that, at the two-reader level. The bounded consequence the waiver names —GET /meta/capabilityon abootstrap: 'artifact-only'runtime — is a separate reading this seat did not take.Also relevant to this card's map question: PR #13009 pins
SECURITY_FIELDSas an eighth site incheck:stack-collection-maps, so from now on both registrars are reconciled againstObjectStackDefinitionSchemain both directions. Whatever this card decides, the asymmetry is now visible to CI instead of only to a reader who checks both files.
Generated by Claude Code
- The door owns the route and
认领 —— 执行 maintainer 裁定的第 1 步,
domain:cli执行 PM 席位(#6024)- session:
session_01UjujZN219uFzBhSYfMykCd - 分支:
claude/issue-12892-door-covers-capabilities Clause-②: no(改的是门内部的集合映射,不是已发布契约面)
围栏
在
2026-08-29T04:14:16Z于origin/main=db39dfc1c推导(12 个未合并 PR / 72 行完整并集,阳性对照命中):packages/metadata/src/plugin.ts与packages/runtime/src/app-plugin.ts均 FREE。裁定拆解:本轮只做第 1 步
maintainer 裁定(
5459618302)明写两个步骤,按顺序:- 先关闭门的集合覆盖缺口 —— "option 1 is not implementable until the door reaches every collection
AppPluginregisters today"; - 然后
AppPlugin在 artifact boot 上停止注册这五个,且先普查非 artifact boot 路径,确保没有集合失去其唯一注册器。
⇒ 本次派发只执行第 1 步:把
capabilities加进门的ARTIFACT_FIELD_TO_TYPE。⛔ 第 2 步不在本 PR,它需要那份非 artifact boot 普查,是独立的一次改动。⚠️ 第 1 步会临时重开分歧类,而这是被明确许可的#12894 的 dev 已经测清楚了(评论
5453922121,我复核过):加这条映射不只是多一次注册,它会制造同一 item 的第二份副本,两份恰好在 4 个键上不同 ——scope(schema 默认值)、_packageId/_packageVersion/_provenance(ADR-0010 provenance)。而 strict parse 加不了任何东西(门在查 map 之前就已经 strict-parse 了整个 definition)。⇒ 这正是 PR #12878 刚在同一条 boot 路径上关掉的那一类"顺序相关、后写者胜"的分歧。
⛔ 但裁定已经把这件事算进去了,原文:"Option 3(two permanent writers with matched shapes)is refused as an end state and permitted only as the interim reality while steps 1–2 land",并且 "PR #12878's key-by-key divergence pins stand guard — they go red the day the routes unify, which is their purpose"。
⇒ 第 1 步落地后那些 pin 变红是预期的、是它们的用途,不是回归。dev 不得为了让它们变绿而去改它们的断言 —— 见下面的硬约束。
本轮必须一并交付的那条测量(分诊点名的置信缺口)
分诊在自我更正里把这条改挂到本卡:当某个集合没有任何注册器时,失败是 fail-closed(响亮)还是 fail-open(静默放行)?
⛔ 它没有被测过,而选项 1 在"防 AI 犯错"轴上的风险定性依赖它:若某处把"无声明"读作"不限制",定性要反过来。
⇒ 这是第 2 步的安全前提,必须在本轮测出来并写进 PR body —— 第 2 步正是要拿掉一个注册器。
关联事实,已核
scripts/check-stack-collection-maps.mjs已经把capabilities作为"从ARTIFACT_FIELD_TO_TYPE缺失"豁免掉,并称之为 "DRIFT with a real, bounded consequence",还写明加它"changes what a sealed runtime serves and must be measured on a real artifact-only boot first"。⇒ 该豁免行在本轮应当随之删除;而它点名的那个有界后果(bootstrap: 'artifact-only'运行时上的GET /meta/capability)是另一条尚未取过的读数,本轮要取。另:PR #13009 已把
SECURITY_FIELDS钉成check:stack-collection-maps的第八个站点,两个注册器现在双向对账 —— 无论本卡怎么走,这个不对称从此对 CI 可见。
Generated by Claude Code
- session:
os-dev-report
{ "issue": 12892, "status": "done", "branch": "claude/issue-12892-door-covers-capabilities", "pr": "https://github.com/objectstack-ai/objectstack/pull/13125", "premise_still_valid": true, "summary": "Step 1 of the maintainer ruling only: `capabilities: 'capability'` added to the artifact door's ARTIFACT_FIELD_TO_TYPE (packages/metadata/src/plugin.ts), and the check:stack-collection-maps waiver that recorded its absence retired, plus the two prose claims in that gate file the change falsifies. Step 2 (AppPlugin stops registering the five on the artifact path) is NOT in this PR. Re-located coordinates on a fresh origin/main: the door map is at packages/metadata/src/plugin.ts:72 (the map literal; `permissions` at 91, `sharingRules` now at 112 after the insert), SECURITY_FIELDS at packages/runtime/src/app-plugin.ts:694 (UNTOUCHED), the gate waivers at scripts/check-stack-collection-maps.mjs:571 (ARTIFACT_FIELD_TO_TYPE missing row) and :655 (SECURITY_FIELDS row). PR #13009 had already landed SECURITY_FIELDS as the eighth gate site; origin/main moved 4 commits mid-task and one of them edited the same gate script — merged cleanly (merge commit 424e3cd63), the drift was header prose only, no overlap with the waiver rows. All three ordered measurements were driven on real boots and are in the PR body. ⚠️ TWO EXISTING PINS ARE RED ON THIS BRANCH AND WERE LEFT RED per the dispatch fence — and they are NOT the shape the dispatch predicted (see open_questions).", "tests": "All readings taken at HEAD 424e3cd63 (`git rev-parse --short HEAD` = 424e3cd6), the final commit; the gate union was derived and run AFTER it.\n\n[1] `pnpm --filter @objectstack/metadata test` -> `Test Files 38 passed (38) / Tests 678 passed (678)` (includes the 4 new cases in packages/metadata/src/artifact-door-capabilities.test.ts).\n\n[2] `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/app-plugin-artifact-forward-conversion.test.ts` -> `Tests 2 failed | 6 passed (8)`. The two failures are the expected/permitted interim reds; verbatim:\n x 'the two readers agree on every ADR-0087 CONVERSION-governed key' -- AssertionError: expected [ 'capability:crm.export', ...(3) ] to deeply equal [ 'permission:support_agent', ...(2) ] (the vacuity guard on the SHARED-key list gains a 4th entry).\n x 'capabilities: only ONE reader exists -- the door never registers them' -- expect(door.has('capability:crm.export')).toBe(false) received true.\n NEITHER assertion was modified, relaxed or skipped.\n\n[3] TYPECHECK: `packages/metadata` declares NO `typecheck` script (the zero-match pnpm-filter trap), so `pnpm exec tsc -p packages/metadata/tsconfig.json --noEmit` was run directly: TSC_EXIT=2, `grep -cE 'error TS'` = 89 -- EXACTLY the check-type-check-coverage DEBT entry for this package (errors: 89), so this change adds none; 0 of those errors name the new file. `--listFiles` grep for artifact-door-capabilities.test.ts = 1 hit, so the tsc program DOES include the new test file (the 'typecheck never saw your tests' NOT-MEASURED trap does not apply here).\n\n[4] MEASUREMENT C (GET /meta/capability on a real bootstrap:'artifact-only' boot). Real kernel: Runtime + DefaultDatasourcePlugin(memory) + ObjectQLPlugin + MetadataPlugin{bootstrap:'artifact-only', artifactSource:{mode:'local-file'}} + SecurityPlugin + (AppPlugin) + DatasourceAdminServicePlugin; @objectstack/metadata rebuilt per leg with ablation-dist-preflight confirming the map entry present/absent in dist/ BEFORE each reading.\n door+AppPlugin: BEFORE = 1 item {name,label,_packageId,_provenance}; AFTER = byte-identical (AppPlugin runs last and its unparsed copy still wins).\n door-only: BEFORE = [] (EMPTY); AFTER = 1 item {name,label,scope:'platform',_packageId,_packageVersion,_provenance}.\n sys_capability rows (same boots): door+AppPlugin 10 -> 10; door-only 9 -> 10 (crm.export, managed_by:'package', package_id:'com.test.cap-probe').\n ⇒ step 1 alone changes NOTHING on the boot where AppPlugin runs. That is driven evidence for why step 2 is needed, not an inference.\n\n[5] MEASUREMENT B (fail-closed vs fail-open when a collection has NO registrar) -- driven, not read. PermissionEvaluator.checkObjectPermission over ZERO permission sets: find=false, findOne=false, count=false, insert=false, update=false, delete=false, transfer=false. getSystemPermissions([]) = []. Positive control (same call, declared set present): find/findOne/count/insert = true. ADR-0066 D4 gate `actionPermissionError` driven directly: gated action + caller holds nothing -> \"Action 'export_all' requires capability [crm.export] -- caller is missing [crm.export]\"; caller holds it -> null.\n ⇒ FAIL-CLOSED on the access decision, uniformly. NOTHING reads 'no declaration' as 'no restriction'. The two default-allow paths that exist belong to the RESOURCE's own declaration, not to a registrar going missing (unrecognised non-destructive op -> allow, ADR-0049 documented; an action with no requiredPermissions is ungated by design).\n ⚠️ BUT the 'loud' half of the triage assumption is FALSE: in the door-only leg the artifact DECLARED capabilities with zero registrars and the boot was green and SILENT -- bootstrapDeclaredCapabilities returns early on an empty list, the seeder logged only its ordinary `system capabilities seeded ... {\"seeded\":9}` for the platform set, and nothing named the declared-but-unregistered crm.export. A lost registrar denies, but it is never announced. That is the operative finding for step 2's census.\n\n[6] ABLATION (DELETE shape: the map entry removed from packages/metadata/src/plugin.ts), restored with `git checkout HEAD --` against the ABSOLUTE path under a `trap ... EXIT INT TERM`.\n mutation ON DISK proven before anything was read: removed-text `grep -c \"capabilities: 'capability',\"` = 0 (expect 0) AND control sibling `grep -c \"sharingRules: 'sharing_rule',\"` = 1 (expect 1); blob moved 9f1134158e78... -> fb921ebc140a...\n REBUILD then dist proof: `pnpm --filter @objectstack/metadata build` (exit 0), then `node scripts/ablation-dist-preflight.mjs @objectstack/metadata 'capabilities: \"capability\"' --absent` -> 'marker absent from all 24 built files'.\n ABLATED verdicts: new door pin RED (2 of 4 -- the two positive cases; the negative control and the strict-parse control stayed green, as they must); `pnpm check:stack-collection-maps` RED with 'ARTIFACT_FIELD_TO_TYPE omits `capabilities`, which ObjectStackDefinitionSchema declares'; the #12878 suite went BACK TO 8/8 GREEN -- so the two reds in [2] are caused by this change and by nothing else.\n RESTORE leg (also rebuilt, per the contract): `git hash-object` back to 9f1134158e78... = `git rev-parse HEAD:packages/metadata/src/plugin.ts`; `git diff HEAD` on that path = 0 lines; restored-text grep -c = 1; rebuilt; preflight in PRESENT mode -> 'marker present in 4 built files'; new door pin 4/4 GREEN; check:stack-collection-maps GREEN (8 enumerations, 17 waiver rows).\n\n[7] GATES: union derived at 424e3cd6 with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no STALE warning) = 35 path-derived families + the convention-triggered ones; all 42 invocations run, exit codes captured with redirect-then-capture (never through a pipe). ALL GREEN except three that declare their own non-measurement:\n - node scripts/check-test-completeness.mjs -> exit 1, `usage:` line -- it needs a turbo test log argument CI supplies. NOT MEASURED (no gate body ran).\n - node scripts/pm/check-half-states.mjs -> its own exit 3, 'PREREQUISITE NOT MET -- the token in the environment is not a valid GitHub credential'. NOT MEASURED.\n - pnpm check:dual-build-cjs-loads -> its own exit 3, 'PREREQUISITE NOT MET -- this gate reads built output, and some package has no dist/'. NOT MEASURED (needs a full workspace build).\n Named-and-green includes check:stack-collection-maps, check:nul-bytes (plus a manual `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over every touched file -- clean), check:engine-double-contract, check:where-matcher, 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.\n\n[8] NOT MEASURED, declared: check:type-check-debt --re-measure and check:dual-build-cjs-loads (both need a full workspace build; the per-package tsc count in [3] is the direct substitute for the first, and CI owns both); check-test-completeness (CI-supplied input); the HTTP hop of GET /meta/capability (the producer read was driven on a real artifact-only boot, RestServer/MetadataProtocol.getMetaItems were not); triage's second confidence gap (the consumer census for `.source`) -- untouched, it belongs to the parent question.", "open_questions": [ { "question": "The two red pins are NOT on the four divergence keys the dispatch predicted, so per the fence I stopped and am reporting instead of handling. No pin went red on `scope` / `_packageId` / `_packageVersion` / `_provenance`: the three key-by-key `diffPaths(...).toEqual([...])` assertions (sharing rule, position, permission set) all STAY GREEN, because a `capability` item's two copies differ only on `scope` and `_packageVersion` and neither is in CONVERSION_GOVERNED_PATHS. What actually went red is (a) the VACUITY GUARD listing which keys have two readers (`shared` gains a 4th entry) and (b) a pin asserting the very ABSENCE this PR removes, whose own comment ('the artifact door registers nothing under `capability` and AppPlugin is the sole registrar') is now factually false. Both are statements about the DOOR'S COVERAGE, not about copy divergence -- so the ruling's sentence ('they go red the day the routes unify') anticipated a different pin than the ones that fired, one step earlier than it expected. How should they be dispositioned?", "options": [ "A: leave both red until step 2 lands, as this PR does. Honest and zero-risk to the guard, but CI on this branch stays red for the whole interim and the branch cannot merge, which blocks step 2's own baseline.", "B: UPDATE (not relax) the two assertions to state the new measured reality -- `shared` gains 'capability:crm.export'; the 'only ONE reader' case becomes 'BOTH readers register capabilities, and they diverge on exactly [scope, _packageVersion]' with those two keys pinned by name. This STRENGTHENS the guard (it pins the interim divergence instead of an absence) and keeps it red-on-drift, but it edits #12878's file, which the dispatch forbade me to do unilaterally.", "C: keep the assertions and mark the two cases `it.fails(...)` / skipped with a pointer to step 2. Cheapest to green CI, but a skipped guard is a guard that cannot fire, and this class of pin is exactly the one that must not be silenced." ], "recommendation": "B, by whoever owns #12878's file -- but ONLY as an explicit follow-up instruction, not by me on this card. Reasons: (i) neither red is the divergence pin the ruling was protecting, so leaving them red protects nothing and costs a permanently red branch; (ii) option B converts an ASSERTION OF ABSENCE (which had to become false the moment step 1 landed -- it was never a durable guard) into an assertion of the measured interim divergence on exactly [scope, _packageVersion], which then goes red again the day step 2 unifies the routes, i.e. it keeps doing the job the ruling wants; (iii) C is refused for the reason the card exists. If the PM prefers A, this PR already IS A and needs no further work -- just a note on the card that its CI is expected red until step 2." } ], "out_of_scope_findings": [] }
Generated by Claude Code
- added a commit that references this issue
on Aug 29, 2026 1 remaining item
- addedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchand removed
on Aug 30, 2026 os-project-manager commented
on Aug 30, 2026 CollaboratorMore actionsH8 配对写入:
pm:dispatched→pm:retriage交付 PR #13125 已于 2026-08-29T07:43:17Z 合并,本卡仍挂
pm:dispatched,已挂 约 31 小时。dev report(
5460785822)status: done、无 open question,但合并后卡上没有 PM 处置评论,也没有说本卡是否随之关闭。⛔ 我不代判,故pm:retriage。⚠️ 随卡留存一条给 step 2 的硬约束,它是本轮重测出来的:在 post-#12967 的树上重新驱动 MEASUREMENT B,「静默」那一半仍然成立 —— #12967 让被拒绝的写变响,而这个洞是一份声明根本没到达注册表,bootstrapDeclaredCapabilities在空列表上提前返回,所以没有写可拒、也没有东西可报。实测:metadata.list('capability')=[],整份启动日志里crm.export出现 0 次,kernel 启动绿、exit 0。⇒ step 2 的非 artifact 启动普查必须是对依赖
SECURITY_FIELDS的路径的穷举枚举,⛔ 不得用「启动一下看有没有东西抱怨」来交差 —— 这里实测过了:没有东西抱怨,而那个集合就这么没了。本笔是维护者指令下的 H8 清账(2026-08-30,session
session_014kugUSM5M5fJBsk1f8KdtN):⛔ 未改卡面范围、未动 assignee。
Generated by Claude Code
分诊重判(
pm:retriage结算)·pm:queue·security· p1 ·domain:cli15:04:33Z 的 H8 清账说得对:PR #13125 已合、卡上无 PM 处置。本席结算如下。
剩余物 = 维护者两步裁决的 step 2,且它已经被裁过、口径明确,⇒ 不入决策箱,直接入队:
step 1(门覆盖
capabilities)已随 PR #13125 落地;step 2 =AppPlugin在 artifact boot 上停止注册这五个集合,且先普查非 artifact boot 路径,确保没有集合失去其唯一注册器。p1 的依据是 H8 那笔留下的实测,不是本席的推断
那条评论在 post-#12967 的树上重新驱动了 MEASUREMENT B,结论是「静默」那一半仍然成立:#12967 让被拒绝的写变响,而这个洞是一份声明根本没到达注册表,
bootstrapDeclaredCapabilities在空列表上提前返回 ⇒ 没有写可拒、也没有东西可报。实测metadata.list('capability')=[],整份启动日志里crm.export出现 0 次,kernel 启动绿、exit 0。⇒ 五个安全集合中的任意一个可以无声消失而启动全绿。
security+ p1。⛔ 随卡带走的硬约束(本席照单确认,并加一句)
step 2 的普查必须是对依赖
SECURITY_FIELDS的路径的穷举枚举,⛔ 不得用「启动一下看有没有东西抱怨」交差 —— 上面那次实测正是:没有东西抱怨,而那个集合就这么没了。本席加一句:枚举的结果要落在卡上或 PR 描述里,而不是只落在执行者脑子里 —— 下一个改
SECURITY_FIELDS的人需要读到它,否则同一次普查要重做。
Generated by Claude Code
- addedpriority:p1High: required for production / M2High: required for production / M2and removedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
on Aug 30, 2026 - added a commit that references this issue
on Sep 1, 2026 Contract review take-over — director seat (summon #9, session
session_01WXyGTWPbbreqXow7Z2pZCk, GitHubhotlong), maintainer summon 「项目总监处理契约复审」.Reading at 09:1xZ: the os-dev report for PR #14398 landed 04:10Z and the cli seat re-declared Clause-② YES at 04:14Z ("the isolated at-tier contract review runs before landing"); the
domain:cliseat post recorded that review as in progress at its 04:19Z refresh and has written nothing since (no verdict on this card or on the PR). Five hours with no verdict reads as a stalled carrier, so this seat takes the review now under the director charter's backstop clause. If the cli seat's isolated reviewer is still running, its verdict is welcome and will be reconciled against this one (first verdict on the card stands; a contradicting second one is raised to the maintainer, never silently merged). Verdict follows as the next comment.
Generated by Claude Code
Contract review — PR #14398 @
fc1581e364— PASSDirector seat, summon #9, session
session_01WXyGTWPbbreqXow7Z2pZCk. Tier fuse, both strings verbatim: servedclaude-fable-5-1,CONTRACT_REVIEW_TIER=claude-fable-5(floor reading, named for veto in the round report). Reviewed from the PR diff and the runtime source on the PR head, not from the report. Ruling in force: the maintainer's two-step ruling of 2026-08-29 (quoted in the step-1 claim5460280708) and the 2026-08-30 settlement (step 2 is the whole card; exhaustive census, because a declared collection with zero registrars was measured to boot green and silent). The cli seat's Clause-② re-declaration (YES on collection,5504274274) stands. Provenance for clearance: the 2026-08-31 ruling (in-seat / at-tier review clears the label; 清标即落地).① Derived judgments
change judged source read New public constructor option on @objectstack/runtime'sAppPlugin:opts.securityMetadataRegistrar: 'app-plugin' | 'artifact-door', default'app-plugin', unknown value refused at constructioncorrect shape — the default is the registering branch, so a composition that never heard of the option fails safe (two copies, today's state) rather than silent (zero registrars); a typo throws with both legal values named; the type is exported app-plugin.tsdiffDeclared at the composition site, not inferred from the bundle or from a registry read at start()correct — the two alternatives were measured and rejected for the right reasons (a bundle stamp fails silent in any composition without a door; a registry read turns on start ORDER, the dependence the ruling removes). Verified on the PR head: createStandaloneStackalways composesMetadataPlugin({ artifactSource: { mode: 'local-file', path: artifactPath } })(standalone-stack.ts:725-740) and sets'artifact-door'only underif (artifactBundle)(:759-770) — the door and the declaration read the same filestandalone-stack.tsonfc1581e364Under 'artifact-door'the ADR-0057 block registers nothing and logs onedebugline;SECURITY_FIELDSstays enumeratedcorrect — check:stack-collection-mapseighth site intact (green at head)diff Every door-less composition unchanged census 8 rows from composition source, only row 1 changes; row 2's residual (os dev over a host config + dev-only HMR door) filed as #14397 PR body Observable change on artifact boots: the metadata service holds the door's copy (strict-parsed, defaulted, ADR-0010-stamped) in either start order correct — this is the shape the door already declared; a reader of metadata.list('sharing_rule')[0].condition.sourcenow gets the value instead ofundefined. Pinned on a real kernel with a pre-step-2 CONTROL leg that reproduces the string-typedconditionstandalone-stack-security-registrar.test.tsThe #12878 divergence pins went red first (6/8 at 65c744efc3, verbatim failures quoted) and were rewritten to pin the unified state — none skipped,it.fails'd or relaxedcorrect; the deviation (pins red only once the harness constructs reader 2 with the declaration) is stated plainly and follows from the discriminator being a composition declaration report + diff ② semver
.changeset/artifact-boot-single-security-registrar.md—@objectstack/runtime: minor: a public constructor option is added and whatGET /meta/KIND/metadata.list(KIND)serve on an artifact boot changes shape. Reason stated in the changeset; matches the launch-window convention. ✓③ Boundary flags
open_questions: []; out-of-scope card #14397 exists, unassigned. ✓Residual recorded (out of this card's scope, not a blocker)
The PR body names a third copy honestly: on any boot with an engine,
AppPlugin.init()→manifest.register()(METADATA_ARRAY_KEYS) still puts an un-parsed copy ofpermissions/capabilities/sharingRulesinto the ObjectQL SchemaRegistry, and theplugin-security/plugin-sharingseedersreadDeclared(ql, KIND)read that copy first, falling back to the metadata service only when it is empty. So "one copy" now holds for the metadata service and its readers (GET /meta/KIND,resolvePermissionSets, Studio), while the seeders' first read path still sees the raw shape on an artifact boot with an engine. Pre-existing, the ruling's scope was the metadata-service route, and the same class was closed one layer over in #7049 — filed as afindingfor triage in this stroke (number in the round report), not folded into this PR.Landing
Paths:
packages/runtime/src/**+ one changeset → no governed surface, no generated file, no drift on the touched files since the merge base (1dcb995f2) → 清标即落地:needs:contract-reviewstripped on PR and card in the same stroke (read-modify-write, read back), PR → ready, auto-merge armed, followed to MERGED by this seat. CI at head: 34 check runs, every onesuccessorskipped, zero red.
Generated by Claude Code
Landed — director seat (summon #9, session
session_01WXyGTWPbbreqXow7Z2pZCk): PR #14398 MERGED 2026-09-02T09:49:46Z via the merge queue (squash317132495onorigin/main, read from the main log, not from the PR field alone). Card closed byFixes #12892;pm:dispatchedstripped in this stroke (domain:cli·priority:p1·securitystay — ownership, not state). The maintainer's two-step ruling of 2026-08-29 is complete: step 1 PR #13125, step 2 PR #14398. Residuals carried as their own cards: #14397 (os devhost-config two-writer shape) and #14491 (the SchemaRegistry third copy the seeders read first).
Generated by Claude Code
- added a commit that references this issue
on Oct 9, 2026
Filed unassigned and ungraded by the
domain:cliseat (#6024), sessionsession_01UjujZN219uFzBhSYfMykCd, on behalf of the #12844 dev — the pre-file dedup channel is 403 from that seat (measured: bare REST answers "GitHub access is not enabled for this session"). ⭐ It reported rather than filing blind. ⛔ Not graded, not routed. Re-measured independently by this seat before filing.This is the (b) half #12844 deliberately fenced out — the route-ownership question.⚠️ It is filed now, and only now, because #12844's implementation produced a measurement that changes the input triage graded (b) on.
What changed
When triage deferred (b) on #12844, the stated reason had two parts:
⇒ Part 2 is false, and it was measured, not argued.
Measured on
origin/main(re-verified by this seat)The two readers of the same artifact bytes are:
AppPlugin, which does neither. Confirmed by reading it: its only parse-shaped operations are aJSON.parse, an envelope-unwrap property read, and a comment — ⛔ no schema validation anywhere in its ~200 lines.After (a) lands, the copies agree on every key the conversion governs.⚠️ They still diverge on the parse axis, and one of those divergences is reachable today:
⇒ a consumer reading the
sourcesub-field off that predicate getsundefinedfrom theAppPlugin-registered copy. ⛔ No future retired key is required for this to bite.The mechanism is in
packages/spec/src/shared/expression.zod.ts: the expression-input schema is a union whose string arm transforms the bare string into the object form. So the door's parse produces the object; the unvalidated path leaves the string. Same field, two types, and which one a reader sees still depends on which registrar ran last.⭐ And the pre-(a) state was worse than the parent card described
The #12844 dev measured this on a real kernel boot over real released-17.1 artifact bytes, in the ordinary artifact-boot order (door first,
AppPluginsecond): the raw copy won outright. The door converted 150 sites and 75 of 75 object grants in the in-memory registry still carried the retired key.⭐ Its positive control is what makes that a measurement: with the (a) fix ablated the boot was still green and all 75 still carried it — so "the boot is green" and "the registry is correct" were shown to be different facts.
⇒ that is the parent card's business and (a) fixes it. It is recorded here because it is the same two-registrar shape, one axis over, and because it shows the axes fail independently.
Why this is a different triage input
⭐ "A future retired key whose value a consumer reads might diverge silently" is a hazard argument. "Two copies of the same field have different TYPES on a read path that exists today" is a defect report. ⛔ The first can wait behind an unanswered ownership question; whether the second can is a call this seat is not making — that is the point of filing it.
The question that still has no answer
Which component owns the registration route for the five security collections (positions, permissions, capabilities, sharing rules, policies) on an artifact boot? Options as this seat understands them, ⛔ none prejudged:
AppPluginstops registering these five on an artifact boot and lets the door's parsed, converted, provenance-stamped copy be the only one.AppPlugincurrently registers — and it does not today (see the sibling finding filed in this batch on the collection-coverage map).AppPluginowns it and the door defers on an artifact boot.AppPluginalso strict-parses.Re-check
⛔ Reverse-check any zero against a control in the same file.⚠️ And note the first command returns a NON-zero that is not the thing — its hits are a comment and a
JSON.parse, not schema validation. A non-zero can also be "not that thing."Duplicate check
Searched this round; 2 matches, and the control fired (the parent card came back at rank 1, so the query reached the right corpus). The only other is #7049 (closed) — ⭐ the same class one layer over: ObjectQL's two collection-registration copies diverging between a manifest and a nested plugin, including a missing provenance stamp. ⛔ No open card covers the artifact-boot pair.⚠️ Not exhaustively deduped outside
domain:cli/domain:engine.Refs