Repository navigation
fix(metadata-core): the object-schema field mask also removes a denied field's references from the served document (ADR-0106 D1) - #21743
Conversation
…ld's references too (ADR-0106 D1) Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 69 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 4 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 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
|
… 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
… 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
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: 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 |
…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
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
Fixes #21723
Clause-②: yes (widening) — one new exported type,
FlsContractRetention, on@objectstack/metadata-core's published./testingentry; gradedminor.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 fromfields. 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.tsclassifies every position once. It has a table for the object level and a table for the field level:validations[],indexes[],activityMilestones[]nameField,displayNameField,imageField,stageField,tenancy.tenantField,lifecycle.ttl.field, a field'sreferenceViahighlightFields,searchableFields,publicSharing.redactFields, list-view column lists,external.columnMap, and a readable field'srelatedListColumns/dependsOntitleFormat, field-group and row-CRUD predicates,publicSharing.eligibility, lifecycleonlyWhen, and a readable field's formulaexpression,visibleWhen/readonlyWhen/requiredWhen,relatedListFilter,defaultValue,autonumberFormat, per-optionvisibleWhenlistViews.*,actions[]inlineColumns,inlineAmountField(declared on the child's own master-detail field, naming the child's own columns)expr) from, a denied field is dropped; a denied amount field is deleted.lookupColumns,lookupFilters,displayField,descriptionField,summaryOperations"References" is an identifier-token test: a string mentions
xwhen one of its identifier tokens is exactlyx. Sorecord.x > 0and{x}match, andx_codeandXdo not. In a classified position a key is a reference only inside a field-keyed block (a filter condition, lifecycleonlyWhen, 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'sdependsOnobject 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 liveObjectSchema/FieldSchema/InlineGridColumnSchema/ListColumnSchema/ColumnPrefixSchema/ColumnSummaryConfigSchemakey 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
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 inassertObjectSchemaMaskCaseused 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 carryretainedfacts: 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-core369/369 + typecheck; rest mask consumers 123/123; runtimemeta-object-fls85/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 grade8a8f488ae): 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-corevitest + typecheck 362/362; rest mask consumers 123/123; runtimemeta-object-fls85/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-loadsneeds a full workspace build,check:type-check-debttimed 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 didcheck:nul-bytes,check:type-check-coverage,check:type-check-debt,check:doc-authoring,check:issue-citations,check:published-files,check:cross-package-test-inputsandcheck:test-source-alias, all exit 0.pnpm --filter @objectstack/metadata-core build && … typecheck && … test: 18 files, 350 tests passed.metadata-core'sdist:@objectstack/rest:meta-object-fls,meta-item-save-capability-gateandmeta-compound-save-and-reset-capability-gate, 3 files, 123 tests passed.@objectstack/runtime:domains/meta-object-fls, 1 file, 85 tests passed.@objectstack/rest: 4 files, 445 tests passed.@objectstack/runtime: 8 meta read/list/parity files, 856 tests passed.scripts/ablation-replace.mjson the committed tree. The reference projection was replaced by the old{ ...rec, fields: kept }. The anchor went 1 to 0 and the blob changed.object-schema-fls-references.test.tsfailed. That includes all three contract projection cases, for example'salary_grade' is gone from fields but is still referenced at $.stageField.git diff HEAD.readableon several fields of one object.codeandeffectivelayers.dispatch-gates.mjs --commandsderived 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.pnpm exec eslint --no-inline-config --format jsonreported 4 files, 0 errors and 0 warnings.eslint.config.mjsnever enables type-aware linting, so this diff cannot change the verdict on any untouched file. The fullpnpm lintis left to CI.Acceptance notes
lookupColumns/displayField, and a parent'ssummaryOperations.fieldnames 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).Generated by Claude Code