Skip to content

fix(metadata-core): the object-schema field mask also removes a denied field's references from the served document (ADR-0106 D1) - #21743

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21723-metadata-mask
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21723-metadata-mask

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21723

Clause-②: yes (widening) — one new exported type, FlsContractRetention, on @objectstack/metadata-core's published ./testing entry; graded minor.

What changed

ADR-0106 D1 says a field the caller cannot read is removed from the served object schema whole. applyObjectSchemaMask (@objectstack/metadata-core) only removed the field's entry from fields. Other parts of the document that named the field went out unchanged. The mask now also removes a denied field's references from the rest of the served object document. Every exit that serves an object schema runs this one projection: the by-name read, the list read and the layered view, on the REST server and the runtime dispatcher. So all of them get the fix, and no exit needed its own edit.

The new module packages/metadata-core/src/object-schema-fls-references.ts classifies every position once. It has a table for the object level and a table for the field level:

Position kind Examples What happens to a reference to a denied field
Rule entry validations[], indexes[], activityMilestones[] The entry is dropped whole. The caller cannot evaluate a rule over a value it never receives, and the rule's text describes the field. The platform still enforces the stored rule on every write.
Role pointer nameField, displayNameField, imageField, stageField, tenancy.tenantField, lifecycle.ttl.field, a field's referenceVia The key is deleted, so the role falls back to its default.
Name list highlightFields, searchableFields, publicSharing.redactFields, list-view column lists, external.columnMap, and a readable field's relatedListColumns / dependsOn The denied entries are filtered out. A list left empty is deleted.
Expression titleFormat, field-group and row-CRUD predicates, publicSharing.eligibility, lifecycle onlyWhen, and a readable field's formula expression, visibleWhen / readonlyWhen / requiredWhen, relatedListFilter, defaultValue, autonumberFormat, per-option visibleWhen The key is deleted. The readable field itself stays.
Presentation entry listViews.*, actions[] Column lists are filtered. An entry whose filter, sort, predicate or params read a denied field is dropped, because serving it without that part would change what it does.
Inline grid inlineColumns, inlineAmountField (declared on the child's own master-detail field, naming the child's own columns) A column that is, or is computed (expr) from, a denied field is dropped; a denied amount field is deleted.
Foreign lookupColumns, lookupFilters, displayField, descriptionField, summaryOperations Left alone. These name another object's fields, which that object's own projection governs.

"References" is an identifier-token test: a string mentions x when one of its identifier tokens is exactly x. So record.x > 0 and {x} match, and x_code and X do not. In a classified position a key is a reference only inside a field-keyed block (a filter condition, lifecycle onlyWhen, an action patch; $ operator keys excluded), and values under closed-vocabulary keys (type, dialect, severity, …) are not read; the unclassified path tests every key. A list view keyed by a denied name is dropped. A list-view column in object form is dropped when any of its facets (field, prefix.field, summary.field) names a denied field; the column, prefix and summary tables are pinned to the live spec. A field's dependsOn object entries are read the same way. A dotted path whose root segment is a denied field counts as a reference to it. The reference walk carries a cycle guard. A key neither table classifies is deleted when it mentions a denied field. A new spec key therefore over-masks rather than leaks until it is classified. A pin holds the tables equal to the live ObjectSchema / FieldSchema / InlineGridColumnSchema / ListColumnSchema / ColumnPrefixSchema / ColumnSummaryConfigSchema key sets, in both directions, so a new key fails CI on the day it lands.

The projection is still pure. The shared cache's full copy is never mutated. A caller who is denied nothing gets the same document reference back, which is the D3 byte-identical guarantee. Key order is kept.

Scope decisions

  1. Object-level validation rules that reference a denied field are dropped, not redacted. A redacted rule (condition removed, message kept) still says the field exists. A rule is server policy, so dropping it from the served document loses no enforcement.
  2. References inside readable fields. Same-object name lists are filtered. Same-object expressions are deleted, and the field and its other facets are kept. Foreign-object name positions are not touched.
  3. Other top-level keys that carry field names. All are covered by the classification above. The full list is in OBJECT_REFERENCE_POSITIONS.

The contract

FLS_CONTRACT_OBJECT (@objectstack/metadata-core/testing) still has four fields, so the consumer suites that count them are unchanged. It now names those fields in every position kind above. The residue check in assertObjectSchemaMaskCase used to look for a quoted name. It now looks for an identifier token in every string leaf and key of the served document, so a name inside an expression fails it. Before, the contract's own readable-formula case passed while serving a formula that read the denied field. Projection cases also carry retained facts: things the masked document must still say, such as the rule over readable fields, the filtered lists and the readable sibling predicates. So a mask that deletes too much fails too. The unmasked cases check that every reference survives.

