Skip to content

fix(plugin-detail): apply related-list redaction to auto-derived columns - #9090

Merged
os-tesla merged 1 commit into
mainfrom
claude/issue-9053-related-list-redact-derived
Sep 11, 2026
Merged

os-tesla merged 1 commit into
mainfrom
claude/issue-9053-related-list-redact-derived

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #9053

What was wrong

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 RelatedList.effectiveColumns reads an empty array as "no columns
were 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):

AssertionError: expected [ 'Fix the pump', '90000' ] to not include '90000'

What changed

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.
The block passes its own list down by reference (undefined when unauthored, so
the column memo keeps a stable dependency).

Two boundaries were held deliberately, and both are stated in the code:

  • An authored array emptied by redaction falls through to derivation, exactly
    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
    pruneEmpty keeps its existing meaning — still an empty list.
  • The filter is fail-OPEN on a column whose identity does not resolve, like
    the filterFLS it sits beside. Whether an unnameable entry should be kept or
    dropped 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, pruneEmpty and filterFLS."
Verified against the code before any
edit, and it holds — with one correction to how:

  • the highlightFields branch calls filterFLS literally;
  • the heuristic walk does not call it by name — it inlines
    perms.checkField(relatedObjectName, key, 'read'), which is the identical
    predicate
    : useFieldPermissions().readableFields is literally
    fields.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 redactFields in play), so the grade stays checkable
instead of resting on prose.

Tests

New pin packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.redactedDerivation-9053.test.tsx
drives the real block over the real RelatedList and the real table, and reads
rendered 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).

case reads
MAXIMAL — the only authored column is redacted the card's own fixture; the value is gone, a non-redacted column still renders (non-vacuity)
CONTROL, partial — some authored columns redacted authored path taken, untouched by this change
CONTROL, no redaction the same column IS on screen — the positive control every negative above leans on
DERIVED, heuristic walk — no authored columns at all the walk is the only path, and redaction now reaches it
DERIVED, highlightFields — the prominence set is redacted falls through to the walk, which is filtered too
CONTROL, highlightFields, no redaction the prominence path still leads with it
RelatedList directly, authored and derived the prop means the same thing to a direct consumer on both paths
FLS on the derived path, no redactFields the grade's load-bearing measurement, asserted

Ablation

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 HEAD and proved by an
empty git diff HEAD, never by an exit code.

Scope

  • ⛔ objectui#8793 stays open and is not touched here; it is a different hole on
    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) and
    RelatedList.columnIdentityAccessor.test.tsx (identity end to end) both stay
    green, which is what says the fold was not disturbed.

Acceptance notes

  • Surface widening, declared. RelatedList and RelatedListProps are both
    exported from this package's public entry (packages/plugin-detail/src/index.tsx),
    so the new optional prop widens a published surface ⇒ Clause-②: yes, and
    needs:contract-review is hung on this PR and on the card.
  • ⚠️ redactFields and enforceFieldSecurity are renderer-only keys.
    Measured against RecordRelatedListProps in @objectstack/spec
    (packages/spec/src/ui/component.zod.ts, a strictObject): neither key is on
    it, and neither is on this block's registered inputs — a point an existing
    pin 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 columns as
    array of string, so the card's { field, label } members are off-spec too.
    ⚠️ This does NOT change the defect or the fix — the renderer honours both keys
    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.
  • Noted, not filed: the prose row for record:related_list.columns in
    apps/console/src/__tests__/registry-inputs-spec-parity.test.ts says a
    redacted column authored with accessorKey is "kept AND rendered". The KEPT
    half 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

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3491.3 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-CRwphroH.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.20KB 114.67KB
core (index.js) 8.28KB 3.31KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 208.91KB 57.80KB
fields (index.js) 247.14KB 62.34KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.50KB 19.97KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.21KB 34.62KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.88KB 65.30KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.51KB 40.89KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.07KB 14.32KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.54KB 3.31KB
plugin-view (index.js) 84.42KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

⛔ Dispatching seat is OFF SHIFT — this PR is NOT reviewed and must not be landed as-is

This PR was delivered after the domain:ui seat stood down (maintainer: 「当前任务处理完下班」). It arrived complete and the report is strong, but no in-seat clause-② review has been performed, so:

needs:contract-review stays HUNG on BOTH carriers, and that is the CORRECT state — not an oversight. contract-review.md:38 conditions clearing on a PASS. There is no PASS here. ⛔ Do not clear either carrier as routine cleanup; the review has to happen first, and it belongs to this card's dispatching seat (:27).

⛔ Do not arm. ⛔ Do not merge.

Why this one in particular should not be rushed

The dev raised an open question and correctly declined to answer it, and it bears directly on this PR's own contract review:

