Skip to content
11 changes: 11 additions & 0 deletions .changeset/10155-record-blocks-capability-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,14 @@ role-based `PermissionProvider`, or a backend predating ADR-0066) still opens
these gates. That is `hasCapabilities`'s ruled unreported-vs-empty doctrine
(objectui#4656) and this change does not move it. A REPORTED empty array is a
real answer and gates strictly.

**Correction, 2026-09-30 (objectui#8649).** "Hides the block" above is wrong
about what the reader sees. When a declared capability is unheld or
unrecognised, the block's content is withheld and an insufficient-permissions
notice (`role="status"`) renders in its place, on all three blocks. That is
what the contract's `requiredPermissions` describe on these blocks says: "this
block does not render its content; wherever it would otherwise render, an
insufficient-permissions notice takes its place". Everything else above stands:
the capabilities the gate asks for, and its fail-closed verdict. The automatic
child-object read gate of `record:related_list` is a different gate, and it
does hide the section.
11 changes: 11 additions & 0 deletions .changeset/8649-detail-renderer-undeclared-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,14 @@ Declaring them here would make this repo accept what the platform refuses;
retiring the reads would delete a redaction that works today on the raw-node
path. Both keys stay honoured exactly as before, and the census above is a test,
so it goes red the day the platform declares one of them.

**Correction, 2026-09-30 (objectui#8649).** The paragraph above that begins
"Three keys are deliberately NOT declared" is superseded, together with the
census under it. `@objectstack/spec` 17.5.0 declares `enforceFieldSecurity`,
`redactFields` and `requiredPermissions` on `record:details`,
`record:highlights` and `record:related_list`, and PR objectui#11184 declares
the same three keys on those blocks: on their `@object-ui/types` props
interfaces, as their registry inputs, and at the renderers' read sites, which
no longer cast. That pull request carries its own release note. The paragraph's
behavioural statement still holds: no runtime behaviour changes, and all three
keys are honoured exactly as before.
43 changes: 43 additions & 0 deletions .changeset/8649-record-block-field-security-triple.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@object-ui/types': minor
'@object-ui/plugin-detail': minor
---

`record:details`, `record:highlights` and `record:related_list` declare the
field-security triple — `enforceFieldSecurity`, `redactFields` and
`requiredPermissions` — on their published types and registry inputs
(objectui#8649).

**This is a widening, and it is exactly the contract's.** `@objectstack/spec`
17.5.0 declares all three keys on these three blocks (objectstack#18159), with
the shapes `boolean`, `string[]` and `string[]`, each optional. The renderers
already honoured all three, reading them through a cast, while the published
surfaces said nothing about them. Both surfaces now accept what the
contract accepts, and nothing past it:

- **`@object-ui/types`** — `RecordDetailsComponentProps`,
`RecordHighlightsComponentProps` and `RecordRelatedListComponentProps` gain
`enforceFieldSecurity?: boolean`, `redactFields?: string[]` and
`requiredPermissions?: string[]`. A document the contract accepts is no longer
refused by `tsc` with `TS2353`; a wrong-typed value (a bare string for
`requiredPermissions`, the string `'true'` for `enforceFieldSecurity`) is
refused, as the contract refuses it.
- **`@object-ui/plugin-detail`** — the three blocks' registry `inputs` publish
the three keys, so `sdui.manifest.json`, `sdui-intrinsics.d.ts` and the parser's
prop walk stop treating them as unknown. Each input carries the contract's type
(`boolean`, or `array` of `string`) and this block's own `.describe()` text
from the spec, verbatim.

**No rendering, gating or masking behaviour moves.** The renderers read the
three keys without the cast now, so the reads carry the declared types; the
emitted JavaScript of all three renderers is byte-identical to before.
`requiredPermissions` is still the ADR-0066 capability gate (all named
capabilities held, or an insufficient-permissions notice takes the block's
place; fails open when the client cannot resolve capabilities), and
`redactFields` / `enforceFieldSecurity` are still presentation-only folds over
the block's own field or column list — none of them is a data-access control.

⚠️ This supersedes one paragraph of another pending note in the same release,
from this card's first half: it says these three keys are deliberately NOT
declared and routed to the producer. That was true against `@objectstack/spec`
17.4.0; the producer declared them in 17.5.0, and this entry is the result.
69 changes: 41 additions & 28 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,23 @@
* `redactFields` is the channel used below, because the block folds members
* ONLY when it filters — with nothing to filter, `columns` is handed down by
* reference and no member is ever read. `redactFields` and
* `enforceFieldSecurity` are renderer-only keys: measured against
* `RecordRelatedListProps` (`@objectstack/spec`), whose top-level keys are
* actions/add/aria/columns/filter/limit/objectName/relationshipField/
* relationshipValueField/showViewAll/sort/title, they are on NEITHER the spec
* nor this block's `inputs`. They are used here as an instrument for a fold
* that is otherwise unobservable, never as evidence that they are an authoring
* surface — the first assertion below states that so a later reader cannot take
* this file as licensing them.
* `enforceFieldSecurity` were renderer-only keys when this file was written, on
* NEITHER `RecordRelatedListProps` (`@objectstack/spec`) nor this block's
* `inputs`. `@objectstack/spec` 17.5.0 declares both on `record:related_list`,
* and objectui#8649 published them as inputs on the strength of THAT
* declaration. This file still uses them as an instrument for a fold that is
* otherwise unobservable, never as evidence that they are an authoring surface:
* the CONTROL at the foot ties each published input to the contract's own
* declaration, so their standing is read off the spec every run, not off this
* file.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { cleanup, render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { ComponentRegistry, columnIdentity } from '@object-ui/core';
import { RecordRelatedListProps } from '@objectstack/spec/ui';
import { RecordRelatedListRenderer } from '../renderers/record-related-list';
import '../index';

Expand Down Expand Up @@ -224,11 +226,20 @@ describe('record:related_list — the `columns` MEMBER shape the renderer reads
expect(await columnsAfterFold(set)).toEqual(set);
});

it('CONTROL — `redactFields` is an instrument here, not an authoring surface', async () => {
// See the file docblock. The block's published `inputs` do not declare it,
// and this assertion is what stops a later reader citing this file as
// evidence that it is declared.
expect(input('redactFields')).toBeUndefined();
expect(input('enforceFieldSecurity')).toBeUndefined();
it('CONTROL — `redactFields` is an instrument here; its authoring standing is the CONTRACT\'s', async () => {
// See the file docblock. Until `@objectstack/spec` 17.5.0 the block's
// published `inputs` did not declare these probe keys and this leg asserted
// exactly that. Now the contract declares them and objectui#8649 publishes
// them — so what this leg pins is the DIRECTION: an input exists here if
// and only if `RecordRelatedListProps` declares the key. Nothing in this
// file can license a key; the spec does, and is read here, not assumed.
const specDeclares = (key: string) => key in RecordRelatedListProps.shape;
for (const key of ['redactFields', 'enforceFieldSecurity']) {
expect(input(key) !== undefined, `${key}: published input vs contract declaration`).toBe(
specDeclares(key),
);
}
// Calibration of `specDeclares`: a key nothing declares reads false.
expect(specDeclares('zzqxNoSuchRecordBlockKey')).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@
*
* ## The instrument, stated rather than smuggled
*
* `enforceFieldSecurity` / `redactFields` are renderer-only keys — on neither
* `@objectstack/spec`'s `RecordRelatedListProps` nor this block's registered
* `inputs` (asserted in the columnMembers file). They are used here because
* they are the ONLY switch that makes the block read a column member at all;
* their presence here is not evidence that they are an authoring surface.
* `enforceFieldSecurity` / `redactFields` are used here because they are the
* ONLY switch that makes the block read a column member at all. They were
* renderer-only keys when this file was written; `@objectstack/spec` 17.5.0
* declares both on `RecordRelatedListProps`, and objectui#8649 published them
* as this block's `inputs` on the strength of that declaration (the
* columnMembers file ties the two together). Their presence here is not
* evidence either way.
*/

import { describe, it, expect, vi, beforeAll } from 'vitest';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,62 @@ describe('record:details — `layout` is retired, not merely undocumented (#3818
expect(src).not.toMatch(/['"]compact['"]/);
});
});

/**
* objectui#8649 — the field-security triple, published as the CONTRACT declares
* it on this block.
*
* `@objectstack/spec` 17.5.0 declares `enforceFieldSecurity`, `redactFields` and
* `requiredPermissions` on `record:details`. The renderer honoured all three before that,
* through a cast, while these `inputs` omitted them; objectui#8649 publishes
* them. Three claims per key, each read off the INSTALLED spec rather than
* restated: the key is declared on this block's props, the published coarse
* type is the one the contract's VALUES bear out (a full parse on an accepted
* value and a named refusal on a rejected one — a value claim, so a key-level
* `unrecognized_keys` reading would not do), and the description is this
* block's own `.describe()` text, verbatim — so a spec that rewords one turns
* this file red rather than leaving the manifest to drift.
*/
describe('record:details — the field-security triple is published as the contract declares it (objectui#8649)', () => {
const TRIPLE = {
enforceFieldSecurity: { type: 'boolean', of: undefined, accepted: true, refused: 'true' },
redactFields: { type: 'array', of: 'string', accepted: ['salary'], refused: [1] },
requiredPermissions: { type: 'array', of: 'string', accepted: ['crm.manage'], refused: 'crm.manage' },
} as const;
const shapeOf = (key: string) =>
(RecordDetailsProps.shape as Record<string, { description?: string; def?: { innerType?: { description?: string } } }>)[key];
const describeOf = (key: string): string | undefined =>
shapeOf(key)?.description ?? shapeOf(key)?.def?.innerType?.description;

for (const [key, want] of Object.entries(TRIPLE)) {
it(`publishes \`${key}\` with the contract's type, proved on values`, () => {
expect(Object.keys(RecordDetailsProps.shape), `${key} is not declared on record:details`).toContain(key);
const published = input(key);
expect(published, `${key} is not published`).toBeDefined();
expect(published?.type).toBe(want.type);
expect(published?.of).toBe(want.of);

const ok = RecordDetailsProps.safeParse({ ...TOP_LEVEL_BASELINE, [key]: want.accepted });
expect(ok.success, `${key}: the contract refused a value of the published type`).toBe(true);
expect((ok.data as Record<string, unknown> | undefined)?.[key]).toEqual(want.accepted);
const bad = RecordDetailsProps.safeParse({ ...TOP_LEVEL_BASELINE, [key]: want.refused });
expect(bad.success, `${key}: the contract accepted a value outside the published type`).toBe(false);
// Refused AT this key — `redactFields: [1]` is refused at its MEMBER
// (`redactFields.0`), which is the member-kind half of the same claim.
expect(bad.error?.issues.map((i) => String(i.path[0]))).toContain(key);
});

it(`\`${key}\`'s description is this block's own describe text, verbatim`, () => {
const text = describeOf(key);
// Non-vacuity: an undefined describe would compare equal to a missing one.
expect(text ?? '', `${key}: the contract carries no describe to publish`).not.toBe('');
expect(input(key)?.description).toBe(text);
});
}

it('CONTROL — the value probe refuses at the key it names, and accepts the unmodified baseline', () => {
expect(RecordDetailsProps.safeParse(TOP_LEVEL_BASELINE).success).toBe(true);
const bad = RecordDetailsProps.safeParse({ ...TOP_LEVEL_BASELINE, enforceFieldSecurity: 'true' });
expect(bad.error?.issues.map((i) => i.path.join('.'))).toEqual(['enforceFieldSecurity']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,65 @@ describe('record:highlights — registry inputs vs @objectstack/spec', () => {
expect(offSpec).toEqual([]);
});
});

const tripleInput = (name: string) => inputs().find((i) => i.name === name);
const HIGHLIGHTS_BASELINE = { fields: ['amount'] };

/**
* objectui#8649 — the field-security triple, published as the CONTRACT declares
* it on this block.
*
* `@objectstack/spec` 17.5.0 declares `enforceFieldSecurity`, `redactFields` and
* `requiredPermissions` on `record:highlights`. The renderer honoured all three before that,
* through a cast, while these `inputs` omitted them; objectui#8649 publishes
* them. Three claims per key, each read off the INSTALLED spec rather than
* restated: the key is declared on this block's props, the published coarse
* type is the one the contract's VALUES bear out (a full parse on an accepted
* value and a named refusal on a rejected one — a value claim, so a key-level
* `unrecognized_keys` reading would not do), and the description is this
* block's own `.describe()` text, verbatim — so a spec that rewords one turns
* this file red rather than leaving the manifest to drift.
*/
describe('record:highlights — the field-security triple is published as the contract declares it (objectui#8649)', () => {
const TRIPLE = {
enforceFieldSecurity: { type: 'boolean', of: undefined, accepted: true, refused: 'true' },
redactFields: { type: 'array', of: 'string', accepted: ['salary'], refused: [1] },
requiredPermissions: { type: 'array', of: 'string', accepted: ['crm.manage'], refused: 'crm.manage' },
} as const;
const shapeOf = (key: string) =>
(RecordHighlightsProps.shape as Record<string, { description?: string; def?: { innerType?: { description?: string } } }>)[key];
const describeOf = (key: string): string | undefined =>
shapeOf(key)?.description ?? shapeOf(key)?.def?.innerType?.description;

for (const [key, want] of Object.entries(TRIPLE)) {
it(`publishes \`${key}\` with the contract's type, proved on values`, () => {
expect(Object.keys(RecordHighlightsProps.shape), `${key} is not declared on record:highlights`).toContain(key);
const published = tripleInput(key);
expect(published, `${key} is not published`).toBeDefined();
expect(published?.type).toBe(want.type);
expect(published?.of).toBe(want.of);

const ok = RecordHighlightsProps.safeParse({ ...HIGHLIGHTS_BASELINE, [key]: want.accepted });
expect(ok.success, `${key}: the contract refused a value of the published type`).toBe(true);
expect((ok.data as Record<string, unknown> | undefined)?.[key]).toEqual(want.accepted);
const bad = RecordHighlightsProps.safeParse({ ...HIGHLIGHTS_BASELINE, [key]: want.refused });
expect(bad.success, `${key}: the contract accepted a value outside the published type`).toBe(false);
// Refused AT this key — `redactFields: [1]` is refused at its MEMBER
// (`redactFields.0`), which is the member-kind half of the same claim.
expect(bad.error?.issues.map((i) => String(i.path[0]))).toContain(key);
});

it(`\`${key}\`'s description is this block's own describe text, verbatim`, () => {
const text = describeOf(key);
// Non-vacuity: an undefined describe would compare equal to a missing one.
expect(text ?? '', `${key}: the contract carries no describe to publish`).not.toBe('');
expect(tripleInput(key)?.description).toBe(text);
});
}

it('CONTROL — the value probe refuses at the key it names, and accepts the unmodified baseline', () => {
expect(RecordHighlightsProps.safeParse(HIGHLIGHTS_BASELINE).success).toBe(true);
const bad = RecordHighlightsProps.safeParse({ ...HIGHLIGHTS_BASELINE, enforceFieldSecurity: 'true' });
expect(bad.error?.issues.map((i) => i.path.join('.'))).toEqual(['enforceFieldSecurity']);
});
});
Loading
Loading