Verification

Second review round (head 80e5f775d): the contract-tier review's object-form list-view column finding and the dotted-path key gap are addressed. metadata-core 369/369 + typecheck; rest mask consumers 123/123; runtime meta-object-fls 85/85; ablation of the object-entry change turns 5 pins red, including three contract cases. dispatch-gates 59/62 green locally, 3 not measured (workspace-build / time-limit), left to CI.

Review round (head c0a2b9990 + changeset grade 8a8f488ae): an independent pre-merge review asked for the inline-grid positions, key-reading precision, a cycle guard, denied-name list-view keys and contract coverage; all are addressed. metadata-core vitest + typecheck 362/362; rest mask consumers 123/123; runtime meta-object-fls 85/85; ablation of the inline-grid and key-reading changes turns their pins red (5 and 1 failures); dispatch-gates 60/62 run green, 2 not measured (check:dual-build-cjs-loads needs a full workspace build, check:type-check-debt timed out locally — CI runs both). The figures below are from the first round.

All runs below are on head 4ab5f92b9. The final head, bbaec097c, changes only a docblock example. On it, metadata-core's build, typecheck and test passed again (350/350). So did check:nul-bytes, check:type-check-coverage, check:type-check-debt, check:doc-authoring, check:issue-citations, check:published-files, check:cross-package-test-inputs and check:test-source-alias, all exit 0.

  • pnpm --filter @objectstack/metadata-core build && … typecheck && … test: 18 files, 350 tests passed.
  • The contract table through every exit, after rebuilding metadata-core's dist:
    • @objectstack/rest: meta-object-fls, meta-item-save-capability-gate and meta-compound-save-and-reset-capability-gate, 3 files, 123 tests passed.
    • @objectstack/runtime: domains/meta-object-fls, 1 file, 85 tests passed.
  • Other mask consumers:
    • @objectstack/rest: 4 files, 445 tests passed.
    • @objectstack/runtime: 8 meta read/list/parity files, 856 tests passed.
  • Ablation, done through scripts/ablation-replace.mjs on the committed tree. The reference projection was replaced by the old { ...rec, fields: kept }. The anchor went 1 to 0 and the blob changed.
    • Result: 16 of 24 tests in object-schema-fls-references.test.ts failed. That includes all three contract projection cases, for example 'salary_grade' is gone from fields but is still referenced at $.stageField.
    • The file was restored to the HEAD blob, with an empty git diff HEAD.
  • Live check on a fresh boot of the stock example app:
    • The caller was a restricted member: a field-level grant withholds readable on several fields of one object.
    • The member's by-name, list and layered reads of that object were scanned for identifier references to any denied field. All three had zero, including the code and effective layers.
    • The admin control read of the same object still serves the field definitions, the rule and the related-list column.
  • Gates: dispatch-gates.mjs --commands derived 62 families for this change set. All 62 ran and exited 0. The reconciliation (--ran) reports 62 accounted for, with a derived zero NOT-MEASURED.
  • Lint, narrowed to the 4 changed TypeScript files: pnpm exec eslint --no-inline-config --format json reported 4 files, 0 errors and 0 warnings. eslint.config.mjs never enables type-aware linting, so this diff cannot change the verdict on any untouched file. The full pnpm lint is left to CI.