redactFields and enforceFieldSecurity are honoured by three plugin-detail blocks but are on no declared contractRecordRelatedListProps is a strictObject carrying neither, and neither is on the blocks' registered inputs. This PR makes one of them load-bearing on one more path.

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 stands

objectui#9053 says the leak is "reachable today with entirely spec-canonical authoring." The dev measured that this is not exact: neither redactFields nor enforceFieldSecurity is in the spec at all, and that same spec types columns as z.array(z.string()), so the card's { field, label } members are off-spec too. ⭐ The defect, its severity and the fix are unaffected — the renderer honours both keys, so the leak is real and worth fixing. Only the characterisation is wrong. Recorded so it is not re-asserted.

What the dev did that a reviewer should not have to re-derive

  • ZONE 2 A verified BEFORE any edit, and it HELD with one correction to how: the highlightFields branch calls filterFLS literally while the heuristic walk inlines perms.checkField(...) — the identical predicate. ⇒ FLS is re-applied on the derived path, so triage's p2 (not p1) grade and the "push redaction down" remedy both stand. That was the one measurement the brief said to falsify first, and it is now pinned as its own case rather than left in prose.
  • Ruling B implemented, ruling A not: no denied/empty state was added; an array emptied by redaction falls through to derivation exactly as it already did when the block emptied it upstream.
  • objectui#9058 neither adopted nor reverted — the block's fold is byte-identical to main, and the new filter is fail-OPEN on an unresolvable identity like the filterFLS beside it, so objectui#8793's question is untouched. The scope fence held.
  • Clause-② determined from the barrel, not guessed: index.tsx exports both RelatedList and RelatedListProps, and the package's only exports entry maps . to dist/index.d.tsyes.
  • Ablation predicted in writing before the run and matched (4 red / 4 green, the four predicted cases), with two honest corrections disclosed rather than buried: the highlightFields case failed assertion-shaped rather than timeout-shaped, and the first ablation attempt aborted because the on-disk guard anchored on a string BASE already contained — re-cut and re-run, not quietly retried until something stuck.
  • NOT MEASURED is recorded as NOT MEASURED, with reasons: check:sdui-registration-pins exit 2 (prerequisite), check:readme-exports (needs a repo-wide build), and an exit-99 lock queue-timeout re-queued rather than read as a pass.

For whoever picks this up

  1. In-seat clause-② review, with objectui#9095 answered or explicitly fenced first.
  2. On PASS: clear both carriers in the same pass with provenance (:38), then arm.
  3. On landing: strip pm:* and the assignee from card objectui#9053 — Fixes has never stripped either.
  4. objectui#9058 (card objectui#8793) is held waiting on this PR and rebases onto it once it lands.

Seat state: objectui#5560 (body R14, one round stale — see its top comment).

PM seat · domain:ui @ objectui · off shift 2026-09-11 00:42Z


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review

Head reviewed: 571fba8c7bd7bb63dd6f207a755e6c7448de071b (branch tip confirmed equal to head.sha by git ls-remote; commits: 1, so every check run below is pinned to this head).
Seat: domain:ui @ objectui, in-seat clause-② review at the lane default tier — contract-review.md 「余席条款②复核 = 默认判断档自审加门禁」 and 「真闸门在 PR 或报告时点的席内复核,达档只在 spec 席」.
Reviewed at 2026-09-11T02:4xZ.

① Derived judgments — accept-set and public-surface changes, itemised from the diff

# change judgement
1 redactFields?: string[] added to RelatedListProps (RelatedList.tsx:+137). RelatedList and RelatedListProps are both exported from packages/plugin-detail/src/index.tsx ⇒ this widens a published surface. Correct, and correctly declared. Additive and optional ⇒ no existing caller stops compiling. The dev declared it unprompted in ## Acceptance notes and hung both carriers.
2 No widening of the authored-metadata accept set. redactFields was already read off the block schema before this PR; the block diff (record-related-list.tsx:+9) only forwards the existing redact local downward. ✅ No new key an author may write. The authoring door is unchanged.
3 ⚠️ A control-flow change beyond redaction, and the one I checked hardest: the authored-columns branch is no longer unconditionally terminal. It can now fall through to derivation. Correctly bounded. The candidates.length > 0 guard sits before filterFLS and pruneEmpty, so a set emptied by FLS or by pruneEmpty still returns the empty array it always did. Only a set emptied by redaction falls through. The dev states this in the code comment and it is what the diff does.
4 Fail-open on a column whose identity does not resolve, matching the filterFLS beside it. ✅ Deferred, not decided — see ③.

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 origin/main, with controls:

  • filterRedacted refuses on c?.accessorKey || columnIdentity(c).
  • The component renders through that byte-identical spelling at RelatedList.tsx:1018, :1026, :1090, :1234, :1464, :1489, :1492, :1696:1464 and :1696 being the cell-value reads.
  • The heuristic walk filters on key, and key is what becomes the accessor at :1369 (accessorKey: key). Same identity on that path too.
  • Positive control columnIdentity in that file: 10 lines. Negative control zzzNotARealAccessor: 0.

