From 6b93b0e8ca722b3cc1df683525186904a85963db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:56:32 +0000 Subject: [PATCH 1/6] feat(types,plugin-detail): declare the field-security triple on the three record blocks and read it un-cast (objectui#8649) `@objectstack/spec` 17.5.0 declares `enforceFieldSecurity`, `redactFields` and `requiredPermissions` on `record:details`, `record:highlights` and `record:related_list`. The mirror now declares them on the three props interfaces, the renderers read them without the `(schema as any)` cast, and the three blocks' registry inputs publish them with the contract's types and describe text. The renderers' emitted JavaScript is byte-identical. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- packages/plugin-detail/src/index.tsx | 41 ++++++++- .../src/renderers/record-details.tsx | 18 ++-- .../src/renderers/record-highlights.tsx | 15 ++-- .../src/renderers/record-related-list.tsx | 23 +++-- packages/types/src/record-components.ts | 90 +++++++++++++++++++ 5 files changed, 164 insertions(+), 23 deletions(-) diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 48023ebf88..177213c443 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -566,6 +566,27 @@ ComponentRegistry.register('details', RecordDetailsRenderer, { // have. { name: 'inlineEdit', type: 'boolean', description: 'Offer the per-field double-click / pencil inline-edit affordances in the detail body. On by default, and an OPT-OUT only: the value is combined with the object\'s own editability (system, engine-owned, append-only and better-auth objects are not user-editable unless they opened userActions.edit) and with the server\'s effective API operation set for the object, so `false` always wins while `true` cannot open editing the platform refuses. The edit session and the atomic Save bar are hosted by the page, so one draft spans the highlights strip and this body.' }, { name: 'showHeader', type: 'boolean', description: 'Render the detail body\'s own title / follow-star / copy-id chip above the fields. Off by default because this block is normally composed under a `page:header` that already draws that chrome, and turning it on there shows the record title twice. Set it true only when this block is the whole page.' }, + // The field-security triple — `enforceFieldSecurity`, `redactFields`, + // `requiredPermissions` — is DECLARED here by objectui#8649, the reverse + // direction `hideFields` above records (objectui#3808), on the three keys + // `@objectstack/spec` 17.5.0 declared on `record:details`, + // `record:highlights` and `record:related_list` (objectstack#18159). + // `RecordDetailsRenderer` honoured all three before either declaration, + // through a cast: through 17.4.0 an author who wrote one was refused at + // parse and honoured by the renderer, and after the 17.5.0 bump the parse + // accepted it while this manifest still said nothing. Publishing them moves + // what the manifest advertises, and no rendering, gating or masking + // behaviour. + // + // Types and descriptions are the CONTRACT's, verbatim: `boolean` for + // `z.boolean()`, `array` of `string` for `z.array(z.string())`, and each + // description is this block's own `.describe()` text. The describe is the + // text of record, and `__tests__/recordDetailsInputs.spec-parity.test.ts` + // re-reads it off the installed spec every run, so a spec that rewords one + // turns that file red here rather than leaving the manifest to drift. + { name: 'enforceFieldSecurity', type: 'boolean', description: 'Fold this block\'s field list through the caller\'s FIELD-read permissions before rendering, so a field the permission set denies leaves no empty row behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — with it off a denied field still arrives masked or stripped, and with it on the server still decides every value.' }, + { name: 'redactFields', type: 'array', of: 'string', description: 'Field names this block never renders, whatever the permission answer (renderer default: render everything authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object\'s `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. Neighbours `hideFields`, which is the dedupe channel the renderer also writes to.' }, + { name: 'requiredPermissions', type: 'array', of: 'string', description: '[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object\'s read or edit permission. When the client has resolved the user\'s capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user\'s capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open.' }, ], }); @@ -646,6 +667,13 @@ ComponentRegistry.register('related_list', RecordRelatedListRenderer, { // `record:activity.showSubscriptionToggle` precedent; the sentence went away // with the gap, not before it. { name: 'add', type: 'object', description: 'Adds an "Add" button that assigns EXISTING records instead of creating one — the m2m/junction case. Shape: `{ picker: { object, valueField?, labelField?, filter? }, linkField?, label? }`. `picker.object` (required) is the object whose records the dialog offers. `picker.valueField` is the field of the picked record used as the link value (default "id"); `picker.labelField` is the column shown in the picker rows (default "name", and the other columns are derived from that object\'s schema). With `linkField` set, selecting records CREATES rows in this list\'s own object as `{ [relationshipField]: parentValue, [linkField]: pickedId }` — the junction case; omit `linkField` and the picked child is RE-PARENTED instead, by setting its own `relationshipField` to this parent. `label` is the button text (default "Add", localizable inline). Setting `add` also enables generic link removal on rows when no host delete handler is wired. `picker.filter` restricts which records the dialog offers — a list of `{ field, operator, value }` rules in the same vocabulary as this list\'s own `filter`, applied as a hard constraint the user cannot widen (it never appears as an editable filter row).' }, + // The field-security triple, DECLARED by objectui#8649 on the contract's + // 17.5.0 declaration — types and descriptions verbatim from this block's + // spec row. The reasoning is written once, on `record:details` above; + // `__tests__/recordRelatedListInputs.spec-parity.test.ts` re-reads each description off the installed spec. + { name: 'enforceFieldSecurity', type: 'boolean', description: 'Fold this list\'s `columns` through the caller\'s FIELD-read permissions on the RELATED object before rendering (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the rows are fetched either way and the server still decides every value.' }, + { name: 'redactFields', type: 'array', of: 'string', description: 'Field names this list never renders, whatever the permission answer (renderer default: render every column authored or derived). Applies to the authored `columns` AND to the columns the list derives for itself when none are authored. Presentation only, evaluated in the browser after the rows are fetched — the values are still in the page, so this is NOT a data-access control and NOT the object\'s `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set.' }, + { name: 'requiredPermissions', type: 'array', of: 'string', description: '[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object\'s read or edit permission. When the client has resolved the user\'s capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user\'s capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open.' }, ], }); @@ -660,8 +688,10 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, { // `readonly` is documented INSIDE the `fields` description, not declared as // an input of its own, because that is where the contract puts it: the spec's // `RecordHighlightsField` carries `readonly` on each ENTRY, while - // `RecordHighlightsProps` has exactly three top-level keys (fields, layout, - // aria). A top-level `{ name: 'readonly', type: 'boolean' }` here would look + // `RecordHighlightsProps` declares no top-level `readonly` (its top-level + // keys are read off the installed spec by + // `__tests__/recordHighlightsInputs.spec-parity.test.ts`, not listed here). + // A top-level `{ name: 'readonly', type: 'boolean' }` here would look // like the fix for "the manifest never mentions readonly" and would instead // publish a key the platform does not accept: the generated // `sdui.manifest.json` and `sdui-intrinsics.d.ts` would advertise @@ -687,6 +717,13 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, { inputs: [ { name: 'fields', type: 'array', required: true, description: 'Key fields to highlight (1-7), bare names or {name,label?,type?,readonly?}. Set readonly: true on an entry to render that chip read-only — it suppresses the inline-edit affordance and the HeaderHighlight editability gate enforces it. Use it for hook/automation-maintained columns that must not be hand-edited from the record header; marking the OBJECT field readonly instead would also strip the hook\'s own write-back.' }, { name: 'layout', type: 'enum', enum: ['horizontal', 'vertical'], description: 'Layout orientation for highlight fields' }, + // The field-security triple, DECLARED by objectui#8649 on the contract's + // 17.5.0 declaration — types and descriptions verbatim from this block's + // spec row. The reasoning is written once, on `record:details` above; + // `__tests__/recordHighlightsInputs.spec-parity.test.ts` re-reads each description off the installed spec. + { name: 'enforceFieldSecurity', type: 'boolean', description: 'Fold this block\'s highlight chips through the caller\'s FIELD-read permissions before rendering, so a field the permission set denies leaves no empty chip behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the record is fetched either way and the server still decides every value.' }, + { name: 'redactFields', type: 'array', of: 'string', description: 'Field names this block never renders as a chip, whatever the permission answer (renderer default: render every field authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object\'s `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set.' }, + { name: 'requiredPermissions', type: 'array', of: 'string', description: '[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object\'s read or edit permission. When the client has resolved the user\'s capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user\'s capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open.' }, ], }); diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 33bacb47f8..b84cab65d6 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -127,8 +127,8 @@ export const RecordDetailsRenderer: React.FC = ({ // ⛔ NOT `{} as any` (objectui#8649). A destructuring default's type joins // the annotated property type at the binding, so `any` here ERASED // `RecordDetailsRendererProps` for every read site in this file: declared - // keys (`hideFields`, `sections`, `columns`, …) and undeclared ones - // (`enforceFieldSecurity`, …) all read `any`, indistinguishably. That is what + // keys (`hideFields`, `sections`, `columns`, …) and ones the mirror did not + // declare then (`enforceFieldSecurity`, …) all read `any`, indistinguishably. That is what // made objectui#8327's checker census unable to classify eleven of this // card's twelve reads, and it is a LOCAL type defect — the exported // annotation above was always correct, so repairing it moves no published @@ -199,8 +199,12 @@ export const RecordDetailsRenderer: React.FC = ({ ); } - const required: string[] = Array.isArray((schema as any).requiredPermissions) - ? (schema as any).requiredPermissions + // Read UN-CAST (objectui#8649): the mirror declares the field-security + // triple since `@objectstack/spec` 17.5.0 declared it on this block, so this + // read and the `enforceFieldSecurity` / `redactFields` reads below carry the + // declared types. Output-identical to the cast they replace. + const required: string[] = Array.isArray(schema.requiredPermissions) + ? schema.requiredPermissions : []; /** * Block-level ADR-0066 CAPABILITY gate, read fail-closed (objectui#10155 — @@ -243,9 +247,9 @@ export const RecordDetailsRenderer: React.FC = ({ ); } - const enforceFLS = (schema as any).enforceFieldSecurity === true; - const redact: string[] = Array.isArray((schema as any).redactFields) - ? (schema as any).redactFields + const enforceFLS = schema.enforceFieldSecurity === true; + const redact: string[] = Array.isArray(schema.redactFields) + ? schema.redactFields : []; const filterList = (list: any[] | undefined): any[] | undefined => { if (!list) return list; diff --git a/packages/plugin-detail/src/renderers/record-highlights.tsx b/packages/plugin-detail/src/renderers/record-highlights.tsx index f5c7968c7d..09665b3254 100644 --- a/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -84,9 +84,14 @@ export const RecordHighlightsRenderer: React.FC = * (objectui#4656), shared with every other capability gate in the tree; a * REPORTED empty array (`[]`, "holds nothing") is a real answer and gates * strictly. + * + * Read UN-CAST (objectui#8649), like `enforceFieldSecurity` and + * `redactFields` below: the mirror declares the triple since + * `@objectstack/spec` 17.5.0 declared it on this block. The mechanism is + * written once, at the same read in `record-details.tsx`. */ - const required: string[] = Array.isArray((schema as any).requiredPermissions) - ? (schema as any).requiredPermissions + const required: string[] = Array.isArray(schema.requiredPermissions) + ? schema.requiredPermissions : []; // Evaluated up-front but enforced AFTER the hooks below (useId / // useRegisterHighlightFields) so hook order stays stable across renders. @@ -123,9 +128,9 @@ export const RecordHighlightsRenderer: React.FC = }, ).filter((f) => typeof f.name === 'string' && f.name.length > 0); - const enforceFLS = (schema as any).enforceFieldSecurity === true; - const redact: string[] = Array.isArray((schema as any).redactFields) - ? (schema as any).redactFields + const enforceFLS = schema.enforceFieldSecurity === true; + const redact: string[] = Array.isArray(schema.redactFields) + ? schema.redactFields : []; const allowedNames = enforceFLS && objectName ? new Set(readableFields(normalized.map((f) => f.name))) diff --git a/packages/plugin-detail/src/renderers/record-related-list.tsx b/packages/plugin-detail/src/renderers/record-related-list.tsx index b3e73d1e1b..0125516ccc 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -86,10 +86,12 @@ export interface RecordRelatedListRendererProps { * declared key type-checked at every read below, cast or not — the refusal * the mirror declares stopped one layer short of the reads it exists for. * - * ⛔ Do not reopen it to admit a key the renderer reads through a cast - * (`requiredPermissions`, `enforceFieldSecurity`, `redactFields`): no block - * the contract maps onto this tag declares them, and objectui#8649 routed - * them to the producer rather than to a declaration here. + * The field-security triple (`requiredPermissions`, `enforceFieldSecurity`, + * `redactFields`) arrives through the mirror like every other member: + * `@objectstack/spec` 17.5.0 declares it on `record:related_list`, and + * objectui#8649 declared it on `RecordRelatedListComponentProps` and removed + * the casts its reads used to go through. ⛔ Still do not reopen this type to + * admit a key the contract does not declare on this block. */ schema?: Omit & Partial> & { @@ -237,8 +239,11 @@ const RecordRelatedListBody: React.FC = ({ return null; } - const required: string[] = Array.isArray((schema as any).requiredPermissions) - ? (schema as any).requiredPermissions + // Read UN-CAST (objectui#8649), like `enforceFieldSecurity` and + // `redactFields` below. The mechanism is written once, at the same read in + // `record-details.tsx`. + const required: string[] = Array.isArray(schema.requiredPermissions) + ? schema.requiredPermissions : []; /** * Block-level ADR-0066 CAPABILITY gate, read fail-closed (objectui#10155 — @@ -283,9 +288,9 @@ const RecordRelatedListBody: React.FC = ({ ); } - const enforceFLS = (schema as any).enforceFieldSecurity === true; - const redact: string[] = Array.isArray((schema as any).redactFields) - ? (schema as any).redactFields + const enforceFLS = schema.enforceFieldSecurity === true; + const redact: string[] = Array.isArray(schema.redactFields) + ? schema.redactFields : []; const rawColumns: any[] = Array.isArray(schema.columns) ? (schema.columns as any[]) : []; let filteredColumns: any[] = rawColumns; diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index d98c0dd3eb..0033708d47 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -253,6 +253,42 @@ export interface RecordDetailsComponentProps { * Declared here since objectui#9040 (see `inlineEdit` above). */ showHeader?: boolean; + /** + * Fold the body's field list through the viewer's FIELD-read permissions + * before rendering (`@objectstack/spec` `RecordDetailsProps.enforceFieldSecurity`, + * `z.boolean()`, optional). Renderer default off: `RecordDetailsRenderer` + * reads it as `=== true`. Presentation only — it re-applies the field-read + * answer the server already enforced and never widens access. + * + * This key and the two below are the field-security triple, declared here by + * objectui#8649 once `@objectstack/spec` 17.5.0 declared all three on + * `record:details`, `record:highlights` and `record:related_list` + * (objectstack#18159). The renderer honoured them before either declaration, + * through a cast; this ALIGNS THE MIRROR to the contract and moves no runtime + * behaviour. `renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts` + * in `@object-ui/plugin-detail` re-derives the contract side every run. + */ + enforceFieldSecurity?: boolean; + /** + * Field names this block never renders from its authored `fields` and + * `sections[].fields`, whatever the permission answer + * (`@objectstack/spec` `RecordDetailsProps.redactFields`, + * `z.array(z.string())`, optional). Bare field NAMES only. Presentation only: + * the values are still fetched into the page, so this is not a data-access + * control. See `enforceFieldSecurity` above for the declaration's standing. + */ + redactFields?: string[]; + /** + * ADR-0066 capability names the viewer must ALL hold, or the block renders an + * insufficient-permissions notice in place of its content + * (`@objectstack/spec` `RecordDetailsProps.requiredPermissions`, + * `z.array(z.string())`, optional — the shape and describe it shares with + * `record:quick_actions`). Capabilities are what permission sets grant through + * `systemPermissions`, not object actions. Presentation only, and it fails + * open where the client cannot resolve the viewer's capabilities. See + * `enforceFieldSecurity` above for the declaration's standing. + */ + requiredPermissions?: string[]; /** ARIA accessibility attributes */ aria?: RecordComponentAriaProps; } @@ -336,6 +372,31 @@ export interface RecordHighlightsComponentProps { * the installed spec in both directions. */ layout?: 'horizontal' | 'vertical'; + /** + * Fold the highlight chips through the viewer's FIELD-read permissions before + * rendering (`@objectstack/spec` `RecordHighlightsProps.enforceFieldSecurity`, + * `z.boolean()`, optional). Renderer default off. Presentation only — the + * server has already applied the same field-read answer. One of the + * field-security triple objectui#8649 aligned; see + * `RecordDetailsComponentProps.enforceFieldSecurity` for the declaration's + * standing. + */ + enforceFieldSecurity?: boolean; + /** + * Field names this strip never renders as a chip, whatever the permission + * answer (`@objectstack/spec` `RecordHighlightsProps.redactFields`, + * `z.array(z.string())`, optional). Bare field NAMES only. Presentation only: + * the values are still fetched into the page. + */ + redactFields?: string[]; + /** + * ADR-0066 capability names the viewer must ALL hold, or the strip renders an + * insufficient-permissions notice in place of its chips + * (`@objectstack/spec` `RecordHighlightsProps.requiredPermissions`, + * `z.array(z.string())`, optional). Presentation only, and it fails open + * where the client cannot resolve the viewer's capabilities. + */ + requiredPermissions?: string[]; /** ARIA accessibility attributes */ aria?: RecordComponentAriaProps; } @@ -445,6 +506,35 @@ export interface RecordRelatedListComponentProps { linkField?: string; label?: string; }; + /** + * Fold this list's authored `columns` through the viewer's FIELD-read + * permissions on the RELATED object before rendering + * (`@objectstack/spec` `RecordRelatedListProps.enforceFieldSecurity`, + * `z.boolean()`, optional). Renderer default off. Presentation only — the + * rows are fetched either way and the server still decides every value. One + * of the field-security triple objectui#8649 aligned; see + * `RecordDetailsComponentProps.enforceFieldSecurity` for the declaration's + * standing. + */ + enforceFieldSecurity?: boolean; + /** + * Field names this list never renders, whatever the permission answer — the + * authored `columns` and the columns the list derives for itself when none + * are authored (`@objectstack/spec` `RecordRelatedListProps.redactFields`, + * `z.array(z.string())`, optional). Bare field NAMES only. Presentation only: + * the values are still fetched into the page. + */ + redactFields?: string[]; + /** + * ADR-0066 capability names the viewer must ALL hold, or the list renders an + * insufficient-permissions notice in place of its rows + * (`@objectstack/spec` `RecordRelatedListProps.requiredPermissions`, + * `z.array(z.string())`, optional). Presentation only, and it fails open + * where the client cannot resolve the viewer's capabilities. Separate from + * the automatic read gate on the related object, which this key neither + * replaces nor widens. + */ + requiredPermissions?: string[]; /** ARIA accessibility attributes */ aria?: RecordComponentAriaProps; } From d82118122f60c9e49e5c7d64c8d2f27dcc562dd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:22:27 +0000 Subject: [PATCH 2/6] test(plugin-detail,console): pin the aligned field-security triple and strike its objectui#11111 bookings (objectui#8649) The three cast ledgers booked to this card (the objectui#9965 and objectui#9475 source guards and this card's own routed-key ledger) are emptied, the objectui#9963 refusal rows flip to acceptance with value-level controls, and each block's parity pin proves the published type on contract values and the description against the installed describe. A new pin drives `record:highlights`' two fold keys, which no test covered. The console guard strikes the nine entries, lowers the cap and the owner count, and registers member pins for the six new array inputs. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .../registry-inputs-spec-parity.test.ts | 69 +++-- ...RelatedListRenderer.columnMembers.test.tsx | 39 ++- ...unresolvedIdentityFailClosed-8793.test.tsx | 12 +- .../recordDetailsInputs.spec-parity.test.ts | 59 ++++ ...recordHighlightsInputs.spec-parity.test.ts | 62 ++++ ...ecordRelatedListInputs.spec-parity.test.ts | 59 ++++ .../detailRendererUndeclaredKeys-8649.test.ts | 282 ++++++++++++++++-- ...cord-details.hideFieldsUncast-9965.test.ts | 193 ++++++++---- ...unresolvedIdentityFailClosed-9054.test.tsx | 9 +- ...ord-highlights.fieldSecurity-8649.test.tsx | 148 +++++++++ ...rd-related-list.propsRefusal-9963.test.tsx | 57 +++- ...relationshipValueFieldUncast-9475.test.tsx | 49 +-- 12 files changed, 854 insertions(+), 184 deletions(-) create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-highlights.fieldSecurity-8649.test.tsx diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index e329400152..f07a871c22 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -759,7 +759,7 @@ const owedIdsOf = (ledger: Record): string[] => const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 4, // objectui#11168: object-gantt, object-map, object-timeline, object-tree offSpecInputs: 1, // objectui#11168: action:group.name - unpublishedKeys: 57, // objectui#11168: 46 (22 + 20 + 2 + 2); objectui#8652: 2; objectui#8649: 9 + unpublishedKeys: 48, // objectui#11168: 46 (22 + 20 + 2 + 2); objectui#8652: 2; objectui#8649: 0 (struck by its landing) refusedArms: 5, // objectui#11168: action:group ×2, action:menu, element:definition-list, object-form memberPins: 6, // objectui#11168: action:group, action:menu, element:definition-list, element:repeater ×3 } as const; @@ -1376,13 +1376,15 @@ const UNPUBLISHED_EXEMPTIONS: Record = { /* * ⚠️ THE 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047). * - * 57 spec keys 17.5.0 newly declares on judged blocks and `inputs` does not - * publish. The bar at the top of this map says a key the renderer honours - * "gets declared"; the ruling decided WHO declares, and it is not the bump: - * each owner card measures each key against its renderer and declares what - * is honoured, refuses or retires the rest. Booked here, not declared, so - * the bump adds nothing to the public surface (Clause ② no). Capped at 57 by - * `the objectui#11111 ledger is capped at exactly the entries it lists`. + * The spec keys 17.5.0 newly declared on judged blocks that `inputs` did not + * publish (57 at the bump). The bar at the top of this map says a key the + * renderer honours "gets declared"; the ruling decided WHO declares, and it + * is not the bump: each owner card measures each key against its renderer + * and declares what is honoured, refuses or retires the rest. Booked here, + * not declared, so the bump adds nothing to the public surface (Clause ② no). + * Capped at `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys` by `the + * objectui#11111 ledger is capped at exactly the entries it lists`, which + * each owner card's landing lowers. */ ...owedEntries( 'action:button', @@ -1428,24 +1430,11 @@ const UNPUBLISHED_EXEMPTIONS: Record = { 'objectui#8652', 'A SPEC KEY NOT PUBLISHED: 17.5.0 declares `navigation` on this block; objectui#8652 met its unlock with that release and mirrors it as its ruled second step.', ), - ...owedEntries( - 'record:details', - FIELD_SECURITY_TRIPLE, - 'objectui#8649', - 'A SPEC KEY NOT PUBLISHED: 17.5.0 declares the field-security triple on exactly the three record blocks; objectui#8649 aligns the mirror and un-casts the reads.', - ), - ...owedEntries( - 'record:highlights', - FIELD_SECURITY_TRIPLE, - 'objectui#8649', - 'A SPEC KEY NOT PUBLISHED: 17.5.0 declares the field-security triple on exactly the three record blocks; objectui#8649 aligns the mirror and un-casts the reads.', - ), - ...owedEntries( - 'record:related_list', - FIELD_SECURITY_TRIPLE, - 'objectui#8649', - 'A SPEC KEY NOT PUBLISHED: 17.5.0 declares the field-security triple on exactly the three record blocks; objectui#8649 aligns the mirror and un-casts the reads.', - ), + // objectui#8649's nine entries — the field-security triple on `record:details`, + // `record:highlights` and `record:related_list` — are STRUCK: that card + // declared all three keys on the three blocks' `inputs`, with the contract's + // types and describe text, so each entry went stale and `carries no stale + // unpublished-key exemption` would refuse it. }; /** @@ -2939,6 +2928,14 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts', pins: 'Members are bare names — the object spelling is rejected on its VALUE (a full parse, not a strip), and the description may not teach `{` (objectui#3808).', }, + 'record:details.redactFields': { + file: 'packages/plugin-detail/src/renderers/__tests__/record-details.unresolvedIdentityFailClosed-9054.test.tsx', + pins: 'Members are field NAMES folded out of the authored `fields` AND every `sections[].fields` — the fold\'s two consumers, each with its own leg — through the real `DetailView` / `DetailSection` inside a real record context, asserted on rendered VALUES: a redacted member\'s value never paints while a resolvable, un-redacted sibling\'s does (the live control in the same render). An entry the fold cannot NAME is dropped rather than kept (fail closed, objectui#9054), the legacy `{ fieldName }` spelling is resolvable and not over-dropped, and with neither fold key set nothing moves. Pre-existing file, promoted to a member pin by objectui#8649, which published the key.', + }, + 'record:details.requiredPermissions': { + file: 'packages/plugin-detail/src/renderers/__tests__/record-blocks.requiredPermissions-gate.test.tsx', + pins: 'Members are ADR-0066 CAPABILITY names and the block needs ALL of them, driven through the real renderer under a REAL stock `MePermissionsProvider` (not a mocked `usePermissions`, which cannot discriminate the two reading paths): an unheld capability and an unrecognised one each put the insufficient-permissions notice in place of the block, a held one renders it, an EMPTY array and an ABSENT key are no gate at all, and the discriminating row is a PARTIAL grant on a two-entry array — gated only when the members are read as `.every` over the WHOLE array. Two discriminators pin that a member is a capability and not an object action (`allowRead` on the object does not open the gate; the enum member `manage` is gated rather than resolved to the read bit), and the role-based `PermissionProvider` pins the unreported-capabilities fail-open. One file runs every row against all three record blocks (objectui#10155); promoted to a member pin by objectui#8649, which published the key.', + }, 'record:details.sections': { file: 'packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts', pins: 'Members are OBJECTS: every spec member key must be discoverable from the description, the retired section-id spelling must be ruled out by name, and the three renderer-only keys the spec refuses (`title`, `showBorder`, `hideEmpty`) may not be taught — filtered through the spec at runtime so a stale prohibition drops out on its own (objectui#3807).', @@ -2951,6 +2948,14 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts', pins: 'Members are objects carrying `readonly` PER ENTRY — asserted to be per-entry rather than top-level, and every spec entry key must be discoverable from the `fields` description (objectui#3407).', }, + 'record:highlights.redactFields': { + file: 'packages/plugin-detail/src/renderers/__tests__/record-highlights.fieldSecurity-8649.test.tsx', + pins: 'Members are field NAMES matched EXACTLY, read through the real renderer and asserted on which chips paint their value: a named member removes exactly that chip, a member naming a field the strip does not show removes nothing (the control), the field\'s LABEL and a case variant remove nothing, and two members remove two chips. The same file pins `enforceFieldSecurity` beside it — a denied field leaves no chip only when the key is `true`, with the key-absent and string-spelling rows as controls. New file (objectui#8649).', + }, + 'record:highlights.requiredPermissions': { + file: 'packages/plugin-detail/src/renderers/__tests__/record-blocks.requiredPermissions-gate.test.tsx', + pins: 'Members are ADR-0066 CAPABILITY names and the block needs ALL of them, driven through the real renderer under a REAL stock `MePermissionsProvider` (not a mocked `usePermissions`, which cannot discriminate the two reading paths): an unheld capability and an unrecognised one each put the insufficient-permissions notice in place of the block, a held one renders it, an EMPTY array and an ABSENT key are no gate at all, and the discriminating row is a PARTIAL grant on a two-entry array — gated only when the members are read as `.every` over the WHOLE array. Two discriminators pin that a member is a capability and not an object action (`allowRead` on the object does not open the gate; the enum member `manage` is gated rather than resolved to the read bit), and the role-based `PermissionProvider` pins the unreported-capabilities fail-open. One file runs every row against all three record blocks (objectui#10155); promoted to a member pin by objectui#8649, which published the key.', + }, 'record:path.stages': { file: 'packages/plugin-detail/src/renderers/__tests__/recordPathStagesMembers-8071.test.tsx', pins: 'The stage member set `{ value, label, terminal? }`, read through the real renderer. `value` is stage IDENTITY — compared against the record\'s `statusField` to choose the current stage, controlled by moving the RECORD rather than the array, by an unmatched value leaving NO stage current, and by a record carrying some stage\'s LABEL still matching nothing. `label` is the rendered text and the `value` behind it never appears. `terminal` OUTRANKS the `WON_TOKENS`/`LOST_TOKENS` heuristic, pinned only on fixtures where the member CONTRADICTS the heuristic and each paired with the identical stage minus `terminal` to show which way the heuristic was pointing, plus an unclassified control. Deliberately the DESKTOP row only: `record-path.crossRowClassification.test.tsx` owns the cross-row agreement invariant, which is a different claim (objectui#8071 slice 6). The `label` member is an `I18nLabel`: its locale-map arm, resolved before the stages are classified or rendered, is pinned through the real `SchemaRenderer` under `zh` and `en` by `record-path.stageLabelI18nLabel-10993.test.tsx` (objectui#10993).', @@ -2969,7 +2974,7 @@ const MEMBER_PINS: Record = { }, 'record:related_list.columns': { file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx', - pins: 'THE `page:header.actions` HOLE ON THIS KEY, asserted as the gap it is: the registration declares `of: \'string\'` and the block folds FIVE member spellings — a bare string, the spec-canonical `{ field }`, the legacy `{ name }` / `{ fieldName }`, and `{ key }`, a tail fallback that is this block\'s alone (`columnIdentity` REFUSES it, asserted next to it, which is the whole content of "tail"). Canonical-first is proven both ways on one mixed `{ field, name }` member, so a fold reading either key alone fails. Two rows carry the sharp edge: a member whose identity does not resolve is DROPPED — the fold fails closed (objectui#8793), because an entry it cannot name is an entry it cannot check, while `RelatedList` renders that entry anyway as `accessorKey || columnIdentity` (`accessorKey` is the instance, excluded from `columnIdentity` on purpose), so keeping it was a field-security bypass — ⚠️ two of the three legs of that bypass now have a SECOND gate downstream, `filterFLS` for a declared field that field security denies and, since objectui#9090, `filterRedacted` for a redacted one, both resolving the very `accessorKey || columnIdentity` pair this fold refuses, so what THIS fold alone still decides is a key the permission evaluator has no opinion about (one the child object never declares), measured by ablation on the merged base and pinned as THE RESIDUAL LEG in `RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx`; the same row pins that the drop is by unresolvability rather than by matching the redacted name, and that an UNFILTERED list still hands the member down untouched — and a mixed set must come back SHORTER and in order, which is the non-vacuity a single-member array cannot give. Every positive carries its control in the same call (the member survives when a DIFFERENT field is redacted), and the instrument itself — `redactFields`, a renderer-only key on neither the spec nor `inputs` — is asserted to be undeclared so the file cannot be read as licensing it. The end-to-end half (an object member reaching the screen with VALUES) is objectui#5022\'s file, which drives `RelatedList` directly and never runs this fold (objectui#8071).', + pins: 'THE `page:header.actions` HOLE ON THIS KEY, asserted as the gap it is: the registration declares `of: \'string\'` and the block folds FIVE member spellings — a bare string, the spec-canonical `{ field }`, the legacy `{ name }` / `{ fieldName }`, and `{ key }`, a tail fallback that is this block\'s alone (`columnIdentity` REFUSES it, asserted next to it, which is the whole content of "tail"). Canonical-first is proven both ways on one mixed `{ field, name }` member, so a fold reading either key alone fails. Two rows carry the sharp edge: a member whose identity does not resolve is DROPPED — the fold fails closed (objectui#8793), because an entry it cannot name is an entry it cannot check, while `RelatedList` renders that entry anyway as `accessorKey || columnIdentity` (`accessorKey` is the instance, excluded from `columnIdentity` on purpose), so keeping it was a field-security bypass — ⚠️ two of the three legs of that bypass now have a SECOND gate downstream, `filterFLS` for a declared field that field security denies and, since objectui#9090, `filterRedacted` for a redacted one, both resolving the very `accessorKey || columnIdentity` pair this fold refuses, so what THIS fold alone still decides is a key the permission evaluator has no opinion about (one the child object never declares), measured by ablation on the merged base and pinned as THE RESIDUAL LEG in `RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx`; the same row pins that the drop is by unresolvability rather than by matching the redacted name, and that an UNFILTERED list still hands the member down untouched — and a mixed set must come back SHORTER and in order, which is the non-vacuity a single-member array cannot give. Every positive carries its control in the same call (the member survives when a DIFFERENT field is redacted), and the instrument itself — `redactFields`, renderer-only until `@objectstack/spec` 17.5.0 declared it and objectui#8649 published it — is asserted to be published exactly when the contract declares it, so the file cannot be read as licensing it. The end-to-end half (an object member reaching the screen with VALUES) is objectui#5022\'s file, which drives `RelatedList` directly and never runs this fold (objectui#8071).', }, 'record:related_list.dataSource': { file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.elementDataSource.test.tsx', @@ -2979,6 +2984,14 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-detail/src/__tests__/RelatedList.listFilter.test.tsx', pins: 'Members are spec `ViewFilterRule` entries, pinned at the WIRE rather than at a prop: `{ field, operator, value }` reaches `dataSource.find` as `[[field, operator, value]]` through the shared sink, ANDed BEHIND the parent relationship so "additional criteria" can only ever narrow this record\'s children. The negatives are what make it a member contract: an empty `filter: []` is unauthored (the query stays the byte-identical MongoDB-style object, not a freshly lowered AST that means the same), the composed AST a binding leaves on this key is accepted WITHOUT being converted twice, the filter survives onto the windowed fetch, and on the raw-URL fallback — a channel that cannot carry an operator — the block REFUSES to fetch rather than answering wider than the metadata asked. Pre-existing file, promoted after being read (objectui#8071).', }, + 'record:related_list.redactFields': { + file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.redactedDerivation-9053.test.tsx', + pins: 'Members are field NAMES and the policy reaches EVERY path that decides columns, asserted on real DOM cells: redacting the only authored column does not resurrect it through auto-derivation, a partial redaction leaves the rest, a redacted field never enters the heuristic-walk or `highlightFields` derived set, and the same policy holds on `RelatedList` itself — each with its no-redaction control. The member-IDENTITY half (which spellings the block\'s fold can name, and that an unnameable one is dropped) is `record:related_list.columns`\' pin. Pre-existing file (objectui#9053), promoted to a member pin by objectui#8649, which published the key.', + }, + 'record:related_list.requiredPermissions': { + file: 'packages/plugin-detail/src/renderers/__tests__/record-blocks.requiredPermissions-gate.test.tsx', + pins: 'Members are ADR-0066 CAPABILITY names and the block needs ALL of them, driven through the real renderer under a REAL stock `MePermissionsProvider` (not a mocked `usePermissions`, which cannot discriminate the two reading paths): an unheld capability and an unrecognised one each put the insufficient-permissions notice in place of the block, a held one renders it, an EMPTY array and an ABSENT key are no gate at all, and the discriminating row is a PARTIAL grant on a two-entry array — gated only when the members are read as `.every` over the WHOLE array. Two discriminators pin that a member is a capability and not an object action (`allowRead` on the object does not open the gate; the enum member `manage` is gated rather than resolved to the read bit), and the role-based `PermissionProvider` pins the unreported-capabilities fail-open. One file runs every row against all three record blocks (objectui#10155); promoted to a member pin by objectui#8649, which published the key.', + }, 'record:related_list.sort': { file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.sortMembers.test.tsx', pins: 'Members are `{ field, order }` and reach `$orderby` verbatim and IN ORDER, driven through the block to the wire because `normalizeSortSpec` — not the block — is what reads them. The rows a declaration reading only "array" can never publish: a member with no `field` is dropped SILENTLY while its siblings survive (a two-key order quietly becomes one-key), an all-unusable array sends no `$orderby` rather than an empty clause, and the STRING arm the registration does not declare at all is read as the OData-ish `field` / `-field` — deliberately NOT `ListView.sort`\'s legacy space-separated clause, which the same spec union spells the same way and means differently (objectui#8221 retired that one at the derivation boundary only, and this key still accepts what it accepts). A CONTROL row pins the declaration as a bare array with no `of` and no description, so making it honest reds this file and forces the pins to be re-read (objectui#8071).', @@ -5551,6 +5564,6 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length, ]), ), - ).toEqual({ 'objectui#11168': 62, 'objectui#8652': 2, 'objectui#8649': 9 }); + ).toEqual({ 'objectui#11168': 62, 'objectui#8652': 2, 'objectui#8649': 0 }); }); }); diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx index d414987600..eae713ff3d 100644 --- a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx @@ -45,14 +45,15 @@ * `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'; @@ -60,6 +61,7 @@ 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'; @@ -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); }); }); diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx index 89592862bf..82bf536910 100644 --- a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx @@ -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'; diff --git a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts index 47fb5a07c4..84da56c853 100644 --- a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts @@ -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)[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 | 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']); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts index 4d34ccf00f..2e573f05ab 100644 --- a/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts @@ -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)[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 | 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']); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/recordRelatedListInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordRelatedListInputs.spec-parity.test.ts index 2bf19c7d93..3b6b1f5640 100644 --- a/packages/plugin-detail/src/__tests__/recordRelatedListInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordRelatedListInputs.spec-parity.test.ts @@ -287,3 +287,62 @@ describe('record:related_list — registry inputs vs @objectstack/spec', () => { expect(addDescription()).toMatch(/hard constraint the user cannot widen/); }); }); + +/** + * 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:related_list`. 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:related_list — 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) => + (RecordRelatedListProps.shape as Record)[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(RecordRelatedListProps.shape), `${key} is not declared on record:related_list`).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 = RecordRelatedListProps.safeParse({ ...baseline, [key]: want.accepted }); + expect(ok.success, `${key}: the contract refused a value of the published type`).toBe(true); + expect((ok.data as Record | undefined)?.[key]).toEqual(want.accepted); + const bad = RecordRelatedListProps.safeParse({ ...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(RecordRelatedListProps.safeParse(baseline).success).toBe(true); + const bad = RecordRelatedListProps.safeParse({ ...baseline, enforceFieldSecurity: 'true' }); + expect(bad.error?.issues.map((i) => i.path.join('.'))).toEqual(['enforceFieldSecurity']); + }); +}); diff --git a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts index 2bb72c36c1..180b5b1037 100644 --- a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts +++ b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts @@ -52,23 +52,23 @@ * which is the same standing `dataSource` and `className` have in * `recordRelatedListInputs.spec-parity.test.ts`. ⇒ ALIGN THE MIRROR, for the * one member this renderer reads off the envelope. - * - `enforceFieldSecurity` and `redactFields` (three renderers each) — declared - * by NO block the UI contract maps, and not on the shared node envelope - * either. ⇒ "declare" is off the table - * outright. ROUTED TO THE PRODUCER, ⛔ not retired here: the renderers honour - * both keys today on the raw-node path, so deleting the reads would remove a - * redaction that is working, and changing runtime masking behaviour is the - * maintainer floor this card must not cross. - * - `requiredPermissions` (three renderers each) — the sharpest of the twelve. - * The contract DOES declare it, on the sibling block `record:quick_actions`, - * but NOT on the three this card covers. A word-frequency screen over the contract reads "present" and is - * wrong about exactly this; the per-schema census below is what separates - * them. ⇒ ROUTED TO THE PRODUCER, same floor. - * - ⭐ UPDATE, `@objectstack/spec` 17.5.0 (objectui#11073): the routed card - * LANDED upstream. All three keys are now declared on exactly these three - * blocks, so the two routings above have expired into align-the-mirror. - * The census rows fired and were flipped; the alignment itself is this - * card's burn-down and was ⛔ not taken by the bump. + * - The field-security triple — `enforceFieldSecurity`, `redactFields`, + * `requiredPermissions` (three renderers each). Through `@objectstack/spec` + * 17.4.0 the first two were declared by NO block the UI contract maps, and + * `requiredPermissions` only on the sibling block `record:quick_actions` — + * the reading a word-frequency screen got backwards. The first landing of + * this card ROUTED all three to the producer (objectstack#18159), ⛔ neither + * declared nor retired: the renderers honoured them on the raw-node path, + * and changing runtime masking or permission behaviour is the maintainer + * floor this card must not cross. + * - ⭐ The routed card LANDED (objectstack PR #19913, ruling A) and shipped in + * `@objectstack/spec` 17.5.0: all three keys are declared on exactly these + * three blocks. The census rows below fired at the 17.5.0 bump + * (objectui#11073), which declared nothing; this card's SECOND landing took + * the exit that expiry left — ALIGN THE MIRROR: the three keys are declared + * on the three props interfaces, the reads are un-cast, and the three + * blocks' registry `inputs` publish them. The emitted JavaScript of the three + * renderers is byte-identical, so no gating or masking behaviour moved. * * ## What each leg can and cannot prove * @@ -86,10 +86,11 @@ * - The source-text legs read the three renderers through the shared comment * mask, so a re-introduced `{} as any` is caught by a run that never * type-checks. Each carries a control that varies only the claim. - * - {@link ROUTED_KEYS} is a LEDGER, and a stale exception is a hole: every - * entry is asserted to be STILL READ by the file it is ledgered against. If - * someone retires one of these reads, this file goes red and the routing - * claim has to be re-derived rather than quietly outliving its subject. + * - {@link ALIGNED_TRIPLE} names the renderer files each triple key is read + * in. Every entry is asserted STILL READ, and read UN-CAST: a cast between + * `schema` and the key would spend the declaration at its own read site — + * the D1 shape this card's first contract review found — while every + * membership-shaped leg above stayed green. */ import { describe, it, expect } from 'vitest'; @@ -109,8 +110,15 @@ import { type ComponentPropsInput, type ReferenceRailEntry, } from '@objectstack/spec/ui'; -import type { RecordRelatedListComponentProps } from '@object-ui/types'; +import type { + RecordDetailsComponentProps, + RecordHighlightsComponentProps, + RecordRelatedListComponentProps, +} from '@object-ui/types'; import type { RecordReferenceRailRendererProps } from '../record-reference-rail'; +import type { RecordDetailsRendererProps } from '../record-details'; +import type { RecordHighlightsRendererProps } from '../record-highlights'; +import type { RecordRelatedListRendererProps } from '../record-related-list'; // @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) import { maskComments } from '../../../../../scripts/js-comment-mask.mjs'; @@ -202,6 +210,144 @@ export type _RailPropertiesEntriesIsSpecEntry = Expect< Equal['entries'], ReferenceRailEntry[] | undefined> >; +/* + * Exit "align the mirror", keys 3-5: the field-security triple on all three + * blocks (objectui#8649's second landing, on `@objectstack/spec` 17.5.0). + * + * Each mirror member is compared against the contract's own input type for + * the same block, reached through the same block-tag map as the + * `relationshipValueField` pin above. RED before the second landing: `TS2339`, + * the members did not exist on the three interfaces. + */ + +type SpecDetailsProps = ComponentPropsInput<'record:details'>; +type SpecHighlightsProps = ComponentPropsInput<'record:highlights'>; + +export type _DetailsTripleMirrorsSpec = Expect< + Equal< + [ + RecordDetailsComponentProps['enforceFieldSecurity'], + RecordDetailsComponentProps['redactFields'], + RecordDetailsComponentProps['requiredPermissions'], + ], + [ + SpecDetailsProps['enforceFieldSecurity'], + SpecDetailsProps['redactFields'], + SpecDetailsProps['requiredPermissions'], + ] + > +>; + +export type _HighlightsTripleMirrorsSpec = Expect< + Equal< + [ + RecordHighlightsComponentProps['enforceFieldSecurity'], + RecordHighlightsComponentProps['redactFields'], + RecordHighlightsComponentProps['requiredPermissions'], + ], + [ + SpecHighlightsProps['enforceFieldSecurity'], + SpecHighlightsProps['redactFields'], + SpecHighlightsProps['requiredPermissions'], + ] + > +>; + +export type _RelatedListTripleMirrorsSpec = Expect< + Equal< + [ + RecordRelatedListComponentProps['enforceFieldSecurity'], + RecordRelatedListComponentProps['redactFields'], + RecordRelatedListComponentProps['requiredPermissions'], + ], + [ + SpecRelatedListProps['enforceFieldSecurity'], + SpecRelatedListProps['redactFields'], + SpecRelatedListProps['requiredPermissions'], + ] + > +>; + +/** + * Spelled out as well as derived, for the reason given at + * `_RelationshipValueFieldIsOptionalString`: `Equal` against the spec alone is + * satisfied if BOTH faces drift to the same wrong type. + */ +type TripleShape = [boolean | undefined, string[] | undefined, string[] | undefined]; + +export type _TripleIsTheContractShapeOnEveryBlock = Expect< + Equal< + [ + [ + RecordDetailsComponentProps['enforceFieldSecurity'], + RecordDetailsComponentProps['redactFields'], + RecordDetailsComponentProps['requiredPermissions'], + ], + [ + RecordHighlightsComponentProps['enforceFieldSecurity'], + RecordHighlightsComponentProps['redactFields'], + RecordHighlightsComponentProps['requiredPermissions'], + ], + [ + RecordRelatedListComponentProps['enforceFieldSecurity'], + RecordRelatedListComponentProps['redactFields'], + RecordRelatedListComponentProps['requiredPermissions'], + ], + ], + [TripleShape, TripleShape, TripleShape] + > +>; + +/** + * The renderers' own `schema` bindings carry the members — the type every read + * in the three files goes through. ⚠️ A MEMBERSHIP question: a cast at the read + * site leaves these green, which is why the source-text legs below exist. + */ +export type _RendererBindingsCarryTheTriple = Expect< + Equal< + [ + [ + NonNullable['enforceFieldSecurity'], + NonNullable['redactFields'], + NonNullable['requiredPermissions'], + ], + [ + NonNullable['enforceFieldSecurity'], + NonNullable['redactFields'], + NonNullable['requiredPermissions'], + ], + [ + NonNullable['enforceFieldSecurity'], + NonNullable['redactFields'], + NonNullable['requiredPermissions'], + ], + ], + [TripleShape, TripleShape, TripleShape] + > +>; + +/** The card's repro as literals: spec-valid documents, accepted by all three faces. */ +const tripleAccepted = { + details: { + enforceFieldSecurity: true, + redactFields: ['salary'], + requiredPermissions: ['crm.manage'], + } satisfies RecordDetailsComponentProps, + highlights: { + fields: ['name'], + enforceFieldSecurity: true, + redactFields: ['salary'], + requiredPermissions: ['crm.manage'], + } satisfies RecordHighlightsComponentProps, + relatedList: { + objectName: 'task', + relationshipField: 'account', + enforceFieldSecurity: true, + redactFields: ['salary'], + requiredPermissions: ['crm.manage'], + } satisfies RecordRelatedListComponentProps, +}; + /* ── Runtime legs ─────────────────────────────────────────────────────────── */ /** @@ -272,16 +418,33 @@ const nodeLevelKeys = (): string[] => objectShapeKeys(PageComponentSchema) ?? [] const CARD_BLOCKS = ['record:details', 'record:highlights', 'record:related_list'] as const; /** - * The keys routed to the producer, and the renderer files each is ledgered - * against. Every entry is asserted STILL READ below — a ledger entry whose - * subject has gone is a hole, not a pass. + * The field-security triple, and the renderer files each key is read in. It + * was the ROUTED-to-producer ledger until the contract declared the keys; it is + * now the set whose reads must stay live AND un-cast (see the legs below). */ -const ROUTED_KEYS = { +const ALIGNED_TRIPLE = { enforceFieldSecurity: ['record-details.tsx', 'record-highlights.tsx', 'record-related-list.tsx'], redactFields: ['record-details.tsx', 'record-highlights.tsx', 'record-related-list.tsx'], requiredPermissions: ['record-details.tsx', 'record-highlights.tsx', 'record-related-list.tsx'], } as const; +/** The block each renderer file registers, for the contract readings below. */ +const BLOCK_OF_FILE = { + 'record-details.tsx': 'record:details', + 'record-highlights.tsx': 'record:highlights', + 'record-related-list.tsx': 'record:related_list', +} as const; + +/** + * A cast standing between `schema` and `key`, e.g. `(schema as any).key`. + * Applied to the real sources AND to a control below. + */ +const castBefore = (key: string): RegExp => + new RegExp(`\\(\\s*schema\\s+as\\s+\\w+\\s*\\)\\s*\\.\\s*${key}\\b`); + +/** The un-cast read, e.g. `schema.key` — the liveness half. */ +const unCastRead = (key: string): RegExp => new RegExp(`(? { } }); -describe('objectui#8649 — the routed-key ledger is not stale', () => { - for (const [key, files] of Object.entries(ROUTED_KEYS)) { +describe('objectui#8649 — the field-security triple is read UN-CAST, and still read', () => { + it('the two matchers discriminate, so the readings below are readings', () => { + // Each control varies ONLY the claim. + expect(castBefore('redactFields').test('Array.isArray((schema as any).redactFields)')).toBe(true); + expect(castBefore('redactFields').test('Array.isArray(schema.redactFields)')).toBe(false); + expect(unCastRead('redactFields').test('Array.isArray(schema.redactFields)')).toBe(true); + expect(unCastRead('redactFields').test('Array.isArray((schema as any).redactFields)')).toBe(false); + // Comments are masked before the legs run, so prose that spells the cast + // out — this file's and the renderers' — can neither satisfy nor defeat them. + expect(mask('// (schema as any).redactFields\nconst x = 1;')).not.toMatch(castBefore('redactFields')); + }); + + for (const [key, files] of Object.entries(ALIGNED_TRIPLE)) { for (const file of files) { - it(`${file} still reads \`${key}\` (ledger entry stays live)`, () => { + it(`${file} reads \`${key}\` off \`schema\` un-cast, and still reads it at all`, () => { const source = maskedSource(file); - expect(source).toContain(`.${key}`); + // Liveness: a negative guard alone is satisfied by deleting the read. + expect(source).toMatch(unCastRead(key)); + // ⭐ The load-bearing NEGATIVE: no cast may stand between the two again. + expect(source).not.toMatch(castBefore(key)); }); } } + it('every file in the ledger registers a block the contract declares the whole triple on', () => { + // So the un-cast reads above are reads of DECLARED keys, block by block — + // the premise of removing each cast, re-derived rather than assumed. + for (const [key, files] of Object.entries(ALIGNED_TRIPLE)) { + for (const file of files) { + expect(declaringBlocksOf(key), `${key} on ${file}`).toContain( + BLOCK_OF_FILE[file as keyof typeof BLOCK_OF_FILE], + ); + } + } + }); + /** * ⭐ The assertion this block used to carry was * `toMatch(/properties\??\.entries/)`, and it was WORTHLESS for the thing it @@ -421,8 +610,8 @@ describe('objectui#8649 — the routed-key ledger is not stale', () => { expect(CAST_BEFORE_PROPERTIES.test('Array.isArray(schema.properties?.entries)')).toBe(false); }); - it('the ledger matcher can fire negative, so the legs above are readings', () => { - expect(mask('const x = 1;')).not.toContain('.enforceFieldSecurity'); + it('the liveness matcher can fire negative, so the legs above are readings', () => { + expect(mask('const x = 1;')).not.toMatch(unCastRead('enforceFieldSecurity')); }); }); @@ -433,6 +622,35 @@ describe('objectui#8649 — the aligned mirror keys reach the renderers', () => expect(relationshipValueFieldAccepted.relationshipValueField).toBe('name'); }); + it('the triple fixtures are reachable from a vitest run (the type legs are the discriminating half)', () => { + expect(tripleAccepted.details.redactFields).toEqual(['salary']); + expect(tripleAccepted.highlights.requiredPermissions).toEqual(['crm.manage']); + expect(tripleAccepted.relatedList.enforceFieldSecurity).toBe(true); + }); + + it('the contract accepts the triple fixtures on each block, values intact, and refuses a wrong-typed value', () => { + const cases = [ + ['record:details', tripleAccepted.details], + ['record:highlights', tripleAccepted.highlights], + ['record:related_list', { ...tripleAccepted.relatedList, columns: ['name'] }], + ] as const; + for (const [tag, doc] of cases) { + const schema = ComponentPropsMap[tag] as unknown as { + safeParse: (v: unknown) => { success: boolean; data?: Record; error?: { issues: Array<{ path: PropertyKey[] }> } }; + }; + const parsed = schema.safeParse(doc); + expect(parsed.success, tag).toBe(true); + expect(parsed.data?.enforceFieldSecurity, tag).toBe(true); + expect(parsed.data?.redactFields, tag).toEqual(['salary']); + expect(parsed.data?.requiredPermissions, tag).toEqual(['crm.manage']); + // A VALUE-level refusal, with the path it names: the mirror's + // `string[]` is the contract's, not a looser face over it. + const wrong = schema.safeParse({ ...doc, requiredPermissions: 'crm.manage' }); + expect(wrong.success, `${tag} accepted a bare-string requiredPermissions`).toBe(false); + expect(wrong.error?.issues.map((i) => i.path.join('.')), tag).toContain('requiredPermissions'); + } + }); + it('the contract accepts the same document, so mirror and contract agree', () => { const parsed = RecordRelatedListProps.safeParse({ objectName: 'task', diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.hideFieldsUncast-9965.test.ts b/packages/plugin-detail/src/renderers/__tests__/record-details.hideFieldsUncast-9965.test.ts index d151f47680..7f1c767e2e 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.hideFieldsUncast-9965.test.ts +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.hideFieldsUncast-9965.test.ts @@ -15,15 +15,18 @@ * * ## The census this card is fenced to * - * The file carries SEVEN `(schema as any)` occurrences over FOUR distinct keys. - * Exactly TWO of those occurrences read a key the mirror declares, and both are - * `hideFields` in the highlight-dedup path. The other five read - * `requiredPermissions`, `enforceFieldSecurity` and `redactFields` — keys the - * mirror does NOT declare, where a cast is the honest spelling until someone - * declares them. They are LEDGERED below with the property that makes each - * exemption expire: the ledger asserts the key is still absent from the - * mirror, so the day it is declared this file goes red instead of the - * exemption quietly outliving its subject. + * When this card landed the file carried SEVEN `(schema as any)` occurrences + * over FOUR distinct keys. Exactly TWO of those occurrences read a key the + * mirror declared, and both were `hideFields` in the highlight-dedup path — + * this card's repair. The other five read `requiredPermissions`, + * `enforceFieldSecurity` and `redactFields`, keys the mirror did NOT declare + * then, so a cast was the honest spelling. They were LEDGERED below with the + * property that makes each exemption expire — the key still absent from the + * mirror — and they expired as designed: `@objectstack/spec` 17.5.0 declared + * all three on `record:details`, objectui#8649 declared them on the mirror and + * removed the five casts, and this file went red until the ledger was emptied. + * The occurrence counts in this paragraph are that history, ⛔ not a live + * census: the legs below re-derive the reads every run. * * ## The three instruments, and which one actually settles it * @@ -145,30 +148,23 @@ type _MirrorRefusesTheMisspelling = RecordDetailsComponentProps['hideFeilds']; * entry is asserted to be ABSENT FROM THE MIRROR below, so the exemption * expires the moment its premise does. * - * ⚠️ SINCE THE 17.5.0 BUMP THIS IS ALSO THE SOURCE-TEXT GUARD'S LEDGER, which - * is why that guard is no longer carve-out-free. `@objectstack/spec` 17.5.0 - * declares all three keys on `record:details`, so they entered the CONTRACT's - * population while the MIRROR still lacks them and the renderer still casts. - * objectui#11111 decision 3 = B (record 5902351047) booked them to - * objectui#8649, which declares them on the mirror and un-casts the reads; - * the bump declares nothing. Capped at exactly these three by the test - * "no cast stands between schema and ANY contract-declared key", and - * objectui#8649's landing empties this map — the last booked entry leaving - * restores the guard to no ledger at all. Expiry: 2026-10-30, or when - * objectui#8649 lands, whichever is first (stated, not clock-enforced: this - * ledger has no date field). + * ⭐ EMPTY since objectui#8649, and that is the measured outcome, not a + * default. It is also the source-text guard's ledger, which held the three + * keys objectui#11111 decision 3 = B (record 5902351047) booked to + * objectui#8649 once `@objectstack/spec` 17.5.0 declared them on + * `record:details`: `requiredPermissions`, `enforceFieldSecurity`, + * `redactFields`. objectui#8649 declared them on the mirror and removed the + * casts, so every entry expired at once and the guard is carve-out-free again. + * ⛔ An entry added here is a cast over a key the mirror declares somewhere: + * move the declaration or the read, don't ledger it. */ -const HONEST_CASTS: Record = { - requiredPermissions: - 'the mirror declares no such key — `RecordDetailsProps` has no permission gate, so the cast is the honest spelling until one is declared. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047): the contract declares it since @objectstack/spec 17.5.0. Expires 2026-10-30, or when objectui#8649 lands.', - enforceFieldSecurity: - 'the mirror declares no such key — field-level security is read off the object, not off the page block. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047): the contract declares it since @objectstack/spec 17.5.0. Expires 2026-10-30, or when objectui#8649 lands.', - redactFields: - 'the mirror declares no such key — it travels with `enforceFieldSecurity` and shares its fate. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047): the contract declares it since @objectstack/spec 17.5.0. Expires 2026-10-30, or when objectui#8649 lands.', -}; - -/** The cap: the booked casts, by name. objectui#8649's landing takes it to `[]`. */ -const OBJECTUI_11111_BOOKED_CASTS = ['enforceFieldSecurity', 'redactFields', 'requiredPermissions']; +const HONEST_CASTS: Record = {}; + +/** The cap: the booked casts, by name. Taken to `[]` by objectui#8649's landing. */ +const OBJECTUI_11111_BOOKED_CASTS: string[] = []; + +/** The field-security triple objectui#8649 declared on the mirror — each asserted DECLARED below. */ +const FIELD_SECURITY_TRIPLE = ['enforceFieldSecurity', 'redactFields', 'requiredPermissions']; interface SchemaRead { /** The property name read off `schema`. */ @@ -184,12 +180,39 @@ interface SchemaRead { interface Measurement { /** Every `schema.KEY` / `(schema as X).KEY` read in the renderer. */ reads: SchemaRead[]; + /** + * The same reads, measured by the same visitor and checker over + * {@link CONTROL_SOURCE} — a virtual file that DOES carry a cast read and an + * `any` read, so the renderer carrying neither is a reading, not a blind spot. + */ + controlReads: SchemaRead[]; /** Keys the MIRROR declares, derived from the very type the read goes through. */ declared: string[]; /** The index signature's value type, or null when the intersection lost it. */ indexType: string | null; } +/** + * The lit control for the program leg (objectui#8649). + * + * Until objectui#8649 the renderer itself supplied both calibrations below — it + * carried five cast reads, each typed `any`. Removing them is the point of that + * card, and it left the renderer with no cast and no `any` read for the + * instrument to find, so "found none" would read the same as "cannot see one". + * This virtual file puts the three shapes back in front of the SAME checker, in + * the SAME program, typed through the renderer's own exported props, so the + * instrument is shown to report each one before its answer about the renderer + * is believed. It exists only in the program's compiler host, never on disk. + */ +const CONTROL_FILE = join(HERE, '..', '__objectui_8649_program_control__.ts'); +const CONTROL_SOURCE = [ + "import type { RecordDetailsRendererProps } from './record-details';", + "declare const schema: NonNullable;", + 'export const castRead = (schema as any).hideFields;', + 'export const unCastRead = schema.hideFields;', + 'export const undeclaredRead = schema.zzqxNoSuchRecordBlockKey;', +].join('\n'); + /** * Build a real program over the renderer and measure every read off `schema`. * @@ -215,17 +238,34 @@ function measure(): Measurement { const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, REPO_ROOT, undefined, configPath); expect(parsed.options.paths?.['@object-ui/types'], 'the mirror must resolve to source').toBeTruthy(); - const program = ts.createProgram([RENDERER], { + const options: ts.CompilerOptions = { ...parsed.options, noEmit: true, skipLibCheck: true, types: [], - }); + }; + // The control file is served from memory; every other file comes off disk + // exactly as before. + const host = ts.createCompilerHost(options); + const diskGetSourceFile = host.getSourceFile.bind(host); + const diskFileExists = host.fileExists.bind(host); + const diskReadFile = host.readFile.bind(host); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => + fileName === CONTROL_FILE + ? ts.createSourceFile(fileName, CONTROL_SOURCE, languageVersion, true) + : diskGetSourceFile(fileName, languageVersion, onError, shouldCreate); + host.fileExists = (fileName) => fileName === CONTROL_FILE || diskFileExists(fileName); + host.readFile = (fileName) => (fileName === CONTROL_FILE ? CONTROL_SOURCE : diskReadFile(fileName)); + + const program = ts.createProgram([RENDERER, CONTROL_FILE], options, host); const checker = program.getTypeChecker(); const source = program.getSourceFile(RENDERER); expect(source, 'the renderer must be a program input').toBeTruthy(); + const control = program.getSourceFile(CONTROL_FILE); + expect(control, 'the control file must be a program input').toBeTruthy(); const reads: SchemaRead[] = []; + const controlReads: SchemaRead[] = []; let schemaType: ts.Type | null = null; /** Unwrap parens / casts / non-null down to the identifier being read. */ @@ -246,27 +286,36 @@ function measure(): Measurement { return { id: ts.isIdentifier(cur) ? cur : null, cast }; }; - const visit = (node: ts.Node): void => { - if (ts.isPropertyAccessExpression(node)) { - const { id, cast } = rootOf(node.expression); - if (id && id.text === 'schema') { - if (!schemaType) schemaType = checker.getTypeAtLocation(id); - reads.push({ - key: node.name.text, - line: source!.getLineAndCharacterOfPosition(node.getStart(source!)).line + 1, - cast, - type: checker.typeToString(checker.getTypeAtLocation(node)), - }); + const visitor = (file: ts.SourceFile, into: SchemaRead[], bindSchemaType: boolean) => { + const visit = (node: ts.Node): void => { + if (ts.isPropertyAccessExpression(node)) { + const { id, cast } = rootOf(node.expression); + if (id && id.text === 'schema') { + if (bindSchemaType && !schemaType) schemaType = checker.getTypeAtLocation(id); + into.push({ + key: node.name.text, + line: file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1, + cast, + type: checker.typeToString(checker.getTypeAtLocation(node)), + }); + } } - } - ts.forEachChild(node, visit); + ts.forEachChild(node, visit); + }; + return visit; }; - visit(source!); + visitor(source!, reads, true)(source!); + visitor(control!, controlReads, false)(control!); const resolved: ts.Type | null = schemaType; const declared = resolved ? checker.getPropertiesOfType(resolved).map((s) => s.name).sort() : []; const indexInfo = resolved ? checker.getIndexInfoOfType(resolved, ts.IndexKind.String) : undefined; - return { reads, declared, indexType: indexInfo ? checker.typeToString(indexInfo.type) : null }; + return { + reads, + controlReads, + declared, + indexType: indexInfo ? checker.typeToString(indexInfo.type) : null, + }; } /** @@ -284,24 +333,43 @@ describe('objectui#9965 — the instrument is calibrated before it is believed', expect(measurement().declared).toContain('hideFields'); expect(measurement().declared).toContain('sections'); expect(measurement().declared.length).toBeGreaterThan(4); - // …and the keys this card leaves cast are genuinely NOT in it. + // …and any key still ledgered as cast is genuinely NOT in it (none since + // objectui#8649 — the loop is kept so a new entry is judged the same way). for (const key of Object.keys(HONEST_CASTS)) { expect(measurement().declared).not.toContain(key); } + // objectui#8649: the field-security triple IS in it now — the membership + // half of that card's alignment, read off the renderer's own binding. + for (const key of FIELD_SECURITY_TRIPLE) { + expect(measurement().declared).toContain(key); + } }); - it('found the reads it is meant to range over, cast and un-cast alike', () => { + it('found the reads it is meant to range over', () => { expect(measurement().reads.length).toBeGreaterThan(8); - expect(measurement().reads.some((r) => r.cast)).toBe(true); expect(measurement().reads.some((r) => !r.cast)).toBe(true); }); + it('CONTROL — the same visitor and checker report a cast and an `any` when one is there', () => { + // Since objectui#8649 the renderer carries neither, so this is the only + // place the instrument is shown to see them (see `CONTROL_SOURCE`). Each row + // varies one thing against its neighbour. + const byKey = (key: string, cast: boolean) => + measurement().controlReads.find((r) => r.key === key && r.cast === cast); + expect(byKey('hideFields', true)?.type).toBe('any'); + expect(byKey('hideFields', false)?.type).toBe('string[] | undefined'); + expect(byKey('zzqxNoSuchRecordBlockKey', false)?.type).toBe('any'); + expect(measurement().controlReads).toHaveLength(3); + }); + it('`any` is a reachable verdict here, so the assertion below can fail', () => { // The index signature is what admits an undeclared key at `any`. Without // this control, "no declared read carries `any`" would also be satisfied by - // an instrument that never reports `any` at all. + // an instrument that never reports `any` at all. The renderer supplied an + // `any` read of its own until objectui#8649 removed the last ones; the + // control file supplies it now. expect(measurement().indexType).toBe('any'); - expect(measurement().reads.filter((r) => r.type === 'any').length).toBeGreaterThan(0); + expect(measurement().controlReads.filter((r) => r.type === 'any').length).toBeGreaterThan(0); }); }); @@ -315,6 +383,23 @@ describe('objectui#9965 — the declaration reaches the read', () => { expect(spent).toEqual([]); }); + it('the field-security triple is still READ, and each read carries the mirror\'s type (objectui#8649)', () => { + // The expression half of objectui#8649's alignment on this file: the leg + // above already refuses a cast or an `any` on these keys now that the mirror + // declares them; this one is the liveness and the exact type, so deleting + // a read cannot satisfy it. + for (const key of FIELD_SECURITY_TRIPLE) { + const hits = measurement().reads.filter((r) => r.key === key); + expect(hits.length, `${key} is no longer read`).toBeGreaterThan(0); + // `string[]` is the `Array.isArray` narrowing of the same declaration. + const declaredAs = key === 'enforceFieldSecurity' ? ['boolean | undefined'] : ['string[] | undefined', 'string[]']; + for (const hit of hits) { + expect(hit.cast, `${key} @ line ${hit.line} is cast`).toBe(false); + expect(declaredAs, `${key} @ line ${hit.line}`).toContain(hit.type); + } + } + }); + it('`hideFields` is still READ, and reads as the mirror declares it', () => { const hits = measurement().reads.filter((r) => r.key === 'hideFields'); // Liveness: a negative guard alone is satisfied by deleting the read. diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.unresolvedIdentityFailClosed-9054.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.unresolvedIdentityFailClosed-9054.test.tsx index cf8008b17a..9542bb2521 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.unresolvedIdentityFailClosed-9054.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.unresolvedIdentityFailClosed-9054.test.tsx @@ -41,9 +41,12 @@ * * ## The instrument, stated rather than smuggled * - * `enforceFieldSecurity` / `redactFields` are read off the block's schema with - * `(schema as any)`. They are the ONLY switch that makes the fold run at all; - * using them here is not a claim that they are a declared authoring surface. + * `enforceFieldSecurity` / `redactFields` are the ONLY switch that makes the fold + * run at all. When this file was written the block read them through + * `(schema as any)` and the contract declared neither; `@objectstack/spec` 17.5.0 + * declares both on `record:details`, and objectui#8649 aligned the mirror and + * removed the cast. Their authoring standing comes from that declaration — this + * file uses them as the switch, and is not evidence either way. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; diff --git a/packages/plugin-detail/src/renderers/__tests__/record-highlights.fieldSecurity-8649.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-highlights.fieldSecurity-8649.test.tsx new file mode 100644 index 0000000000..c766b98bfc --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-highlights.fieldSecurity-8649.test.tsx @@ -0,0 +1,148 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#8649 — `record:highlights` folds its chips through `redactFields` + * and `enforceFieldSecurity`, and that behaviour is what the declaration + * describes. + * + * ## Why this file exists + * + * `@objectstack/spec` 17.5.0 declares the field-security triple on + * `record:highlights`, and objectui#8649 declared it on the mirror, removed the + * `(schema as any)` casts the renderer read it through, and published it as the + * block's registry `inputs`. The emitted JavaScript did not move, so the + * behaviour an author gets is the behaviour this renderer always had — but no + * test drove the two FOLD keys on this block before: the capability gate + * (`requiredPermissions`) is pinned for all three record blocks by + * `record-blocks.requiredPermissions-gate.test.tsx`, and the fold keys only on + * `record:details` (`record-details.unresolvedIdentityFailClosed-9054.test.tsx`) + * and `record:related_list` (`RecordRelatedListRenderer.redactedDerivation-9053.test.tsx`). + * This file closes that gap for the block, so "the declaration changed no + * behaviour" rests on a pin here rather than on an argument. + * + * It is also the member pin for `record:highlights.redactFields` in the + * console's registry-inputs guard: the published input is `array` of `string`, + * and the rows below fix what a member IS to the renderer — a field NAME, + * matched exactly, never a label or a case variant. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { RecordContextProvider } from '@object-ui/react'; +import { PermissionProvider } from '@object-ui/permissions'; +import type { ObjectPermissionConfig, RoleDefinition } from '@object-ui/types'; +import { RecordHighlightsRenderer } from '../record-highlights'; + +const objectSchema = { + name: 'employee', + fields: { + owner: { type: 'text', label: 'Owner' }, + status: { type: 'text', label: 'Status' }, + salary: { type: 'text', label: 'Salary' }, + }, +}; + +/** Distinct VALUES, so a chip is read off the screen by what it paints. */ +const RECORD = { id: 'E1', owner: 'owner-value-A', status: 'status-value-B', salary: 'salary-value-C' }; + +const FIELDS = ['owner', 'status', 'salary']; + +const roles: RoleDefinition[] = [{ name: 'restricted', label: 'Restricted' }]; + +/** `read` on `employee`, and `read` on every field EXCEPT the ones named. */ +function permsDenying(...deniedFields: string[]): ObjectPermissionConfig[] { + return [ + { + object: 'employee', + roles: { + restricted: { + actions: ['read'], + fieldPermissions: deniedFields.map((field) => ({ field, read: false })), + }, + }, + }, + ]; +} + +function renderStrip(schema: Record, wrap?: (n: React.ReactNode) => React.ReactElement) { + const node = ( + + + + ); + return render(wrap ? wrap(node) : node); +} + +const denySalary = (n: React.ReactNode) => ( + + {n} + +); + +/** Which of the three chips painted their value. */ +const painted = () => + FIELDS.filter((f) => screen.queryByText(RECORD[f as keyof typeof RECORD] as string) !== null); + +afterEach(() => { + cleanup(); +}); + +describe('record:highlights — `redactFields` (objectui#8649)', () => { + it('CONTROL — with neither fold key, every authored chip paints its value', () => { + renderStrip({}); + expect(painted()).toEqual(['owner', 'status', 'salary']); + }); + + it('a member names a field, and exactly that chip is not rendered', () => { + renderStrip({ redactFields: ['salary'] }); + expect(painted()).toEqual(['owner', 'status']); + }); + + it('CONTROL — a member naming a field the strip does not show removes nothing', () => { + renderStrip({ redactFields: ['some_other_field'] }); + expect(painted()).toEqual(['owner', 'status', 'salary']); + }); + + it('members are matched as exact field NAMES — a label or a case variant removes nothing', () => { + // The published member kind is `string` and the contract's is + // `z.array(z.string())` of field NAMES; the renderer compares with + // `includes`, so `Salary` (the label) and `SALARY` are different names. + renderStrip({ redactFields: ['Salary', 'SALARY'] }); + expect(painted()).toEqual(['owner', 'status', 'salary']); + }); + + it('every member counts — two names remove two chips', () => { + renderStrip({ redactFields: ['owner', 'salary'] }); + expect(painted()).toEqual(['status']); + }); +}); + +describe('record:highlights — `enforceFieldSecurity` (objectui#8649)', () => { + it('a field the permission set denies leaves no chip', () => { + renderStrip({ enforceFieldSecurity: true }, denySalary); + expect(painted()).toEqual(['owner', 'status']); + }); + + it('CONTROL — the same denial with the key absent paints every chip: the key is the switch', () => { + renderStrip({}, denySalary); + expect(painted()).toEqual(['owner', 'status', 'salary']); + }); + + it('CONTROL — only `true` switches it on; the string spelling the contract refuses does not', () => { + // The read is `=== true`, which the un-cast declaration (`boolean`) now + // states at the type level too. + renderStrip({ enforceFieldSecurity: 'true' }, denySalary); + expect(painted()).toEqual(['owner', 'status', 'salary']); + }); + + it('the two keys compose: a denied field and a redacted one both leave', () => { + renderStrip({ enforceFieldSecurity: true, redactFields: ['owner'] }, denySalary); + expect(painted()).toEqual(['status']); + }); +}); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-related-list.propsRefusal-9963.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-related-list.propsRefusal-9963.test.tsx index 3effd634df..8c239eaee2 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-related-list.propsRefusal-9963.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-related-list.propsRefusal-9963.test.tsx @@ -35,14 +35,16 @@ * * ## What is refused now that was admitted before — measured, not implied * - * The three keys the renderer reads THROUGH a cast (`requiredPermissions`, - * `enforceFieldSecurity`, `redactFields`) are declared by no block the UI - * contract maps; objectui#8649 routed them to the producer and ruled - * "declare" off the table (`detailRendererUndeclaredKeys-8649.test.ts` ledgers - * them). Their READS are untouched — each goes through its own cast. What the - * narrowing refuses is a TYPED call site writing one of them into a `schema` - * literal, which is the contract's own answer for these keys; the legs below - * pin that as a reading. + * When this card landed, the three keys the renderer read THROUGH a cast + * (`requiredPermissions`, `enforceFieldSecurity`, `redactFields`) were declared + * by no block the UI contract mapped, and the narrowing refused a TYPED call + * site writing one of them into a `schema` literal — the contract's own answer + * then. That answer moved: `@objectstack/spec` 17.5.0 declares all three on + * `record:related_list`, and objectui#8649 declared them on the mirror and + * removed the casts. They are now ordinary mirror members, reached through the + * same `Omit` as every other, so the legs below that pinned the refusal now pin + * the acceptance — and a misspelling of any of them is refused like any other + * misspelled key. * * ## Which instrument settles what * @@ -190,29 +192,54 @@ export const _everyMirrorMember: Schema = { showViewAll: true, actions: ['new'], add: { picker: { object: 'contact' }, linkField: 'contact_id', label: 'Add' }, + enforceFieldSecurity: true, + redactFields: ['salary'], + requiredPermissions: ['crm.manage'], aria: { ariaLabel: 'Contacts' }, }; -/* What the narrowing refuses at a TYPED call site: the three cast-read keys objectui#8649 routed to the producer. */ +/* + * The field-security triple at a TYPED call site. These three legs pinned the + * REFUSAL while the contract declared the keys on no block mapped onto this tag; + * `@objectstack/spec` 17.5.0 declares them on `record:related_list` and + * objectui#8649 aligned the mirror, so each is now accepted with the contract's + * type — and a wrong-typed value or a misspelling is refused, which the three + * `@ts-expect-error` rows below keep as readings. + */ -export const _requiredPermissionsRefused: Schema = { +export const _requiredPermissionsAccepted: Schema = { relationshipField: 'account_id', - // @ts-expect-error objectui#9963 — `requiredPermissions` is declared by no block the contract maps onto this tag (objectui#8649); its read is a cast, its typed write is refused. requiredPermissions: ['crm.manage'], }; -export const _enforceFieldSecurityRefused: Schema = { +export const _enforceFieldSecurityAccepted: Schema = { relationshipField: 'account_id', - // @ts-expect-error objectui#9963 — same standing as above (objectui#8649 ROUTED_KEYS). enforceFieldSecurity: true, }; -export const _redactFieldsRefused: Schema = { +export const _redactFieldsAccepted: Schema = { relationshipField: 'account_id', - // @ts-expect-error objectui#9963 — same standing as above (objectui#8649 ROUTED_KEYS). redactFields: ['salary'], }; +export const _requiredPermissionsWrongTypeRefused: Schema = { + relationshipField: 'account_id', + // @ts-expect-error objectui#8649 — the contract declares `z.array(z.string())`; a bare string is refused. + requiredPermissions: 'crm.manage', +}; + +export const _enforceFieldSecurityWrongTypeRefused: Schema = { + relationshipField: 'account_id', + // @ts-expect-error objectui#8649 — the contract declares `z.boolean()`; the string spelling is refused. + enforceFieldSecurity: 'true', +}; + +export const _redactFieldsMisspelledRefused: Schema = { + relationshipField: 'account_id', + // @ts-expect-error objectui#8649 — a misspelling of a declared member is refused like any other undeclared key. + redactField: ['salary'], +}; + /* ── Runtime legs: the two retained loosenesses are READ ─────────────────── */ const h = vi.hoisted(() => ({ captured: null as any })); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-related-list.relationshipValueFieldUncast-9475.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-related-list.relationshipValueFieldUncast-9475.test.tsx index 2f5745fd70..e19ff97860 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-related-list.relationshipValueFieldUncast-9475.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-related-list.relationshipValueFieldUncast-9475.test.tsx @@ -163,26 +163,14 @@ const declaredKeys = (): string[] => Object.keys(RecordRelatedListProps.shape).s * is a declaration that disagrees with itself somewhere: write the measurement * that makes it load-bearing, or move the declaration instead. * - * ⚠️ THREE ENTRIES SINCE THE 17.5.0 BUMP, and they are exactly that - * disagreement, ruled rather than measured away. `@objectstack/spec` 17.5.0 - * declares the field-security triple on `record:related_list`, while the - * mirror (`RecordRelatedListComponentProps`) does not and the renderer reads - * the three through a cast. objectui#11111 decision 3 = B (record 5902351047) - * booked them to objectui#8649 — which "moves the declaration", as the - * sentence above demands — instead of declaring them in the bump. Capped at - * exactly these three by `the ledger holds exactly the objectui#11111 triple`; - * objectui#8649's landing strikes all three, and the last one leaving restores - * the empty ledger. Expiry: 2026-10-30, or when objectui#8649 lands, whichever - * is first (stated, not clock-enforced: this ledger has no date field). + * EMPTY AGAIN since objectui#8649. The 17.5.0 bump left three entries here — + * the field-security triple, contract-declared on `record:related_list` while + * the mirror lacked it and the renderer read it through a cast — booked to + * objectui#8649 by objectui#11111 decision 3 = B (record 5902351047). + * objectui#8649 moved the declaration, as the sentence above demands, and + * removed the three casts, so the ledger is carve-out-free again. */ -const LOAD_BEARING_CASTS: Record = { - enforceFieldSecurity: - 'contract-declared since @objectstack/spec 17.5.0, absent from the mirror, read through a cast. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047). Expires 2026-10-30, or when objectui#8649 lands.', - redactFields: - 'contract-declared since @objectstack/spec 17.5.0, absent from the mirror, read through a cast; travels with `enforceFieldSecurity`. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047). Expires 2026-10-30, or when objectui#8649 lands.', - requiredPermissions: - 'contract-declared since @objectstack/spec 17.5.0, absent from the mirror, read through a cast. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047). Expires 2026-10-30, or when objectui#8649 lands.', -}; +const LOAD_BEARING_CASTS: Record = {}; describe('objectui#9475 — the guard population is derived and the matcher discriminates', () => { it('reads the contract’s own props schema, non-empty and calibrated both ways', () => { @@ -245,20 +233,15 @@ describe('objectui#9475 — the declaration reaches the read', () => { }); // THE CAP (objectui#11111 decision 3 = B, record 5902351047). The ledger was - // empty from objectui#9964 to the 17.5.0 bump; it now holds exactly the - // three keys booked to objectui#8649, pinned by name so a fourth is red, and - // every entry must cite its owner and expiry. objectui#8649's landing takes - // this back to `[]`, the carve-out-free state. - it('the ledger holds exactly the objectui#11111 triple, each booked to objectui#8649 with its expiry', () => { - expect(Object.keys(LOAD_BEARING_CASTS).sort()).toEqual([ - 'enforceFieldSecurity', - 'redactFields', - 'requiredPermissions', - ]); - for (const [key, why] of Object.entries(LOAD_BEARING_CASTS)) { - expect(declaredKeys(), `${key} is booked but the contract no longer declares it`).toContain(key); - expect(why, `${key} names no owner card`).toContain('objectui#8649'); - expect(why, `${key} carries no expiry`).toContain('Expires 2026-10-30'); + // empty from objectui#9964 to the 17.5.0 bump, held exactly the three keys + // booked to objectui#8649 until that card landed, and is back to `[]` — the + // carve-out-free state. Pinned empty so a new entry is a reviewed diff here. + it('the ledger is empty again — objectui#8649 struck the objectui#11111 triple', () => { + expect(Object.keys(LOAD_BEARING_CASTS)).toEqual([]); + // …and the three keys it struck are in the guarded population, so the + // derived guard above now covers them with no carve-out. + for (const key of ['enforceFieldSecurity', 'redactFields', 'requiredPermissions']) { + expect(declaredKeys(), `${key} left the contract's population`).toContain(key); } }); From a7523e0557fad441fa527137e092e5ac6fa38fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:23:11 +0000 Subject: [PATCH 3/6] chore(changeset): declare the field-security triple widening on types and plugin-detail (objectui#8649) Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- ...8649-record-block-field-security-triple.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .changeset/8649-record-block-field-security-triple.md diff --git a/.changeset/8649-record-block-field-security-triple.md b/.changeset/8649-record-block-field-security-triple.md new file mode 100644 index 0000000000..97670e9f74 --- /dev/null +++ b/.changeset/8649-record-block-field-security-triple.md @@ -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. From bf0385366703d6b958d5e080369aeacbf3ecc50d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:30:45 +0000 Subject: [PATCH 4/6] test(plugin-detail): pin each renderer binding's triple per block (objectui#8649) The nested three-block form compared unequal under the identity-trick Equal while each block compared equal alone; split per block, with the reason kept beside the pins. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .../detailRendererUndeclaredKeys-8649.test.ts | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts index 180b5b1037..3fc432ad16 100644 --- a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts +++ b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts @@ -302,27 +302,43 @@ export type _TripleIsTheContractShapeOnEveryBlock = Expect< * The renderers' own `schema` bindings carry the members — the type every read * in the three files goes through. ⚠️ A MEMBERSHIP question: a cast at the read * site leaves these green, which is why the source-text legs below exist. + * + * One pin per block, deliberately. The three written as ONE nested tuple + * compare unequal under this `Equal` even though each block compares equal on + * its own (measured while writing them: the nested form reddened, each split + * form was green) — a limit of the identity trick on types derived through an + * intersection, not a difference in the members. */ -export type _RendererBindingsCarryTheTriple = Expect< +export type _DetailsBindingCarriesTheTriple = Expect< Equal< [ - [ - NonNullable['enforceFieldSecurity'], - NonNullable['redactFields'], - NonNullable['requiredPermissions'], - ], - [ - NonNullable['enforceFieldSecurity'], - NonNullable['redactFields'], - NonNullable['requiredPermissions'], - ], - [ - NonNullable['enforceFieldSecurity'], - NonNullable['redactFields'], - NonNullable['requiredPermissions'], - ], + NonNullable['enforceFieldSecurity'], + NonNullable['redactFields'], + NonNullable['requiredPermissions'], ], - [TripleShape, TripleShape, TripleShape] + TripleShape + > +>; + +export type _HighlightsBindingCarriesTheTriple = Expect< + Equal< + [ + NonNullable['enforceFieldSecurity'], + NonNullable['redactFields'], + NonNullable['requiredPermissions'], + ], + TripleShape + > +>; + +export type _RelatedListBindingCarriesTheTriple = Expect< + Equal< + [ + NonNullable['enforceFieldSecurity'], + NonNullable['redactFields'], + NonNullable['requiredPermissions'], + ], + TripleShape > >; From 731b1de1d3e76c32882f8fb6f6f46d56bf9e5a83 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:04:10 +0000 Subject: [PATCH 5/6] docs(plugin-detail): the requiredPermissions gate draws a notice, not a hidden block (objectui#8649) The docblocks above the block-level ADR-0066 capability gate in record-details.tsx, record-highlights.tsx and record-related-list.tsx said an unheld capability "hides the whole block / strip / section". The renderers draw an insufficient-permissions notice (role="status") in the content's place, which is what the contract's requiredPermissions describe on these blocks says. Comment text only: the emitted JavaScript of all three files is byte-identical before and after. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- packages/plugin-detail/src/renderers/record-details.tsx | 6 +++++- .../plugin-detail/src/renderers/record-highlights.tsx | 6 +++++- .../plugin-detail/src/renderers/record-related-list.tsx | 8 +++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index b84cab65d6..7ea30c916f 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -214,7 +214,11 @@ export const RecordDetailsRenderer: React.FC = ({ * the one meaning the word carries on `action`, `app`, `field` and * `bulkAction` — so it is read through the permission context's capability * path (`hasCapabilities` over the reported `systemPermissions`). An unheld - * or unrecognised capability hides the whole block. + * or unrecognised capability withholds the block's content, and an + * insufficient-permissions notice (`role="status"`) renders in its place: + * the block is not hidden. That is what the contract's describe on + * `RecordDetailsProps.requiredPermissions` says ("an insufficient-permissions + * notice takes its place"). * * ⛔ NOT `perms.can(objectName, name)`. That call's second argument is the * closed object-action enum, and the stock `/me/permissions` provider maps diff --git a/packages/plugin-detail/src/renderers/record-highlights.tsx b/packages/plugin-detail/src/renderers/record-highlights.tsx index 09665b3254..8c82018af3 100644 --- a/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -62,7 +62,11 @@ export const RecordHighlightsRenderer: React.FC = * the one meaning the word carries on `action`, `app`, `field` and * `bulkAction` — so it is read through the permission context's capability * path (`hasCapabilities` over the reported `systemPermissions`). An unheld - * or unrecognised capability hides the whole strip. + * or unrecognised capability withholds the strip's content, and an + * insufficient-permissions notice (`role="status"`) renders in its place: + * the block is not hidden. That is what the contract's describe on + * `RecordHighlightsProps.requiredPermissions` says ("an + * insufficient-permissions notice takes its place"). * * ⛔ NOT `perms.can(objectName, name)`. That call's second argument is the * closed object-action enum, and the stock `/me/permissions` provider maps diff --git a/packages/plugin-detail/src/renderers/record-related-list.tsx b/packages/plugin-detail/src/renderers/record-related-list.tsx index 0125516ccc..16a7230a90 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -253,7 +253,13 @@ const RecordRelatedListBody: React.FC = ({ * the one meaning the word carries on `action`, `app`, `field` and * `bulkAction` — so it is read through the permission context's capability * path (`hasCapabilities` over the reported `systemPermissions`). An unheld - * or unrecognised capability hides the whole section. + * or unrecognised capability withholds the section's content, and an + * insufficient-permissions notice (`role="status"`) renders in its place: + * the block is not hidden. That is what the contract's describe on + * `RecordRelatedListProps.requiredPermissions` says ("an + * insufficient-permissions notice takes its place"). The automatic + * child-object read gate above is a different gate and does hide the + * section: it returns `null`. * * ⛔ NOT `perms.can(objectName, name)`. That call's second argument is the * closed object-action enum, and the stock `/me/permissions` provider maps From 2b17f990bd8d0a3d77ccf485806ee015d64910d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:04:10 +0000 Subject: [PATCH 6/6] chore(changeset): append dated corrections to two pending changesets (objectui#8649) .changeset/8649-detail-renderer-undeclared-keys.md: its paragraph saying the field-security triple is deliberately NOT declared is superseded now that the three record blocks declare it. .changeset/10155-record-blocks-capability-gate.md: the capability gate does not hide the block; an insufficient-permissions notice takes the content's place. Both notes are appended at the end. No existing line and no frontmatter is edited (the standing rule for pending changesets: "Allow the appended note (Recommended)"). Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .changeset/10155-record-blocks-capability-gate.md | 11 +++++++++++ .changeset/8649-detail-renderer-undeclared-keys.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/.changeset/10155-record-blocks-capability-gate.md b/.changeset/10155-record-blocks-capability-gate.md index 0324a15d0e..7d899a92cb 100644 --- a/.changeset/10155-record-blocks-capability-gate.md +++ b/.changeset/10155-record-blocks-capability-gate.md @@ -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. diff --git a/.changeset/8649-detail-renderer-undeclared-keys.md b/.changeset/8649-detail-renderer-undeclared-keys.md index d7d8532d5a..342ecf67eb 100644 --- a/.changeset/8649-detail-renderer-undeclared-keys.md +++ b/.changeset/8649-detail-renderer-undeclared-keys.md @@ -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.