Acceptance notes

  • Out of scope, as ADR-0106 D5(4) defers it: other metadata types (view, page, dataset, …) that name a hidden field are a separate follow-up. This PR covers the object schema only.
  • Foreign-object positions. Object B's lookup to object A may list A's fields in lookupColumns / displayField, and a parent's summaryOperations.field names a child field. Those names are judged against B's denied set, never A's. Masking them needs A's readable set while B is served. That is a cross-object projection, not attempted here. On the stock example app no such position names a denied field (measured on the member's full list read).
  • Prose is not matched in readable field definitions or in list views and actions. Labels and descriptions are display text. Prose inside a dropped rule goes with the rule.

Generated by Claude Code

@github-actions github-actions Bot added the size/l label Oct 4, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-core, touching 100 documentable anchor(s).

69 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 1289925c0a5649768db44f47c04bbe4002e8f777.

⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: defaultValue (symbol, 30 pages), sharingModel (symbol, 43 pages)
  • 32 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 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 1289925c0a5649768db44f47c04bbe4002e8f777 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 85667e9d0597cf43f2e2e336516dd1dc2a79a589 — the merge of head 80e5f775deba32e6be3a0ef8a1a5f438037b9701 into base 1289925c0a5649768db44f47c04bbe4002e8f777, 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 85667e9d0597cf43f2e2e336516dd1dc2a79a589 && git checkout 85667e9d0597cf43f2e2e336516dd1dc2a79a589
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1289925c0a5649768db44f47c04bbe4002e8f777 80e5f775deba32e6be3a0ef8a1a5f438037b9701 && git checkout -B drift-repro 1289925c0a5649768db44f47c04bbe4002e8f777 && git merge --no-ff 80e5f775deba32e6be3a0ef8a1a5f438037b9701

node scripts/docs-audit/affected-docs.mjs --json 1289925c0a5649768db44f47c04bbe4002e8f777

⚠️ 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 1289925c0a5649768db44f47c04bbe4002e8f777 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… grid as this object's, and keys only where keys are fields

- `inlineColumns` / `inlineAmountField` are declared on the child's own
  master_detail field and name the child's own columns: a column that is a
  denied field, or is computed from one, is dropped; a denied amount field is
  deleted.
- In a classified position an object key is a reference only inside a
  field-keyed block (FilterCondition, lifecycle onlyWhen, action patch), and
  closed-vocabulary values (rule type, envelope dialect, …) are not read, so a
  denied field named like a schema word no longer drops every rule, view,
  action or CEL envelope. The unclassified fail-safe path still reads every key.
- The reference walk guards its path, so a cyclic document terminates.
- A list view keyed by a denied field's name is dropped.
- The contract fixture uses the real `expression` key and covers list views,
  actions and the inline grid.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@github-actions github-actions Bot added size/xl and removed size/l labels Oct 4, 2026
claude added 2 commits October 4, 2026 14:28
… field-keyed key reading

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…tention type on its ./testing entry

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8a8f488ae5bffcb5b69050d773f1e7ce1e398e2e
Local-runs: probe — the dispatching seat asked for the metadata-core vitest run at this head plus a scratch read of the reference-mask module over the position classes the prior review flagged; nothing else was built or re-run

① Derived judgments

  1. Published surface of @objectstack/metadata-core. ./testing: one new exported interface (FlsContractRetention); the fields arm of the exported FlsContractVerdict union gains an optional retained member; the as const fixture FLS_CONTRACT_OBJECT gains list-view, action and inline-grid facets and its formula field now uses the spec's expression key (the former spelling was never a FieldSchema key). assertObjectSchemaMaskCase, OBJECT_SCHEMA_MASK_CASES, ObjectSchemaMaskCase, ObjectSchemaMaskExit, ObjectSchemaMaskOutcome — signatures unchanged. Runtime entry: applyObjectSchemaMask signature unchanged; a project posture now also removes a denied field's references. Judged: accurate, and a widening.
  2. "Every position is classified, pinned both ways against the live ObjectSchema / FieldSchema / InlineGridColumnSchema key sets." Verified: the pins exist and run in both directions. Caveat: they are key-set pins on three shapes only; a nested shape under a classified key is not pinned, which is where the defect in item 3 lives.
  3. "Name lists — the denied entries are filtered out" (list-view column lists). INCORRECT for the object form of a column entry. The scrub judges an entry by its own field name alone; a same-object field pointer the spec declares on the entry's own shape (its prefix and footer-summary pointers) is served when it names a denied field. Class: residual same-object reference inside a classified name-list position. Severity: medium — a disclosure of the kind ADR-0106 D1 closes, in a position the PR table claims handled, not exercised by the contract fixture, so the suite stays green. Remedy class: read an object entry's other facets with the same reference test (drop the entry or the view) and give the fixture one entry in object form.
  4. The prior review's six items (inline grid classified as this object's; keys read only inside field-keyed blocks with a closed-vocabulary skip; cycle guard; denied-name list-view key; fixture key and coverage; docblock limitations): verified fixed by reading and by probe. No over-deletion found in the closed-vocabulary skip set; no classified position holding a field-keyed block was found outside the field-keyed key set.
  5. Low, not blocking: inside a field-keyed block the key is compared whole, while every other key and string reading is identifier-token based, so a path-spelled key whose root segment is a denied field is not read. Informational: a presentation entry whose non-field identifier (an action parameter name) equals a denied field is dropped — over-masking, never a leak.
  6. Disclosure hygiene: PR body, changeset, commit messages and code comments name no example-app object, field or persona and carry no reproduction recipe.

② Semver level

  • Clause-②: yes (widening) with a minor changeset: correct. A new exported type on the published ./testing subpath and an optional member added to an exported union arm are widenings; yes takes at least minor.
  • The behaviour change — a restricted caller's served object document omits more — enforces the already-declared ADR-0106 D1 contract on the runtime entry. No authorable key, export or config is removed or renamed, so no BREAKING arm and no ADR-0087 disposition is due. The fixture's key correction is test-only data consumed through the published driver; the four in-repo consumer suites read none of its properties. Judged: not a narrowing.

