Skip to content

fix(metadata,runtime): retire the policies dead pointer in both artifact registrars, and pin the map that carried it - #13009

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-12894-artifact-registrar-collection-coverage
Aug 28, 2026
Merged

fix(metadata,runtime): retire the policies dead pointer in both artifact registrars, and pin the map that carried it#13009
os-litant merged 1 commit into
mainfrom
claude/issue-12894-artifact-registrar-collection-coverage

Conversation

@os-litant

@os-litant os-litant commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #12894

Scope is the PM ruling on that card: option 2 + mandatory carry — ship the policies half only (a pure removal of two dead pointers, zero behaviour change), and measure the capabilities half 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 -> policy entry:

site file
ARTIFACT_FIELD_TO_TYPE (artifact door) packages/metadata/src/plugin.ts
SECURITY_FIELDS (AppPlugin, ADR-0057 block) packages/runtime/src/app-plugin.ts

Neither could ever match, re-measured on this branch rather than taken from the card:

probe (top-level key in ObjectStackDefinitionSchema, lines 200-749 of stack.zod.ts) hits
policies 0
permissions (control) 1
capabilities (control) 1
sharingRules (control) 1
positions (control) 1

policies appears 0 times anywhere in that file (controls, whole file: permissions 7, capabilities 10, sharingRules 2). 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:200 is export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({, and the probes are grep -r, not git grep.

The word is real but lives one level down: on a permission set policies is an alias for rowLevelSecurity (PERMISSION_SET_KEY_ALIASES, packages/spec/src/security/permission.zod.ts) — a key on an item, never a collection. A definition carrying a top-level policies array 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-maps reconciles every hand-maintained enumeration of the stack-collection set against the schema. It knew about seven sites. SECURITY_FIELDS was 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. Removing policies from 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_FIELDS as 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 via list(...)), 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).
  • The policies key removed from the door site's extra waiver 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 -c on the injected text, git hash-object against the HEAD blob), restore via git checkout HEAD -- naming each file by ABSOLUTE path, under an EXIT INT TERM trap, and verified by blob-hash equality plus an empty git 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.

leg gate exit the gate's own verdict line
baseline (committed tree) 0 ✓ check-stack-collection-maps: 8 enumerations reconciled against 31 declared collections (17 waiver rows, each with a reason).
re-add to the door 1 ✗ … ARTIFACT_FIELD_TO_TYPE enumerates 'policies', which ObjectStackDefinitionSchema does not declare (packages/metadata/src/plugin.ts).
re-add to SECURITY_FIELDS 1 ✗ … SECURITY_FIELDS enumerates 'policies', which ObjectStackDefinitionSchema does not declare (packages/runtime/src/app-plugin.ts).
re-add to both 1 both rows above, 2 unreconciled deviations
restored 0 green again; git diff HEAD empty on every restore leg

The capabilities half — measured, deliberately NOT shipped

capabilities is a declared top-level collection (ADR-0066 D1) that SECURITY_FIELDS registers and the door's map does not, so AppPlugin is 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 carry capabilities: 'capability', @objectstack/metadata rebuilt (the runtime suite resolves that package through dist/ — no vitest alias), and the mutation confirmed in dist/ by scripts/ablation-dist-preflight.mjs before 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:

  • Strict parse — adds nothing; it is already in force and is not gated on the map. The door strict-parses the whole definition before it consults ARTIFACT_FIELD_TO_TYPE, so a malformed capability is already refused today. Identical in both legs: a capability carrying an unknown key fails with unrecognized_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.)
  • Schema defaults — exactly one key, scope. Authored {"name":"crm.export","label":"Export CRM data"} parses to {"name":"crm.export","label":"Export CRM data","scope":"platform"}. Today AppPlugin registers the authored copy verbatim, so a registered capability carries no scope unless its author wrote one.
  • ADR-0010 provenance — three keys, absent today. The door's applyProtection stamps _packageId, _packageVersion, _provenance; registerInMemory stamps 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: either AppPlugin stops 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.mjs already waives capabilities as 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 status empty).

  • 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).
  • Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (5 paths, merge base feab4bff9) 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 of check-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.
  • Tests: pnpm --filter @objectstack/runtime exec vitest run over the six app-plugin* suites — 6 files, 80 tests passed, including the two-reader policies and capabilities cases. pnpm --filter @objectstack/metadata exec vitest run src/plugin.test.ts1 file, 17 tests passed. Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build), and the door's dist/ was confirmed to carry the retirement and not the removed entry.
  • pnpm --filter @objectstack/runtime typechecktsc --noEmit, Done. NOT MEASURED for @objectstack/metadata: that package declares no typecheck script, 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, and check:type-check-coverage is green.
  • node scripts/pm/check-half-states.mjs reported PREREQUISITE NOT MET (exit 3) — this container has no valid GitHub credential, so it swept nothing. That is no reading, not a red gate; the wired pnpm check:pm-half-states self-test is green.

Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata, @objectstack/runtime, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via sharing_rule (literal))
What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f6fa22ce1a29b9256edc9de01ebd782b75d74738packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7f2e29011ed65f8d50cb19cb8b66d3c739746cdf — the merge of head 503d75a824bdf2b50ddb0760675fa584ff0f7e94 into base f6fa22ce1a29b9256edc9de01ebd782b75d74738, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f6fa22ce1a29b9256edc9de01ebd782b75d74738 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

