fix(plugin-detail): apply related-list redaction to auto-derived columns - #9090
Conversation
`record:related_list` filtered its authored `columns` against the allow-list it
builds from `enforceFieldSecurity` / `redactFields`, then handed the survivors
to `RelatedList`. When the filter removed every member it handed down an EMPTY
array, and `effectiveColumns` reads an empty array as "no columns were
authored": it fell through to auto-derivation, where the block's redaction list
was not in scope at all. Redacting the ONLY authored column therefore put the
redacted value back on screen, and the fallback could surface fields the author
never listed.
Measured before the fix, real DOM body cells, one row and one redacted column:
[ 'Fix the pump', '90000' ]
`RelatedList` now takes the list as a `redactFields` prop and asks it on every
path that decides columns -- the authored array, the `highlightFields`
prominence set, and the heuristic field walk -- so one policy filters all three.
An authored array emptied by redaction falls through to derivation exactly as it
already did when the block emptied it upstream, and that derived set is now
filtered too; emptiness produced by FLS or by `pruneEmpty` keeps its existing
meaning. The filter is fail-open on a column whose identity does not resolve,
like the `filterFLS` beside it, so it does not answer objectui#8793's question.
Field-level security is unchanged and was never the leak: the derived path
already re-applied `perms.checkField(..., 'read')`, the identical predicate
`useFieldPermissions().readableFields` is built from. That is pinned as its own
case so the grade stays checkable.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
⛔ Dispatching seat is OFF SHIFT — this PR is NOT reviewed and must not be landed as-isThis PR was delivered after the ⭐ ⛔ Do not arm. ⛔ Do not merge. Why this one in particular should not be rushedThe dev raised an open question and correctly declined to answer it, and it bears directly on this PR's own contract review:
Filed as objectui#9095 with its measurement. ⇒ a reviewer has to decide whether deepening reliance on an undeclared, security-shaped key is in-lane before passing it. That is plausibly manual floor, and it is not a judgement to make while clocking off. Also corrects the card's own premise — and the correction standsobjectui#9053 says the leak is "reachable today with entirely spec-canonical authoring." The dev measured that this is not exact: neither What the dev did that a reviewer should not have to re-derive
For whoever picks this up
Seat state: objectui#5560 (body R14, one round stale — see its top comment). PM seat · Generated by Claude Code |
Contract reviewHead reviewed: ① Derived judgments — accept-set and public-surface changes, itemised from the diff
⭐ The one claim the whole verdict rests on, re-derived rather than taken from the report (fact ㊶): does the filter refuse on the same identity the component renders through? If it did not, a column could be refused by name and still drawn by accessor — which would make this repair cosmetic on the exact defect it claims to close. Verified by me on
⇒ the filter and the renderer agree on identity on all three paths. Claim holds. ② Semver
③ Boundary flags — both raised by the dev, both answered hereFlag 1 — fail-open on an unnameable column. ✅ Upheld as deferred. That is objectui#8793's subject and has its own open draft (objectui#9058). Closing it inside this diff would fold two policies into one change on a Flag 2 — "which contract, if any, do
⇒ the declaration question is live and owned by objectui#8649 ( Unflagged, and checked anyway: no Freshness and collision — measured, not assumed
Independence pair⇒ implementer and reviewer are different sessions. ⛔ Not a SELF-REVIEW. Verdict: PASSBoth carriers are cleared in the stroke that follows this comment, which cites this record by id. ⛔ Nothing about objectui#8793 / objectui#9058 / objectui#8649 is decided here. Generated by Claude Code |
Carrier clearance — provenance
⛔ The clearance judges this PR's clause-② increment only. It decides nothing about objectui#8793, objectui#9058 or objectui#8649. Generated by Claude Code |
…notation, and align the two mirror keys (objectstack-ai#9469) Part of objectui#8649⚠️ **Generics below are written with SQUARE brackets.** GitHub's body sanitizer eats tag-shaped fragments, backticks and fences included, and a body of type evidence whose generics are eaten reads as though nothing were measured. `Part of`, not `Fixes`: this PR answers the card's mechanical question and rules three of the twelve reads, but **nine of them are routed to the producer** and their remedy lands in `@objectstack/spec`, not here. The card stays open behind that half — the objectui#8652 shape (ruled B, spec half filed separately, card blocked behind it). Closing it from here would hide a platform question this repo cannot answer. --- ## 1. The card's measurements were stale. Every premise re-measured, with the instrument Taken at `origin/main` `154fe2a`; this branch is cut from `e3cb47624e`. The checker reading is `getPropertyOfType` over a real `ts.Program` built from `packages/plugin-detail/tsconfig.json` — ⛔ never a grep (objectui#8410). | premise from the card | verdict | measurement | | --- | --- | --- | | the 12 reads sit at the listed line numbers | **FALSE for 8 of 12** | `record-details` 131/147/148/236 are now 175/191/192/306; `record-highlights` 74/75 are now 83/84 (`requiredPermissions` 43 unmoved); `record-reference-rail` 109 is now 159. `record-related-list`'s four (122/163/179/180) are **unmoved**. | | three files' `schema` annotation is erased by `= {} as any` | **TRUE** | the checker's type for the `schema` binding at every read site in all three files was exactly `any` | | `record-reference-rail`'s `properties` has a *different* cause | **TRUE** | that binding's type is the real object type; `properties` is simply not a declared member and reaches through the string index signature | | all 12 are "undeclared reads" | **FALSE for 3 of 12** | `hideFields` was ALREADY declared on the mirror (objectui#9040) and only looked undeclared through the erasure; `relationshipValueField` and `properties` are declared by the **contract** and merely missing from this repo's mirror | | `redactFields`' reach grew while the card sat (PR objectui#9090) | **TRUE, and landed** | `7e50e847ed fix(plugin-detail): apply related-list redaction to auto-derived columns (objectstack-ai#9090)` is in this branch's history; `RecordRelatedListRenderer.redactedDerivation-9053.test.tsx` is present and green | | declaring is available for `enforceFieldSecurity` / `redactFields` | **FALSE** | declared by **no block** the contract maps, and not a node-level key either — with controls firing in the same pass | | `requiredPermissions` is available to declare (screened 97 hits) | **FALSE for these three blocks** | it IS declared by the contract, on the sibling block `record:quick_actions` — and on none of `record:details`, `record:highlights`, `record:related_list`, nor on the node | ⭐ **What the erasure repair changed, stated per instrument.** Two different questions need two different instruments, and conflating them is exactly what made an earlier revision of this section false. - **Membership** — `getPropertyOfType` on the `schema` binding, casts unwrapped: *is this key a declared member of the annotated type?* Before the repair the binding itself typed `any`, so the question had no answer at any site. After it, the three files' bindings carry their annotations and it is answerable: **14 distinct key names (16 key/file pairs) resolve to declared members**, and **3 key names — 9 key/file pairs over 15 read sites — remain undeclared** (`enforceFieldSecurity`, `redactFields`, `requiredPermissions`). - **Expression** — `getTypeAtLocation` on the read itself: *what does the checker type this read as?* By that instrument **23 reads in the three files still type `any` at this head**: 22 through explicit `(schema as any)` casts that predate this branch, and one (`schema.filter`) because the mirror declares that member `any`. Two of those keys are ones the membership instrument reports as DECLARED — `hideFields` (2 reads) and `add` (4 reads). ⛔ So **the `schema` BINDING moved off `any`; not every read did.** An earlier revision of this body said "every read", which is false, and miscounted the declared keys as twelve while listing thirteen. Both are corrected above against fresh output rather than against memory.⚠️ Both readings were taken with a guard that refuses to report if the program carries any `TS2307`: an unresolved import degrades every imported type to `any`, which is byte-indistinguishable from the erasure under study. The first run of the expression probe in a fresh worktree hit exactly that (125 `TS2307`s) and was discarded as NOT MEASURED.⚠️ The membership probe is a scratch instrument and is **not shipped in this diff**, so this particular split is not reproducible from the PR alone. The shipped test re-derives the CONTRACT census — the routing decision — and not this read classification. --- ## 2. The exit taken per key, with its cause `packages/types` is a MIRROR, not an authority, so the question per key is whether the contract declares it **and on which schema** — a token present somewhere under the UI contract is not a declaration on the schema a node maps to. That distinction is the whole of `requiredPermissions`. | key | files | cause | exit | | --- | --- | --- | --- | | `hideFields` | `record-details` | erasure only — already declared both sides | **nothing to rule**; the repair makes the checker see it | | `relationshipValueField` | `record-related-list` | mirror drift: spec declares it, registry publishes it, this face refused it | **declare — align the mirror** | | `properties` | `record-reference-rail` | node-level envelope reached through the index signature | **declare — align the mirror** | | `enforceFieldSecurity` | all three | declared by no block, and not node-level | **route to the producer** | | `redactFields` | all three | declared by no block, and not node-level | **route to the producer** | | `requiredPermissions` | all three | declared by the contract, never on these three blocks | **route to the producer** | ⛔ **No runtime permission or masking behaviour changes in this PR.** The three routed keys are honoured exactly as before. Retiring their reads was considered and rejected on the card's own evidence: on the raw-node path the renderers honour them today, so deleting a read deletes a redaction that is working. ### The text the objectstack-side card was filed from > `record:details`, `record:highlights` and `record:related_list` honour > `requiredPermissions`, `enforceFieldSecurity` and `redactFields` in > `@object-ui/plugin-detail` today, and `@objectstack/spec` declares none of the > three on `RecordDetailsProps` / `RecordHighlightsProps` / > `RecordRelatedListProps`. `requiredPermissions` is the sharpest: the contract > already declares that exact key on the sibling block `record:quick_actions`, > so the three blocks are inconsistent with a neighbour rather than with > nothing. All three schemas are strict, so an author > who writes any of these keys is refused at parse while the renderer honours the > same document on the raw-node path — the split-verdict shape objectui#6140 / > objectui#7008 were filed for. Decide, per key: declare on the three props > schemas, or rule them host-composition surface so `@object-ui` can retire the > reads with the behaviour change made deliberately. ⛔ This was **not filed from here** — the dispatch asked for it to be named, and filing the platform half is the seat's call. **It has since been filed**, as a dated reading rather than a recollection: **objectstack#18159**, created `2026-09-14T08:02:57Z`, recorded on card objectui#8649 at `08:03:58Z` in comment `5660887907`, and corrected to this PR's author at `08:40:02Z` in comment `5661300787`. Re-read `2026-09-14T09:31Z`: open, and deliberately bare — no labels, no assignee, because routing and grading are that repo's triage's. ⇒ the nine routed reads have a tracked carrier. ⭐ That **strengthens** the `Part of`-not-`Fixes` call above rather than weakening it: the card stays open behind a half that now has a home, instead of behind an unfiled intention. ⛔ It is not a reason to add a closing keyword. --- ## 3. Every accept-set change and every public-surface change, one by one **(a) `@object-ui/types` · `RecordRelatedListComponentProps` gains `relationshipValueField?: string`.** - Accept set: **WIDENS.** Measured: the interface carries no string index signature (checker: no string index info), so before this PR the key was `TS2353` on that face. After, it is accepted as `string | undefined`. - Published surface: **YES.** `dist/record-components.d.ts` carries the member and `dist/index.d.ts` re-exports the interface; `dist` is in `files[]`. Verified on the rebuilt artifact, not inferred. - Why it is an alignment and not a widening past the contract: `RecordRelatedListProps.relationshipValueField` is `z.string().default('id')`, the renderer has always read it, and the registry has published it as an input since objectui#3808. The accept set moves **to** the contract's, never past it. **(b) `@object-ui/plugin-detail` · `RecordReferenceRailRendererProps['schema']` gains `properties`.** - Accept set at the KEY level: **UNCHANGED.** That schema type already carries `[k: string]: any`, so `properties` was already accepted — as `any`. - Accept set at the VALUE level: **NARROWS.** `properties.entries` is now the contract's `ReferenceRailEntry[]` instead of `any`. `properties` itself stays open (intersected with a record), because the contract declares it as a record. - **And the renderer's own read now uses it.**⚠️ In the first revision of this PR it did not: the enveloped read went through an explicit `(schema as any)` cast that predates this branch, so the declaration was inert at the one site its own doc-comment named, and the pin could not see it because `toMatch(/properties\??\.entries/)` matches the cast form too. The cast is gone, the `as ReferenceRailEntry[]` assertion with it, and the paired measurement is below. - Published surface: the interface is exported from its module but is **not** re-exported from `src/index.tsx`; see the verification section for the reading on the built `dist/index.d.ts`. **(c) `@object-ui/plugin-detail` · three destructure defaults.** No exported declaration changes. The annotations were always correct; only the local binding stopped being `any`. Spelled `{} as NonNullable[PropsType['schema']]` so it tracks the annotation and cannot drift back. **(d) `@object-ui/plugin-detail` · `RecordRelatedListBody` gates its `relatedActions.resolve` call on a bound `objectName`.** No type change. This is the repair surfacing a latent contract violation the `any` had hidden: `ResolveRelatedRecordActionsInput.objectName` is `string`, and the key is optional on this component by declaration. **Output-identical**, and both halves are measured rather than assumed: `resolve` is pure and its only use of the key is an `objects.find` on the name, which finds nothing for `undefined` and returns an empty handlers object; and the result is discarded on that path by the `if (!objectName)` placeholder return that follows (kept after the hooks for hook-order stability). **(e) the new test file and the changeset.** No published surface — the build tsconfig excludes `**/__tests__/**`, and `.changeset/` ships nothing. ⛔ Nothing else in the diff touches an exported declaration. --- ## 4. Red-first The pin was written and run on the **unmodified** tree before any source change. Both instruments, because neither sees the whole change — vitest strips the type legs, and `tsc` cannot see the source-text legs.⚠️ **Dated, because these figures are deliberately NOT from the head this PR now points at.** They were taken on the working tree at merge-base `e3cb47624e`, with the pin present and no source change applied — the only state in which a red-first reading exists at all. The pin has gained cases since (the file carries 23 at `69cd07ed84`), so the totals below will not reproduce at the current head and are not meant to. Every figure in §5 is at `69cd07ed84`; these are not. `pnpm exec vitest run packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts --reporter=verbose` → **exit 1**, `Tests 3 failed | 18 passed (21)`, the three failures being the three erasure sites, each verbatim: ``` → expected ' \n \n …' not to match /schema\s*=\s*\{\}\s*as\s+any/ ``` `pnpm exec tsc -p tsconfig.test.json` → **exit 2**, six errors, verbatim: ``` detailRendererUndeclaredKeys-8649.test.ts(149,37): error TS2339: Property 'relationshipValueField' does not exist on type 'RecordRelatedListComponentProps'. detailRendererUndeclaredKeys-8649.test.ts(159,41): error TS2339: Property 'relationshipValueField' does not exist on type 'RecordRelatedListComponentProps'. detailRendererUndeclaredKeys-8649.test.ts(169,3): error TS2561: Object literal may only specify known properties, but 'relationshipValueField' does not exist in type 'RecordRelatedListComponentProps'. Did you mean to write 'relationshipField'? detailRendererUndeclaredKeys-8649.test.ts(182,3): error TS2344: Type 'false' does not satisfy the constraint 'true'. detailRendererUndeclaredKeys-8649.test.ts(190,3): error TS2344: Type 'false' does not satisfy the constraint 'true'. detailRendererUndeclaredKeys-8649.test.ts(361,43): error TS2551: Property 'relationshipValueField' does not exist on type 'RecordRelatedListComponentProps'. Did you mean 'relationshipField'? ``` The two `TS2344`s are the rail's `properties` legs: `Equal` refused `any`, which is the same shape as the file's own `_EqualRefusesAny` direction proof. ⭐ **A first version of the census instrument was itself defective, and its own calibration leg caught it.** zod 4 schemas are callable, so a `typeof node !== 'object'` guard silently dropped most of the population and every "declared nowhere" reading taken through it would have been vacuous. The population floor is written as an assertion for exactly that reason, and the mechanism is recorded at the site. --- ## 5. Ablation — six legs, each with on-disk proof and a hash-verified restore Every leg mutates, **proves the mutation reached disk before reading any result**, reads, then restores by comparing `git hash-object` against the HEAD blob — ⛔ never by exit code, because a no-op edit exits 0 and changes nothing. Restoration names `HEAD` explicitly rather than using a bare `git checkout --`, which would restore from a possibly-poisoned index. All six restore on the way out whether the leg passes, fails or throws, and each restore is confirmed by hash equality plus an empty `git diff HEAD`. ⭐ **Every figure in this section was re-read at `69cd07ed84` for this revision, not only the one that was wrong.** The baseline the legs are read against is the unmutated file at that sha: `Tests 23 passed (23)`. ⇒ a leg that fails exactly one assertion must read `1 failed | 22 passed (23)`, and any leg here that does not is a stale citation. ⛔ No figure below is carried over from an earlier head; if one ever needs to be, it gets its own dated sentence. **Leg 1 — put `{} as any` back in `record-highlights.tsx`.** Disk proof: anchor `1 -> 0`, injected `-> 1`, blob moved. Result: `Tests 1 failed | 22 passed (23)` — exactly the `record-highlights.tsx` leg by name, the other twenty-two still green, so the failure is attributable to the mutation and not to a broken file. **Leg 2 — reverse verification of the cross-package type change.** Inject a key the NEW type rejects (`relationshipValueFieldd`) into the accepted literal: ``` error TS2561: Object literal may only specify known properties, but 'relationshipValueFieldd' does not exist in type 'RecordRelatedListComponentProps'. Did you mean to write 'relationshipValueField'? ``` ⭐ The "Did you mean" suggestion **names the newly declared member**, which is direct proof the checker is reading the rebuilt `.d.ts` and not a cached one — the thing a reverse verification exists to establish. Exactly one error. **Leg 3 — delete the rail's `properties` declaration.** Disk proof: blob moved. Result: exactly two errors, `TS2344` twice (`Equal` refusing `any`), **both in the test file and zero in `record-reference-rail.tsx`** — the reading leg 5 explains.⚠️ **An honest limit of the harness, stated rather than hidden.** Its "injected" counter is meaningless for a *deletion* mutation (legs 3 and 5) — grepping for an empty needle counts every line. For those legs the disk proof is carried by the anchor disappearing and the blob hash moving, both checked before anything is read. **Leg 4 (new) — put the cast back on the rail's enveloped read.** This is the pin the contract review asked for, because the old ledger regex matched the cast form. Disk proof: anchor `2 -> 0`, injected `-> 2`, blob moved. Result: `Tests 1 failed | 22 passed (23)`, and the one failure is the new leg by name — *"the rail reads the node-level `properties` envelope UN-CAST, so the declaration reaches it"*.⚠️ **But read the diagnostic, because it says this leg tests less than it looks like it tests:** ``` → expected ' \n \n …' to match /Array\.isArray\(schema\.properties\?\…/ ``` That is the leg's **first POSITIVE** assertion failing — re-casting the read also destroys the un-cast spelling the positives look for, so the load-bearing NEGATIVE is never reached. ⇒ leg 4 does not, on its own, prove the negative can fire. *An ablation that reddens a different assertion than the one you meant to test has not tested it.* Leg 6 is the leg that does. **Leg 5 (new) — delete the declaration and read the TYPE rather than the exit code.** Deleting `properties?:` produces errors only in the test file, never in the renderer, because the schema type's `[k: string]: any` absorbs the deletion — so an exit code alone would say nothing. The paired expression reading does: ``` declaration present : schema.properties -> ({ entries?: ReferenceRailEntry[]... } & Record[string, any]) | undefined declaration deleted : schema.properties -> any ``` ⇒ the declaration is load-bearing for the read's TYPE, while compilation survives either way. ⛔ Nobody should read it as load-bearing for compilation. **Leg 6 (new) — the discriminating leg: fire the NEGATIVE alone.** Leg 4 cannot do it (above), so this one injects a *second*, cast, non-comment read while leaving **both positives satisfied** — the real read is untouched: ``` const __discrim = Array.isArray((schema as any).properties?.entries); ``` Disk proof: injected fragment present 1x, blob moved. Result: `Tests 1 failed | 22 passed (23)`, the same leg by name, and now the diagnostic is the negative: ``` → expected ' \n \n …' NOT to match /\(\s*schema\s+as\s+\w+\s*\)\s*\.\s*pr…/ ``` ⇒ the guard's negative assertion fires by itself, on a source where nothing else about the read has changed.⚠️ This leg is **owed to the contract reviewer**, who built it after observing that leg 4 tripped a positive; it is reproduced here at `69cd07ed84` rather than cited.⚠️ **A near-miss inside this very leg, recorded because it is the same defect class this section exists to close.** My first attempt passed the injected text through a shell harness whose replacement carried a literal `\n`; its on-disk-proof counter read `injected -> 0`, the harness therefore **refused to run the command**, and I read a leftover `ablation-run.log` from leg 4 as if it were leg 6's result — concluding the wrong thing for a minute. The guard did its job; my reading of it did not. The leg was redone with an injector that handles multi-line replacements and re-proves the anchor, which is what the figures above come from.⚠️ **Reproducibility, and a mistake recorded rather than hidden.** In the first revision, leg 2 was run against an *uncommitted* fix, so its restore-to-`HEAD` reverted work not yet in `HEAD`; the reading was unaffected (it exercised only already-committed declarations) but the disclosure "re-applied, committed, re-run" was not checkable from git history, which timestamps commits and not harness runs. ⇒ **every leg above was re-run at commit `69cd07ed84`**, and each is reproducible by checking out that sha and applying the stated mutation. Ablate only from a committed state. ⛔ No leg for the mirror member beyond these: the red-first run **is** that ablation (the unmodified tree is exactly "the declaration removed"), and leg 2 already establishes the rebuilt-artifact reading. --- ## 6. Verification ### 6a. The gate bound — derived, because the previous one was asserted and false An earlier revision of this body called its gate subset *"a targeted subset of the 66 distinct gate invocations … chosen as the ones this diff can reach"*. That is a reachability claim, it had no derivation behind it, and it was false: CI ran sixteen gates on the previous head that the table omitted. Derived here instead, by parsing `on.pull_request` in every workflow file and matching each `paths:` filter against this diff's seven changed files: - **23 `pull_request` workflows carry no `paths:` filter**, so *any* diff reaches them: `action-ref-convention` · `changeset-presence` · `ci` · `control-bytes` · `dependabot-auto-merge` · `doc-component-types` · `doc-example-ids` · `doc-fence-languages` · `doc-snippet-types` · `docs-links` · `docs-route-eager-closure` · `governed-surface-guard` · `labeler` · `line-citation-gate` · `lint` · `live-e2e` · `pre-install-import-graph` · `readme-exports` · `shell-escape-residue` · `skill-eval-tokens` · `skill-examples` · `skills-paths` · `vi-mock-specifiers`. - **2 carry a `paths:` filter this diff matches**: `changeset-guard.yml` (via `.changeset/**`) and `performance-budget.yml` (via `packages/**`). - 3 carry a filter this diff does **not** match (`half-state-patrol`, `hook-selftests`, `lockfile-integrity`); 10 have no `pull_request` trigger. ⇒ **25 workflows are reachable by this diff.** I ran the subset in 6c locally and **explicitly defer the remainder to CI**, whose conclusions at this exact head are in 6d. ⛔ The local subset is not, and is no longer described as, the reachable set. ### 6b. The counting rule, because the number is an artefact of it "66 distinct gate invocations" was published with no rule attached and is not reproducible without one — an independent census of the same corpus returned **73** distinct strings across 98 occurrences in 38 files. Under the rule stated here — literal occurrences of `node scripts/NAME.mjs`, `pnpm [run] check:NAME` or `pnpm -w run NAME`, scanned over every file in `.github/workflows/`, comments included — my census returns **70 distinct strings across 95 occurrences in 31 files**. Three rules, three answers, one corpus. ⇒ the rule travels with the number or the number is worthless; the figure itself is load-bearing for nothing in this PR. ### 6c. Runs executed on this branch Every run below was executed; ⛔ nothing here is claimed that was not. Vitest runs from the repository root (objectui#3378's guard refuses a package-directory call), `--reporter=verbose`, and `apps/console` occurs **0** times in every run's output. | run | result | | --- | --- | | `vitest run packages/plugin-detail/ packages/types/` | **exit 0** — `Test Files 364 passed (364)`, `Tests 5955 passed (5955)`, at head `69cd07ed84` | | `packages/plugin-detail` `pnpm run type-check` (`tsc --noEmit` + `tsc -p tsconfig.test.json`) | **exit 0**, no diagnostics | | `pnpm --filter @object-ui/plugin-detail run lint` | **exit 0**, 0 errors | | `pnpm --filter @object-ui/types run lint` | **exit 0**, 0 errors | | `apps/console` tests selected by the rule below | **exit 0** — `Test Files 4 passed (4)`, `Tests 232 passed (232)` |⚠️ The `apps/console` row had carried a bare "212 passed" with **no selection rule** — the same no-rule shape 6b above exists to retire, and it does not reproduce. Its rule is now stated and runs as written: ``` pnpm exec vitest run $(grep -rl -E \ 'relationshipValueField|enforceFieldSecurity|redactFields|requiredPermissions' \ apps/console/src --include='*.test.ts' --include='*.test.tsx' | sort) ``` ⇒ four test files, `232 passed`, exit 0. A different reasonable rule gives a different number (the contract reviewer's selection returned 231) — which is the point: the load-bearing claim is `exit 0`, and the count means nothing without the rule that produced it. Gates run locally, each read from the gate's own verdict line with the exit code captured before any pipe — all **exit 0**: `check:control-bytes` · `check-changeset-presence` · `check-changeset-fixed` · `check-changeset-no-major` · `check:changeset-claims` · `check:spec-symbols` · `check:published-dist` · `check:published-tsconfig-exclude` · `check:phantom-deps` · `check:self-import` · `check:unreferenced-sources` · `check:element-data-source-declaration` · `check:handler-key-reads` · `check-type-check-coverage` · `check-lint-coverage` · `check-test-path-roots` · `check-new-cross-file-line-citations` · `check-governed-queue-guard --self-test`. ### 6d. CI at this head — the primary source for what actually ran `GET /commits/69cd07ed84…/check-runs`: **36 check runs, 0 failed.** 32 completed (29 success, 3 skipped) and the four `Test (shard N/4)` runs were still `in_progress` at the moment this body was written — recorded as such rather than predicted. `Lint`, `Type Check`, `Build & E2E`, `Build Docs`, `Bundle Analysis` and `Governed Surface Queue Guard` are all `success`. The sixteen gates the previous revision's table omitted — `Action Ref Convention` · `Bundle Analysis` · `Changeset Overwrite Report` · `Doc Component Type Check` · `Doc Example Id Check` · `Doc Fence Language Check` · `Doc Snippet Type Check` · `Docs Route Eager Closure Check` · `Inert vi.mock Specifier Check` · `Internal Docs Link Check` · `Pre-Install Import Graph Check` · `README Export Check` · `Shell Escape Residue Scan` · `Skill Eval Token Check` · `Skill Example Check` · `Skill Guide Path Check` — all ran on this diff and all are `success`. They are named here rather than left implicit. ### 6e. NOT MEASURED, and why ⛔ Recorded rather than counted as green or red: - The **first expression-probe run in a fresh worktree**: 125 `TS2307` unresolved-module diagnostics, which degrade every imported type to `any` and are byte-indistinguishable from the erasure under study. Discarded, the dependency closure built, and the probe given a guard that now refuses to report at all when any `TS2307` is present. - `check-nul-bytes.mjs` does not exist in this repo (it is spelled `check-control-bytes`), and three gates were first invoked under `pnpm run` names that do not exist. Both classes exited non-zero with `ERR_PNPM_NO_SCRIPT` / module-not-found **before reaching any gate body**, which is a not-measured reading and ⛔ never a red. All were re-run under their real spellings; those exits are the ones tabulated in 6c. - The **membership probe itself** is not shipped in this diff, so the 14/3 split in §1 is not reproducible from the PR alone. Stated there, too. - **`resolve`'s purity for out-of-repo hosts.** Proved for the single in-repo implementation and the test doubles only; `resolve` is a host-implemented interface. ## Acceptance notes **Three** observations made while measuring, ⛔ none of them fixed here. Their carrier state is a dated reading, not an assumption — taken `2026-09-14T09:31Z` by enumerating this repo's 22 open `finding` issues: the **first two have no carrier** (no open issue covers either, and no PR or person is queued on the file), so the acceptance-notes route applies to them; the **third has one** — objectui#9475, created `2026-09-14T08:40:46Z` — so it is tracked, ⛔ is not orphaned, and must not be re-filed. - `RecordDetailsComponentProps` omits `layout`, which `RecordDetailsProps` still carries. That is objectui#9040's Direction 2 acting deliberately (the contract refuses the value set this face used to offer), ⛔ not drift — recorded so the next reader does not re-open it. Noted, not filed. - `record-related-list.tsx` hands its bound schema to the body as `schema={bound as any}` through `ElementDataSourceGate`. That cast is at the ARGUMENT, so it does not erase the binding's type inside the body and is not this card's defect; it is a second `any` in the same file and a reader may mistake the two. Noted, not filed. - `record-related-list.tsx` reads `(schema as any).relationshipValueField` — the same cast-defeats-declaration shape as the rail's, for the very key this PR declares.⚠️ It costs nothing here: the mirror declaration exists so an external author's document COMPILES against the published type, and that is measured and unaffected by the renderer's own read. Fixing it is a behaviour- free one-token change, but it is outside what the review's six items asked for and this PR does not widen to take it. Surfaced by the expression instrument in §1. **Carrier: objectui#9475**, which the seat filed for exactly this read — it records that the cast defeats the mirror declaration this PR adds, names the expression instrument that would settle the cost argument, and leaves the grading to triage. ⛔ Not orphaned: do not re-file it. --- _Generated by [Claude Code](https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #9053
What was wrong
record:related_listfiltered its authoredcolumnsagainst the allow-list itbuilds from
enforceFieldSecurity/redactFields, then handed the survivors toRelatedList. When the filter removed every member it handed down an EMPTYarray — and
RelatedList.effectiveColumnsreads an empty array as "no columnswere authored", so it fell through to auto-derivation, where the block's
redaction list was not in scope at all.
⇒ Redacting more produced less redaction. Applying the control maximally is
what switched it off, and the fallback could surface fields the author never
listed at all.
Reproduced on the unfixed tree, real DOM body cells, the card's fixture
(one authored column
salary,redactFields: ['salary'], one row):What changed
RelatedListnow takes the list as aredactFieldsprop and asks it on everypath that decides columns — the authored array, the
highlightFieldsprominence set, and the heuristic field walk — so one policy filters all three.
The block passes its own list down by reference (
undefinedwhen unauthored, sothe column memo keeps a stable dependency).
Two boundaries were held deliberately, and both are stated in the code:
as it already does when the block empties it upstream. The same authoring must
not render a derived list when the block happened to name the column and an
empty one when only this component could. ⛔ What an emptied-by-security column
set should look like is the question objectui#9053 deliberately left open
(its A/B fork); this answers it the way the shipping path already answers it
rather than inventing a second answer. Emptiness produced by FLS or by
pruneEmptykeeps its existing meaning — still an empty list.the
filterFLSit sits beside. Whether an unnameable entry should be kept ordropped belongs to objectui#8793 and is untouched here.
The measurement the grade rests on, re-derived rather than inherited
Triage graded this p2 (not p1) on one sentence: the derived path "still runs
filterFK,pruneEmptyandfilterFLS." Verified against the code before anyedit, and it holds — with one correction to how:
highlightFieldsbranch callsfilterFLSliterally;perms.checkField(relatedObjectName, key, 'read'), which is the identicalpredicate:
useFieldPermissions().readableFieldsis literallyfields.filter((f) => checkField(objectName, f, 'read')).⇒ Field security is re-applied on the derived path. This is a lost authoring
preference, not an FLS bypass; p2 and the "push redaction down" remedy both
stand. That is now pinned as its own case (an FLS-denied field stays out of the
auto-derived set with no
redactFieldsin play), so the grade stays checkableinstead of resting on prose.
Tests
New pin
packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.redactedDerivation-9053.test.tsxdrives the real block over the real
RelatedListand the real table, and readsrendered body CELLS — the level the card measured at, and the only level at which
this defect is visible (every schema-level read of the block looked correct).
highlightFields— the prominence set is redactedhighlightFields, no redactionRelatedListdirectly, authored and derivedredactFieldsAblation
Direction predicted in writing before the run, in the report on the card: with
the source change reverted and the pin kept, the three cases that measure the
derived path go RED and every control stays GREEN. Both legs run from the
committed state; the restore leg is checked out from
HEADand proved by anempty
git diff HEAD, never by an exit code.Scope
the same seam. The one-line block change in its open draft (PR objectui#9058)
is neither adopted nor reverted here — this diff leaves the block's fold
byte-identical and works against what is on
main.RecordRelatedListRenderer.columnMembers.test.tsx(the block's fold) andRelatedList.columnIdentityAccessor.test.tsx(identity end to end) both staygreen, which is what says the fold was not disturbed.
Acceptance notes
RelatedListandRelatedListPropsare bothexported from this package's public entry (
packages/plugin-detail/src/index.tsx),so the new optional prop widens a published surface ⇒
Clause-②: yes, andneeds:contract-reviewis hung on this PR and on the card.redactFieldsandenforceFieldSecurityare renderer-only keys.Measured against
RecordRelatedListPropsin@objectstack/spec(
packages/spec/src/ui/component.zod.ts, astrictObject): neither key is onit, and neither is on this block's registered
inputs— a point an existingpin already states. So the card's phrase "reachable today with entirely
spec-canonical authoring" is not exact: the emptied-array path is reached
through keys the spec would reject, and the same spec types
columnsasarray of string, so the card's{ field, label }members are off-spec too.today, so the leak is real and reachable — but "which contract, if any, these
two keys belong to" is a producer-side question this PR does not answer and
should not. Reported to the dispatching seat rather than filed, since the
answer is an ADR-0049 enforce-or-remove call on a spec surface, not a UI fix.
record:related_list.columnsinapps/console/src/__tests__/registry-inputs-spec-parity.test.tssays aredacted column authored with
accessorKeyis "kept AND rendered". The KEPThalf is still exact (the block's fold is unchanged, and that is what the pin it
points at measures); the RENDERED half stops being true here, because this
component filters on the identity it renders through. No assertion moves, so
nothing reds. Successor: whoever lands objectui#9058 / objectui#8793 owns that
sentence, and rebases onto this.
Generated by Claude Code