③ Boundary flags

  • Dev flags — other metadata types deferred per D5(4); foreign-object positions judged by their own object's projection; prose not matched in readable fields, list views and actions: acknowledged and consistent with ADR-0106. The prose boundary holds for the keys skipped — none of them interpolates record fields (action success copy interpolates the server result scope only).
  • Check-runs on this head when read (2026-10-04T14:54Z): 21 success, 5 skipped, 12 still in progress, 0 failed — not yet a full-green reading.
  • Regen-provenance: not applicable.

Implemented-by: claude/issue-21723-metadata-mask
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: FAIL — item ① 3: a classified name-list position still serves a same-object reference to a denied field through an entry's nested pointer; the PR's D1 claim does not hold for that position until it is read.


Generated by Claude Code

claude added 2 commits October 4, 2026 15:07
…mes a denied field

A list column in object form names fields of this object through its nested
prefix and summary pointers as well as its own field; the name-list scrub
read only the latter. Columns now go through a classified table pinned
against the live column schemas, so a new nested pointer is classified
before it ships. A dependsOn entry's param stays a remote key.

A dotted path rooted at a denied field (in a field-keyed block, a name list
or a pointer) is now read as a reference to that field.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…ject-schema mask scope

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 80e5f775deba32e6be3a0ef8a1a5f438037b9701
Local-runs: probe — the dispatching seat asked for the metadata-core vitest run at this head (18 files, 369/369) and a scratch read of the reference-mask module over the position classes the prior record flagged, with one spec build to serve both; nothing else was built or re-run