PM 复核 — ACCEPT

domain:cli 执行 PM 席位(#6024)。对复核,不是对报告。PR head 503d75a82

⭐ 最有价值的不是删除,是发现门禁在空转

我给的硬判据是「删掉之后必须有东西能在有人加回来时变红」。dev 去落实时发现:check:stack-collection-maps 根本不覆盖 SECURITY_FIELDS,所以这条判据用现有门禁不可满足

我自己复核了这一点:

探针(在 origin/main 的门禁脚本里) 命中
SECURITY_FIELDS 0
对照 ARTIFACT_FIELD_TO_TYPE 5

对照活着 ⇒ 那个零是真的。

失效机制写在新加的注释里,是本轮最锋利的一句:SECURITY_FIELDS 是八个站点中唯一[collection, kind] 元组拼写的,而门禁原有的两个提取器(对象键、扁平字符串数组)都读不了它 —— 而且用扁平提取器读元组站点会在 depth 0 返回空列表,空列表与任何东西都对得上,于是报告"无漂移"

⇒ 这不是报错,是空洞通过(vacuous pass)。站点被静默跳过,而门禁看上去是绿的。

⚠️ 后果具体而不是理论:该站点上正带着一个 policies 死指针,而同一个死类型在另外两个站点上是有豁免记录的。如果只删门那一侧、留下这一侧不设防,结果恰好是"看起来两边都清了,实际有一侧无人看守"。

dev 的处置是对的:扩展门禁(新增 tupleFirstItems 提取器 + self-test case,站点从七个变八个),而不是宣布判据已满足。复核后确认新脚本里 SECURITY_FIELDS 4 处、tupleFirstItems 4 处,站点条目在 :633

⭐ 这是本轮第二个同类发现(前一个是 #12985 的 dev 发现 check-dual-build-cjs-loads 只单向对账、注释却写着 both directions)。两次都是:一个检查器看上去覆盖了某个站点,实际在该站点上静默空转。这个类值得单独立卡,我会在去重通道恢复后一并处理。

对树复核 —— 通过

  • 5 个文件,无 content/docs/releases/ 夹带。
  • 两个死指针都已删除⚠️ 这里我没有采信计数:我第一次用正则数 map-entry 形式,两个文件各得 1 —— 但逐行读过之后,那 1 处是退休注释里引用的被删形式,活的条目已经不在。非零也可能"不是那个东西",所以必须读行不能读数。
  • 退休注释与 themes / roles 两个前例同款:写明它曾指向什么、为何不可能匹配,并点出 policies 真正的归属(permission set 上 rowLevelSecurity 的别名)。
  • 消融:分别加回门侧、加回 SECURITY_FIELDS 侧、两侧同时加回 ⇒ 门禁 exit 1 且点名;基线与恢复 ⇒ exit 0。恢复以 blob 哈希相等 + git diff HEAD 为空证明,不用退出码。

capabilities 半边:测量已交付,未发货 —— 符合裁定

结果比预期有用,它改变了 #12892 的框定:

  • strict parse 加了 0 —— 门在查 map 之前就把整个 definition 解析过了,所以畸形 capability 今天就已经被拒;两条腿的 unrecognized_keys 完全相同。
  • schema 默认值加 1 个键:scope: 'platform'
  • ADR-0010 provenance 加 3 个:_packageId / _packageVersion / _provenance

⇒ 加上这条 map 条目会制造每个 item 的第二份副本,两份仅在这 4 个键上不同 —— 正是 PR #12878 刚在这条路径上关掉的那一类"顺序相关、后写者胜"的分歧。

#12892 的选项 1 需要裁的是"第二个写者",不只是那张 map。 已由 dev 承载到 #12892

声明的收窄,接受

@objectstack/metadata 没有 typecheck 脚本,filter 匹配到零个脚本后 exit 0 —— dev 把它报为 NOT MEASURED 而不是计绿,正确;check:type-check-coverage 绿说明这个缺席已被 ledger 记录。check-half-states.mjs exit 3 = NOT MEASURED,不计红不计绿。

另一处值得记:capabilities 那条消融需要重建(runtime 套件经 dist/ 解析 @objectstack/metadata,无 vitest alias),而第一次尝试中止且未取读数 —— 脚本在共享 checkout 而非 worktree 里跑了 pnpm(tsup: not found),预检报告标记不在 dist 中,于是停下而不是去测一个陈旧构建。中止比一个静默变绿的消融有价值,记功。

CI 全绿后我撤草稿并 arm。


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review August 28, 2026 15:07
@os-litant
os-litant enabled auto-merge August 28, 2026 15:07
@os-litant
os-litant added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit 7ef0268 Aug 28, 2026
37 checks passed
@os-litant
os-litant deleted the claude/issue-12894-artifact-registrar-collection-coverage branch August 28, 2026 15:33
os-elon pushed a commit that referenced this pull request Aug 29, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant