fix(metadata,runtime): retire the policies dead pointer in both artifact registrars, and pin the map that carried it - #13009
Conversation
…ifact registrars `ARTIFACT_FIELD_TO_TYPE` (the artifact door) and `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` list each carried a `policies` -> `policy` entry that could never match: `ObjectStackDefinitionSchema` is a `strictObject` declaring no top-level `policies` key, so a definition carrying one is refused by the door's strict parse and reaches neither registry. On a permission set `policies` is an alias for `rowLevelSecurity` -- a key on an item, never a collection. Both entries are removed with the in-place note this map already writes for a retirement. That was the third such retirement here, so the removal ships with the check the two predecessors lacked: `check-stack-collection-maps` now pins `SECURITY_FIELDS` as an eighth site (new `tupleFirstItems` extractor for its `[collection, kind]` tuple shape, plus a self-test case), so re-adding a key the schema does not declare fails in CI on either registrar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 29 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 7f2e29011ed65f8d50cb19cb8b66d3c739746cdf && git checkout 7f2e29011ed65f8d50cb19cb8b66d3c739746cdf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6fa22ce1a29b9256edc9de01ebd782b75d74738 503d75a824bdf2b50ddb0760675fa584ff0f7e94 && git checkout -B drift-repro f6fa22ce1a29b9256edc9de01ebd782b75d74738 && git merge --no-ff 503d75a824bdf2b50ddb0760675fa584ff0f7e94
node scripts/docs-audit/affected-docs.mjs --json f6fa22ce1a29b9256edc9de01ebd782b75d74738
|
PM 复核 — ACCEPT
⭐ 最有价值的不是删除,是发现门禁在空转我给的硬判据是「删掉之后必须有东西能在有人加回来时变红」。dev 去落实时发现: 我自己复核了这一点:
对照活着 ⇒ 那个零是真的。 失效机制写在新加的注释里,是本轮最锋利的一句: ⇒ 这不是报错,是空洞通过(vacuous pass)。站点被静默跳过,而门禁看上去是绿的。
dev 的处置是对的:扩展门禁(新增 ⭐ 这是本轮第二个同类发现(前一个是 #12985 的 dev 发现 对树复核 —— 通过
capabilities 半边:测量已交付,未发货 —— 符合裁定结果比预期有用,它改变了 #12892 的框定:
⇒ 加上这条 map 条目会制造每个 item 的第二份副本,两份仅在这 4 个键上不同 —— 正是 PR #12878 刚在这条路径上关掉的那一类"顺序相关、后写者胜"的分歧。 ⇒ #12892 的选项 1 需要裁的是"第二个写者",不只是那张 map。 已由 dev 承载到 #12892。 声明的收窄,接受
另一处值得记:capabilities 那条消融需要重建(runtime 套件经 CI 全绿后我撤草稿并 arm。 Generated by Claude Code |
…CURITY_FIELDS went unread The header said the flat extractor 'could not read it and the site was skipped instead of failing'. Re-measured on 8cb96ec: false. This gate has refused an empty extraction since #7032, and swapping the site's extractor back to stringArrayItems exits 1 by name. The site was skipped because it was never a SITE — SECURITY_FIELDS occurred 0 times in this file before #13009 (control ARTIFACT_FIELD_TO_TYPE: 5). The hole was the hand-written SITES population, not the extractor set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw
Fixes #12894
Scope is the PM ruling on that card: option 2 + mandatory carry — ship the
policieshalf only (a pure removal of two dead pointers, zero behaviour change), and measure thecapabilitieshalf without shipping it. The measurement is in this body and on #12892.What ships — two dead pointers, and the check the last two retirements did not get
Both readers of an artifact boot carried a
policies->policyentry:ARTIFACT_FIELD_TO_TYPE(artifact door)packages/metadata/src/plugin.tsSECURITY_FIELDS(AppPlugin, ADR-0057 block)packages/runtime/src/app-plugin.tsNeither could ever match, re-measured on this branch rather than taken from the card:
ObjectStackDefinitionSchema, lines 200-749 ofstack.zod.ts)policiespermissions(control)capabilities(control)sharingRules(control)positions(control)policiesappears 0 times anywhere in that file (controls, whole file:permissions7,capabilities10,sharingRules2). Per the card's instrument warning, the file was confirmed to be the schema and not a test first —packages/spec/src/stack.zod.ts:200isexport const ObjectStackDefinitionSchema = lazySchema(() => strictObject({, and the probes aregrep -r, notgit grep.The word is real but lives one level down: on a permission set
policiesis an alias forrowLevelSecurity(PERMISSION_SET_KEY_ALIASES,packages/spec/src/security/permission.zod.ts) — a key on an item, never a collection. A definition carrying a top-levelpoliciesarray is refused by the strict door outright, so neither registrar could reach one.Both entries are removed, each leaving the note this map already writes when it retires an entry: what it pointed at, and why it could not match. That is the pattern the file has now used three times (
themes;roles->positions, whose note records that it "matched nothing and silently dropped compiled positions").The acceptance criterion: re-adding it now fails CI
The two predecessors were retired with a reason and nothing to stop a fourth instance. This one ships with the check.
check:stack-collection-mapsreconciles every hand-maintained enumeration of the stack-collection set against the schema. It knew about seven sites.SECURITY_FIELDSwas not one of them — and how it was missing is the finding, not a footnote: it is the only one of the eight that pairs its keys as[collection, kind]tuples, and both existing extractors read either object keys or flat string arrays. A tuple body yields nothing at depth 0, so the site was not reported wrong — it was skipped. Removingpoliciesfrom the door while leaving this list unpinned would have left exactly one place where the fourth instance of a twice-retired pattern could land back unnoticed.So this PR adds:
SECURITY_FIELDSas the eighth pinned site, with the waiver row recording that it is a deliberate four-collection subset (the ADR-0057 security metadata the boot seeders read vialist(...)), not an enumeration of the collection set.tupleFirstItems, an extractor for that shape, with a self-test case covering the comment/nesting cases the flat extractor already pins (13 assertions, up from 12).policieskey removed from the door site'sextrawaiver row rather than left behind — the waiver list is a ratchet, and a stale row fails exactly like an unrecorded deviation.Reverse verification — the guard really goes red
Mutation and restore both proven on disk (anchored
grep -con the injected text,git hash-objectagainst theHEADblob), restore viagit checkout HEAD --naming each file by ABSOLUTE path, under anEXIT INT TERMtrap, and verified by blob-hash equality plus an emptygit diff HEAD— never by an exit code. The gate reads source text, not a build output (stated in its own header, and the reason it can run in the lint job), so no rebuild leg applies to this ablation.✓ check-stack-collection-maps: 8 enumerations reconciled against 31 declared collections (17 waiver rows, each with a reason).✗ … ARTIFACT_FIELD_TO_TYPE enumerates 'policies', which ObjectStackDefinitionSchema does not declare (packages/metadata/src/plugin.ts).SECURITY_FIELDS✗ … SECURITY_FIELDS enumerates 'policies', which ObjectStackDefinitionSchema does not declare (packages/runtime/src/app-plugin.ts).git diff HEADempty on every restore legThe
capabilitieshalf — measured, deliberately NOT shippedcapabilitiesis a declared top-level collection (ADR-0066 D1) thatSECURITY_FIELDSregisters and the door's map does not, soAppPluginis its sole registrar on an artifact boot. The ruling was to measure what the door would add and hand it to the ownership decision on #12892, not to change the map here. Measured by driving both real readers over one artifact (the #12844 two-reader harness), with the door's map mutated to carrycapabilities: 'capability',@objectstack/metadatarebuilt (the runtime suite resolves that package throughdist/— no vitest alias), and the mutation confirmed indist/byscripts/ablation-dist-preflight.mjsbefore any reading was taken. The restore leg was rebuilt and re-confirmed--absent, and the restored measurements are byte-identical to the baseline.Per axis, and the answers are not the ones the axis names suggest:
ARTIFACT_FIELD_TO_TYPE, so a malformed capability is already refused today. Identical in both legs: a capability carrying an unknown key fails withunrecognized_keys, map entry or not. (Also measured: the alias-authored form{ key, title }is refused by the top-level door too —invalid_type+unrecognized_keys— so that alias table is diagnostic guidance, not accepted input.)scope. Authored{"name":"crm.export","label":"Export CRM data"}parses to{"name":"crm.export","label":"Export CRM data","scope":"platform"}. TodayAppPluginregisters the authored copy verbatim, so a registered capability carries noscopeunless its author wrote one.applyProtectionstamps_packageId,_packageVersion,_provenance;registerInMemorystamps none. Measured door copy:{… ,"scope":"platform","_packageId":"com.test.cap-probe","_packageVersion":"3.4.5","_provenance":"package"}. Measured bundle copy:{"name":"crm.export","label":"Export CRM data"}.The consequence the three axes produce together, and the reason this is not a drive-by: adding the 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 #12878 has just closed for the other four security collections on this very path. So the door-owns-the-route option on #12892 needs a decision about the second writer as well, not only about the map: eitherAppPluginstops registering capabilities on the artifact path, or the two copies are made to agree. Shipping the map entry alone would re-open the class one commit after it was closed.One correction to the filing card, which offered "record the asymmetry as deliberate" as an option because it found no note: a note does exist, and it does not say deliberate.
check-stack-collection-maps.mjsalready waivescapabilitiesas missing from the door's map and calls it "DRIFT with a real, bounded consequence", adding that it "must be measured on a real artifact-only boot first". This PR is that measurement; the ownership decision stays on #12892.Verification
Union run on the final commit
503d75a8(clean tree,git statusempty).pnpm check:stack-collection-maps— green (self-test 13 assertions, then 8 enumerations reconciled).pnpm check:nul-bytes— green; plus a direct control-character scan over every touched file (no hits).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(5 paths, merge basefeab4bff9) and run green:check:agent-test-spelling,check:bash32-floor,check:cli-command-ids,check:entry-guard,check:parse-guard,check:pnpm-filter-targets,check:watch-hint-literal,check:pm-dispatch-gates,check:durability-log-level,check:page-declaration-shape,check:slot-lookup,check:published-files,check:test-source-alias,check:type-source-resolution,check:objectql-double-limit,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:cross-package-test-inputs,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check:type-check-coverage, and the script forms ofcheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-ci-filter-parity,check-comment-mask-adoption,check-plugin-teardown-shape,check-undeclared-dep-imports,check-cross-package-test-inputs,scripts/pm/bare-root-worklist.mjs --self-test,scripts/pm/release-rehearsal-clone.mjs --self-test.pnpm --filter @objectstack/runtime exec vitest runover the sixapp-plugin*suites — 6 files, 80 tests passed, including the two-readerpoliciesandcapabilitiescases.pnpm --filter @objectstack/metadata exec vitest run src/plugin.test.ts— 1 file, 17 tests passed. Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), and the door'sdist/was confirmed to carry the retirement and not the removed entry.pnpm --filter @objectstack/runtime typecheck—tsc --noEmit, Done. NOT MEASURED for@objectstack/metadata: that package declares notypecheckscript, so the filter matched zero scripts there and exited 0 without checking anything; its type surface is covered by its own build (tsup+check-dts-emitted.mjs, green twice here) and its ledger entry, andcheck:type-check-coverageis green.node scripts/pm/check-half-states.mjsreportedPREREQUISITE NOT MET(exit 3) — this container has no valid GitHub credential, so it swept nothing. That is no reading, not a red gate; the wiredpnpm check:pm-half-statesself-test is green.Generated by Claude Code