① Derived judgments

  1. The prior record's FAIL item (comment 5981291233, ① 3 — an object-form list-view column serving a same-object pointer to a denied field through its nested facets): fixed. A column entry now goes through a classified table; the entry is dropped when ANY facet reads a denied field (its own field, its prefix.field, its summary.field), and a facet the table does not classify is read fail-safe (any mention drops the entry). The column, prefix and summary tables are pinned both ways against the live ListColumnSchema / ColumnPrefixSchema / ColumnSummaryConfigSchema key sets, and the shared contract fixture carries a view in object form whose retained facts hold the readable sibling column. Verified by reading and by probe. No new over-deletion: a readable column stays when only a sibling column is denied; the bare summary vocabulary, a renderer type and a click action id are not read as fields; an emptied list is deleted and the view stays.
  2. The prior record's low item (① 5 — a path-spelled key whose root segment is a denied field was not read): fixed. A name, a pointer and a field-keyed key are matched on the root segment as well as whole, so a dotted path through a denied field is a reference to it in a field-keyed block (including under a $ operator), a name list and a role pointer alike. Controls hold: a path rooted at a readable field is kept, and a NON-root segment equal to the denied name is not a reference.
  3. dependsOn object entries are classified: field is a sibling pointer, param is the lookup target's key and is kept. An unclassified facet on such an entry is read fail-safe. Judged: accurate, and not an over-deletion.
  4. Published surface. No export is added to or changed on the ./testing entry this round; the as const fixture FLS_CONTRACT_OBJECT gains one list view in object form (a widening of the exported fixture's literal type). The new *_POSITIONS constants are exported from the references module only, which the package's runtime index does not re-export — package-internal, read by the pin test. The runtime entry applyObjectSchemaMask keeps its signature; a project posture removes more.
  5. Sweep for remaining nested same-object pointers under a classified position: none found. Every same-object pointer declared on a nested list-view shape (the board, calendar, gantt, gallery, timeline, chart, map, tree, grouping, row-colour, conditional-formatting, sort, filter-rule and user-filter sub-configs) and on a nested action shape (params and their options, patch, ai.paramHints, the row-id field, the target template, the body extras, the predicates) sits under a key the classified reading reads, so a denied name there drops the entry whole; none sits under a prose or vocabulary key. On the field level the remaining nested shapes (select options, inline-grid column options, roll-up summary, lookup columns and filters, currency config, storage) either name another object's fields or carry no field pointer.
  6. The informational over-mask from the prior record (an action param whose name equals a denied field drops the action) is now written into the module's accepted-limitations list with its reason. Consistent: over-masking never discloses.
  7. Disclosure hygiene: the PR body, the changeset, the two commit messages and the code comments of this round name no example-app object, field or persona and carry no reproduction recipe.

② Semver level

  • The PR body's Clause-②: yes (widening) with a minor changeset for @objectstack/metadata-core: still correct. The widening is the one the prior record graded (one new exported interface and one optional member on the ./testing entry); this round adds no further export there, and the fixture's literal-type growth is the same class. yes takes at least minor; minor is declared. No authorable key, export or config is removed or renamed, so no BREAKING arm and no ADR-0087 disposition is due.
  • The changeset body carries Clause-②: no with no direction arm. The gate that reads that carrier looks for a narrowing arm, and none is declared, which is right for this diff; but the value disagrees with the PR body's yes on the same question. Not blocking (neither gate cross-reads the other carrier and the diff narrows nothing); flagged below for alignment.

③ Boundary flags

  • Dev flags acknowledged and consistent with ADR-0106: other metadata types deferred per D5(4); foreign-object positions judged by their own object's projection; prose not matched in readable field definitions, list views and actions; a param name equal to a denied field over-masks by design.
  • The vocabulary skip for schema is total in a classified position. The only schema-keyed subtrees reachable there describe a JSON field's value shape (a json_schema rule on a readable field) or a metadata type's shape (a list view's schema data provider), not this object's record, so a name inside one is not a reference to a field; this sits inside the prose-and-vocabulary boundary the dev already recorded. Informational.
  • Owning seat: align the changeset body's Clause-② value with the PR body's yes (widening) so the two carriers say the same thing; a follow-up edit, not a condition of this verdict.
  • Check-runs on this head when read (2026-10-04T15:46Z): 31 success, 5 skipped, 4 still in progress (three Test Core shards and Lint and Repo Gates), 0 failed — not yet a full-green reading; the Tier S landing waits for it.
  • Regen-provenance: not applicable.

Implemented-by: claude/issue-21723-metadata-mask
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: PASS


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…cision in words instead of a tracker number (stage 13) (objectstack-ai#21763)

Part of objectstack-ai#20749
Clause-②: no

Stage 13 of this card, and the fourth area of class (e): the test
strings shipped under `packages/spec/src`, as ruled in `5902360492` on
objectstack-ai#20513. This stage takes two whole directories, `contracts/` and
`conversions/`. Their 97 test-title and test-string literals carried 108
tracker ids citing 78 records. 107 ids in 96 literals now either state
what their record decided, in words (form D), or are dropped where the
title already says it. One id stays, for the reason given below. Text
only: no assertion, identifier, test count or code comment changes.

## Census at the base (`866b4393d0`, the claim's base)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`) and
`census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`),
byte-identical to the copies stages 10 to 12 used. A literal counts as a
test title when its folded message is argument 0 of a `describe` / `it`
/ `test` call, `.each` / `.skip` / `.only` chains included. Everything
else is an "other" string.

Both instruments read **1509 messages / 1606 ids in 348 files at the
base**, which is stage 12's head reading exactly. `contracts/` reads 63
/ 74 and `conversions/` 34 / 34, also stage 12's figures.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 |
| `ui/` | 81 | 392 / 415 | 374 / 397 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| **`contracts/`** (this PR) | 25 | **63 / 74** | 59 / 70 | 4 / 4 |
| **`conversions/`** (this PR) | 9 | **34 / 34** | 34 / 34 | 0 |
| `security/` | 8 | 28 / 28 | 28 / 28 | 0 |
| `ai/` | 9 | 18 / 20 | 13 / 15 | 5 / 5 |
| `identity/` | 6 | 15 / 15 | 14 / 14 | 1 / 1 |
| `integration/` | 4 | 14 / 14 | 13 / 13 | 1 / 1 |
| `migrations/` | 2 | 9 / 12 | 9 / 12 | 0 |
| `marketplace/`, `meta-spelling/`, `studio/` | 5 | 7 / 7 | 7 / 7 | 0 |
| **total** | **348** | **1509 / 1606** | **1422 / 1515** | **87 / 91**
|

- **Controls.** Lit, a title: `contracts/data-engine.test.ts:575` reads
one message with four ids. Lit, an `expect` message:
`contracts/metadata-service-roundtrip-conformance.test.ts:142` reads one
message. Dark: the comment at
`contracts/core-service-contracts.test.ts:3` ("[objectstack-ai#4127] The map claims a
binding per slot") reads 0. Planted in a scratch copy: an id put back
into a title reads 1 / 1, and an id in an added comment reads 0.
- **A wider pattern** (any `#` plus digits) reads 63 / 75 under
`contracts/` and 35 / 36 under `conversions/` test files at the base.
The extras are `(batch objectstack-ai#76)` in the
`resume-failure-report.pin.test.ts:137` title, and `PD objectstack-ai#12` (Prime
Directive 12, contract-first) in two `conversions.test.ts` titles,
`:180` and `:785`. None matches the gate's 3-to-5-digit pattern.
- **At the head:** 1413 messages / 1499 ids in 315 files. `contracts/`
reads 1 / 1 (the needle below), `conversions/` 0 / 0. Nothing else
moved. The wider pattern reads that needle and the two `PD objectstack-ai#12` titles,
and nothing else.

## How the area was chosen

Stage 10's rule: rank whole first-level directories by ids, and take the
busiest within about 10% of the ~100-id bound. `data/` (501), `ui/`
(415), `api/` (201) and `system/` (165) each exceed it alone, and the
files directly in `src/` (120) are 20% over. No single remaining
directory fits except smaller ones, and `contracts/` with `conversions/`
reads exactly 108, within 10% of the bound. That is the pairing the
stage-10 ACCEPT named, so the rule needed no second pass.

**Named for the next stages:** `data/` (about five stages, by
subdirectory or file group; `data/driver/` alone is 52), `ui/` (about
four), `api/` (two), `system/` (two), the files directly in `src/` (one,
120), and `security/`, `ai/`, `identity/`, `integration/`,
`migrations/`, `marketplace/`, `meta-spelling/` and `studio/` together
(one, 96).

## What each id became

32 ids in 21 literals now state a decision in words. 75 ids in 75
literals are dropped where the title already says what the record
decided. Every cited record was read with its comments through REST: 71
answer 200, and 7 answer 404 (objectstack-ai#6345, objectstack-ai#6523, objectstack-ai#11741, objectstack-ai#12010, objectstack-ai#12248,
objectstack-ai#16559, objectstack-ai#16786). For those seven the decision was read from what landed:
the landing commit and the CHANGELOG entry.

| record(s) | literal | now reads |
|:--|:--|:--|
| objectstack-ai#16293 | `action-confirmation-contract.pin.test.ts:63` |
"action-confirmation contract — an unconfirmed gated action is refused".
The ruling: a gated action without an explicit confirmation is refused
loudly, with the way to confirm. |
| objectstack-ai#10331 | `approval-service.test.ts:23` | "approval rows declare the
organization_id they are stamped with". The finding's first reading
landed: both row types declare it, optional and nullable. |
| objectstack-ai#19846 | `automation-context-caller-param-keys.pin.test.ts:49` |
"AutomationContext.callerParamKeys — the keys the caller supplied". The
ruling replaced the headless-screen inference with this explicit signal.
|
| objectstack-ai#18235 | `automation-service.test.ts:422` | "FlowRuntimeState —
carries the reason a flow is unbound", so a policy-disabled flow reads
differently from a broken binding. |
| objectstack-ai#15937 | `confirmed-blueprint-identity-contract.pin.test.ts:52` |
"confirmedBlueprintIdentity — declared on the protocol
ToolExecutionContext". The maintainer chose option 1: declare it in the
protocol, not only on cloud's augmentation. |
| objectstack-ai#11493 (2) | `data-driver.test.ts:290`, `data-engine.test.ts:455` |
"introspectSchema — an optional driver member at the spec shape" and
"introspectDatasource — answers the spec introspection shape". Both
ruled steps. |
| objectstack-ai#12248, objectstack-ai#11833 | `data-engine.test.ts:514` | "datasource resolution
members — declared, and optional". Fork 1 of the objectstack-ai#11833 ruling, option
A, landed as `8425c17cc`. |
| objectstack-ai#12248, objectstack-ai#12010, objectstack-ai#12805, objectstack-ai#11833 | `data-engine.test.ts:575` |
"datasource lifecycle members — declared at the shape the engine keeps".
Item 4 of the objectstack-ai#11833 ruling put `ConnectionEngineLike`'s members on the
contract (`8425c17cc`, `77b91bd`), and objectstack-ai#12805 caught the declared def up
to what the engine retains. |
| objectstack-ai#12482, objectstack-ai#12010, objectstack-ai#11833 | `data-engine.test.ts:679` | "syncObjectSchema
— declared, since two services already call it". |
| objectstack-ai#5040 | `http-server.test.ts:183` | "optional setFallbackHandler — a
not-found hook, not a wildcard route". The design's option C, so a
declared endpoint never shadows a registered route. |
| objectstack-ai#9835 | `http-server.test.ts:316` | "optional afterResponse — a
transport-agnostic response observer". |
| objectstack-ai#6617 | `job-service.test.ts:126` | "JobHandler degraded-outcome
channel — optional and additive". |
| objectstack-ai#14766, objectstack-ai#14501 | `job-service.test.ts:263` | "IJobService.replay force
option — a succeeded window replays only when forced". The A + a2
ruling. |
| objectstack-ai#4127 | `notification-service.test.ts:153` | "inbox — declared on the
contract, and optional". |
| objectstack-ai#5928 | `objectql-engine-hook-scope.test.ts:41` |
"IObjectQLEngine.registerHook scope faces — global minus excluded
objects". The ruled A shape, `excludeObjects`. |
| objectstack-ai#12248, objectstack-ai#11833 | `objectql-engine.test.ts:29` | "getObject return
contract — a structured answer, not `unknown`". Fork 3. |
| objectstack-ai#12481, objectstack-ai#12248, objectstack-ai#11833 | `objectql-engine.test.ts:106` | "getSchema
return contract — the same answer as its alias getObject". Fork 3, one
member over. |
| objectstack-ai#20157, objectstack-ai#19995 | `objectql-engine.test.ts:163` | "judgeFilter contract
— the engine judges a filter without running it". Ruling C. |
| objectstack-ai#4539 | `sharing-service.test.ts:35` | "recipient vocabularies, each
under its own name". The same-name, different-form exports were split by
renaming. |
| objectstack-ai#5858 | `sharing-service.test.ts:194` | "HierarchyScopeContext tenancy
authority — organizationId is authoritative". |

**Dropped only (75 ids):** objectstack-ai#3903, objectstack-ai#4045, objectstack-ai#4127 (2), objectstack-ai#4158, objectstack-ai#4251, objectstack-ai#4343,
objectstack-ai#4347 (2), objectstack-ai#4401, objectstack-ai#4456 (2), objectstack-ai#4538 (2), objectstack-ai#4827, objectstack-ai#4829, objectstack-ai#4923 (5), objectstack-ai#5011,
objectstack-ai#5122, objectstack-ai#5125, objectstack-ai#5126, objectstack-ai#5493 (3), objectstack-ai#5777, objectstack-ai#5817, objectstack-ai#5945 (4), objectstack-ai#6345 (2),
objectstack-ai#6428, objectstack-ai#6430, objectstack-ai#6523, objectstack-ai#6775 (2), objectstack-ai#6776, objectstack-ai#7378 (3), objectstack-ai#7616 (2), objectstack-ai#8321,
objectstack-ai#11122 (2), objectstack-ai#11741, objectstack-ai#11832, objectstack-ai#13700, objectstack-ai#13937, objectstack-ai#14103, objectstack-ai#14244, objectstack-ai#14384,
objectstack-ai#14945, objectstack-ai#14969, objectstack-ai#15389, objectstack-ai#15429, objectstack-ai#16231, objectstack-ai#16495, objectstack-ai#16559, objectstack-ai#16693, objectstack-ai#16786,
objectstack-ai#19620, objectstack-ai#20323, objectstack-ai#20390, objectstack-ai#20740, objectstack-ai#20935, objectstack-ai#20940, objectstack-ai#21005, objectstack-ai#21220, objectstack-ai#21458.

- Each title already states the pinned decision. In `conversions/` most
titles are the conversion entry's own id ("action-aria-removed",
"app-hidden-to-unpublished"), which is the decision that landed.
- **Qualified references** were read before dropping: "objectstack-ai#13937 shape 4"
(keep the consume order and add an operator exit verb; the title already
names the repairable run), "objectstack-ai#4923 house rule" (equal values dedupe,
different values keep both; the title already says "keeps BOTH"), "objectstack-ai#4127
batch 3" (the ledger extends past the enum; the title says "beyond the
enum"), "objectstack-ai#4251 B3" (`IObjectQLEngine` widens `IDataEngine`; the title
says it), "objectstack-ai#5122 shape" (a wrapper that forwards only required members;
the title says it), and "objectstack-ai#7378 row 1" / "row 3" (both refusal messages
already state their row's ruling).
- **The 404 records:** objectstack-ai#6345 (`e2798fa`: one driver vocabulary, `mongo`
converged to `mongodb`), objectstack-ai#6523 (`aa4b90d`: enforcement takes the full
`ExecutionContext`), objectstack-ai#11741 (`b706af9`: an optional `organizationId` on
both email inputs), objectstack-ai#16559 (`c7aca0dce`: the resume failure declared
once, carried by a success answer), objectstack-ai#16786 (`6059b29`: `updateById`
declares the record or `null`). Each title already carries what landed.
- **`(batch objectstack-ai#76)`** in `resume-failure-report.pin.test.ts:137` goes with
`objectstack-ai#16559` in the same literal. It names the decision batch whose ruling
the title already states ("the resume failure a success answer
carries"). It is outside the gate's pattern, and it is declared here.
- **`PD objectstack-ai#12`** stays in `conversions.test.ts:785`. It is a Prime
Directive reference in AGENTS.md, not a tracker number, and the
untouched sibling title at `:180` spells it the same way.

## The one id that stays

`contracts/approval-service.test.ts:274` is
`expect(doc).toContain('objectstack-ai#16495')`. It is not a title: it is the expected
value of an assertion that reads the `continueRestoredRun` docblock in
`contracts/approval-service.ts` and pins that the docblock names the
sibling it was ruled to copy. Changing it needs a code comment and
assertion logic, which this claim excludes. It moves with that docblock
when the comment lane rewrites `approval-service.ts:999`.

## Readers

- **Test-name filters:** none. A tracked-tree search for `-t` and
`--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t
"owner-scoped"`), which is unrelated.
- **Snapshots:** none. Neither directory has `__snapshots__`, and no
`.snap` file is tracked under `packages/spec`.
- **Projects:** none of the 34 files is listed in
`packages/spec/vitest.repo-tests.json`, so all run in the `local`
project.
- **By substring:** every old literal, plus a window around each id (241
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The 17 needle hits land on 11 lines:
- Sibling test strings in other lanes' packages:
`packages/objectql/src/metadata-service-roundtrip-conformance.test.ts:232`
(the objectql driver of the same table, `register must REFUSE this write
(objectstack-ai#7378)`);
`packages/plugins/plugin-security/src/get-queryable-fields.test.ts:136`
and `:165` (`[objectstack-ai#20935]`),
`resolve-permission-sets-for-context.pin.test.ts:103` (`[objectstack-ai#7616]`),
`authored-row-write-verdict.test.ts:350` (`[objectstack-ai#5493]`); and
`packages/metadata-core/src/artifact-forward-conversion.test.ts:277`
(`(ADR-0113, objectstack-ai#16693)`).
- Code comments:
`packages/spec/src/contracts/automation-service.ts:1061` (`objectstack-ai#13937 shape
4`), `packages/spec/src/contracts/index.ts:44` (`(objectstack-ai#4127)`),
`packages/cli/src/utils/view-container-names.ts:11` and
`packages/objectql/src/view-container-name-refusal.ts:13` (`objectstack-ai#7378 row
1`).

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. The declared lines are the three in
`metadata-service-roundtrip-conformance.test.ts`: the reference double's
two refusal messages (`:68`, `:75`) and the `expect` message at `:142`.
No test asserts on the dropped text: the refusal checks assert `code`,
`status` and the write's coordinates.

- **Result:** 33 of 34 files SAME on all three legs.
`conversions.test.ts` passes the skeleton and comment legs and is
flagged on one string, `:785`, because its rewritten title keeps `PD
objectstack-ai#12`. That was predicted in writing before the run. With `PD objectstack-ai#12`
spelled `PD-12` in both the base and head copies of that one line, the
file reads SAME with 19 changed titles.
- **Totals:** 96 changed literals, 93 titles and 3 declared. The diff's
`+` lines are exactly the 96 planned lines, and every file keeps its
line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string keeping an
id VIOLATION; an undeclared `expect` message changed VIOLATION; a title
re-split into a `+` chain DIFF.

**Test counts:** the 34 files were run at the base (in a separate base
worktree at `866b4393d0`) and at the head, in the `local` project. Both
sides read 595 / 595 passed, with the same count and status sequence per
file in 34 of 34. 354 full test names change, and each equals the base
name with the planned replacements applied. One full name repeats on
both sides: two `sqlite` rows of the `stored.test.ts` `it.each` table
share the `%s` name. That predates this PR.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
34 touched files are in it, and no `*.test.ts` at all. The controls
`src/shared/expression.zod.ts` and `dist/contracts/index.js` are in it.
- In `dist/`, four new phrases and three old ones each read in 0 files.
The control `Unrecognized key(s) on` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `72513933ee`)