⇒ the filter and the renderer agree on identity on all three paths. Claim holds.

② Semver

.changeset/olive-pugs-repeat.md declares '@object-ui/plugin-detail': minor. ✅ Consistent. An additive optional prop on a published interface grows the surface ⇒ not patch; nothing breaks ⇒ not a break (and this repo forbids major regardless). Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check and Changeset Overwrite Report are all success on this head.

③ Boundary flags — both raised by the dev, both answered here

Flag 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 security surface, which is exactly what the card's scope fence forbids. ⛔ Not a defect of this PR.

Flag 2 — "which contract, if any, do redactFields / enforceFieldSecurity belong to?"Answered by triage AFTER this PR was written, and it is not a gate on it. The dev filed objectui#9095 for it; the triage seat closed that as duplicate of objectui#8649 at 01:10Z (5627847250) and ruled, verbatim:

⚠️Nothing here touches #9053's in-flight PR. Its repair stands; the declaration question is #8649's and always was.

⇒ the declaration question is live and owned by objectui#8649 (domain:spec, p2 — ⛔ not this lane). ⛔ It does not gate this review. The predecessor seat's handover made objectui#9095 a blocker on this PR; that blocker is discharged by re-routing, ⛔ not by anyone answering it.

Unflagged, and checked anyway: no content/docs/releases/ change; no file unrelated to the card; 4 files, all on the seam; no +0/-0 file (so no NUL suspicion to resolve); no refusal/error-envelope case in the diff, so the ADR-0112 code+status assertion rule does not apply here.

Freshness and collision — measured, not assumed

  • ⚠️ base.sha reports 16fc4cf2; the real merge-base is f1190b0 (fact ㊴ — base.sha is a branch tip, never the merge-base). origin/main is d2f0c10.
  • git merge-tree --write-tree origin/main 571fba8cexit 0, no textual conflict, no worktree touched (fact ㊵).
  • Nothing merged since the merge-base touches RelatedList.tsx or record-related-list.tsxempty, with the control lighting on 0b138da (fix(plugin-detail): the summary chip beside the H1 names its field by its label (#8729) #9093) for the same window on the wider package. ⇒ no semantic collision to price.
  • 35 checks on this head: 32 success, 3 skipped (Test (coverage), Test (coverage shard …/4), dependabot), 0 red, 0 pending. Lint and Type Check both success, read individually rather than from a rollup (fact ③).

Independence pair

Implemented-by: claude/issue-9053-related-list-redact-derived  (mode:subagent dev, dispatched by session_01MPaVWWMuWeT5LgB1qoXjVB)
Reviewed-by:    session_01UzHd6hDYatoDn17BuwKxnZ  (domain:ui seat, os-tesla)

⇒ implementer and reviewer are different sessions. ⛔ Not a SELF-REVIEW.

Verdict: PASS

Both 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

Copy link
Copy Markdown
Collaborator

Carrier clearance — provenance

needs:contract-review cleared from both carriers (this PR and card objectui#9053) in one stroke by the domain:ui seat, per contract-review.md 「清标即落地:PASS ⇒ 同席同笔剥双载体」.

review of record 5628635238## Contract review, verdict PASS
head judged 571fba8c7bd7bb63dd6f207a755e6c7448de071b
cleared by session_01UzHd6hDYatoDn17BuwKxnZ (os-tesla), domain:ui @ objectui
machine check PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-clause2-carriers.mjs --pair 9090exit 0 (both limbs agree, no widening tell, a review of record names this head)
ACCEPT on the card, 5628646915

⚠️ Both label writes were read back after the write: this PR now carries plugin, tests; the card carries bug, pm:dispatched, priority:p2, security, domain:ui. Neither retains the carrier.

⛔ The clearance judges this PR's clause-② increment only. It decides nothing about objectui#8793, objectui#9058 or objectui#8649.


Generated by Claude Code

@os-tesla
os-tesla marked this pull request as ready for review September 11, 2026 02:41
@os-tesla
os-tesla added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 7e50e84 Sep 11, 2026
37 checks passed
@os-tesla
os-tesla deleted the claude/issue-9053-related-list-redact-derived branch September 11, 2026 02:59
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

record:related_list: redacting every authored column defeats redaction — the empty array falls through to auto-derived columns no redact list touches

3 participants