- `pnpm turbo run build` over all packages: 71 / 71.
- `@objectstack/spec`:
  - `vitest run --project local`: 614 files, 18285 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck`. Its program
holds all 34 touched files, counted with `tsc --listFilesOnly`.
- **Gates:** `dispatch-gates --commands` derived 79 families (stage 12's
80 without `check:future-spec-major`, which no touched file feeds), and
all 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0
UNRUN.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 34
files, 0 errors and 0 warnings. The population comes from ESLint's own
config: 34 configured, 0 ignored. No `parserOptions.project` or
`projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 192 changed lines.

## Acceptance notes

- **The `objectstack-ai#16495` needle** at `approval-service.test.ts:274` stays, with
the `approval-service.ts:999` docblock it pins. The comment lane owns
both.
- **Code comments still carry ids** in these 34 files and in the two
directories' sources. Comments are not this card's share and are
untouched here.
- **Sibling test strings in other packages** repeat ids this PR dropped:
the objectql driver of the round-trip table, three `plugin-security`
test files and one `metadata-core` test (listed under Readers). Each is
its own lane's test-string stage.
- **`origin/main` moved** five commits past the base before this PR
opened (objectstack-ai#21752, objectstack-ai#21754, objectstack-ai#21753, objectstack-ai#21751, objectstack-ai#21743). None touches
`packages/spec`, so nothing was merged. objectstack-ai#21756, which also edits
`contracts/security-service.test.ts`, has no PR yet; whichever lands
later merges `origin/main`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

2 participants