Repository navigation
feat(types,plugin-detail): declare the field-security triple on record:details / record:highlights / record:related_list and read it un-cast (objectui#8649) - #11184
Conversation
…hree 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 <noreply@anthropic.com>
…d 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 <noreply@anthropic.com>
… and plugin-detail (objectui#8649) Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude <noreply@anthropic.com>
…jectui#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 <noreply@anthropic.com>
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Director seat (objectstack#12708, summon #30 续 2), on the standing ask 「契约复审」 and the claim's own line "the PR waits as a draft for the director seat's contract review" (5904866451). Inputs: card objectui#8649 (body and its sixteen comments, the unlock scan 5903931531, the claim 5904866451 and the dev report 5906917849), PR objectui#11184 (body, the eighteen-file list, the net diff against ① Derived judgmentsAccept-set change: a widening, and exactly the contract's. Nine members are published where the mirror published none:
② Semver level
③ Boundary flags
Check-runs on the head, read 2026-09-30T08:16Z — 43 runs: none Implemented-by: VERDICT: PASS |
… 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 <noreply@anthropic.com>
…(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 <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Director seat (objectstack#12708, summon #30 续 2). The head moved past this seat's PASS 5907136265 ( ① Derived judgmentsEvery judgment of record 5907136265 (the nine members are the contract's, the nine registry descriptions are the spec's The delta, sentence by sentence:
② Semver levelUnchanged: ③ Boundary flags
Check-runs on the head, read 2026-09-30T09:23Z — none Implemented-by: VERDICT: PASS |
|
New head after the PASS: a text-only round 2, so a new record is owed. From the The director's record The delta
|
…jectui#11168) PR #11184 (objectui#8649) landed as 6479086 and struck its nine field-security rows from the objectui#11111 ledger; slice 1 of objectui#11168 struck its own. The one conflict, in apps/console/src/__tests__/registry-inputs-spec-parity.test.ts, is resolved by re-deriving every figure from the merged ledger, counting its entries per ledger and per owner: - OBJECTUI_11111_LEDGER_CAPS: unjudgedBlocks 4, offSpecInputs 0, unpublishedKeys 5, refusedArms 2, memberPins 4. - Owner-count pin: objectui#11168 13, objectui#8652 2, objectui#8649 0. objectui#8649 stays in OBJECTUI_11111_OWNERS at 0, the convention its own landing used for an owner with nothing left. - The UNPUBLISHED_EXEMPTIONS booking docblock takes main's wording as landed. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude <noreply@anthropic.com>
…r block path honours (objectui#11168 slice 1) (objectstack-ai#11185) Refs objectstack-ai#11168 Clause-②: yes (narrowing) — breaking for an author who wrote the retired `action:group.name`, an object at `action:group.actions` or `action:menu.actions`, or `md` at `action:group.size`: each is now refused by the manifest, the intrinsics and the page validator, as the spec already refuses it. The widening beside it is the spec keys the four blocks now publish as inputs. objectui's fixed group ships the break as `minor`, stated in the changeset. The PR waits as a draft for the director seat's contract review. Slice 1 of objectui#11168: the four `action:*` blocks. Every guard row this card owns on these blocks was measured through the real `SchemaRenderer`, the renderer's own forward and the real `ActionRunner`, against the installed `@objectstack/spec` 17.5.0 rows, and decided under objectui#11111 decision 3 = B (record `5902351047`): declare what the block path honours, refuse or retire what it does not. The PR stays a draft for the director seat's contract review. ## What moved - **Declared** (published as `inputs`): on `action:button` and `action:icon`, `visible`, `disabled`, `params`, `description`, `openIn`, `method`, `bodyExtra`, `bodyShape`, `operation`, `patch`, `confirmText`, `successMessage`, `errorMessage`, `refreshAfter`, `locations`, `toast`, `resultDialog`, `onSuccess`, `objectName`, plus `recordIdField` on `action:button`; on `action:group`, `location` and `visible`; on `action:menu`, `size` and `visible`. - **Retired**: `action:group.name`. - **Narrowed** (refused arms): `action:group.actions` and `action:menu.actions` from the `object` kind to a list of objects (`type: 'array'`, `of: 'object'`); `action:group.size` from `sm`/`md`/`lg` to `default`/`sm`/`lg`/`icon`. - **Member pins**: `action:group.actions`, `action:menu.actions`, and the ten array/object-armed keys each leaf block now publishes. - **Held unpublished**, still booked to this card with the measurement below: `endpoint` on `action:button` and `action:icon`, `undoable` on `action:button`. The renderers keep forwarding both, so nothing changes at runtime. - `public-block-binding-reach.test.tsx` derives its population from public blocks that publish `objectName`. The two action leaves now do, and on them the key names the object an action is dispatched against, not a data binding. Both are listed and ledgered in `NO_DATA_REACH` with that reason, which still asserts that the block fetches nothing under the name. - One changeset (`@object-ui/components`: minor) and one paragraph in `content/docs/guide/layout.md`. ## Per-key verdicts Read sites are cited by symbol and quoted expression. "Pin" names the row in `packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx` (leaf blocks) or `action-group-menu-inputs-11168.test.tsx` (containers). Every pin row carries a control in which the key is absent or holds the other value. ### `action:button` and `action:icon` (one row per key, both blocks unless noted) | Key | Read site on the block path | Verdict | Evidence | |---|---|---|---| | `visible` | `ActionButtonRenderer` / `ActionIconRenderer`: `useCondition(toPredicateInput(schema.visible), recordData …)` then `hasDeclaredVisibilityGate(schema.visible) && !isVisible`; and the `SchemaRenderer` node gate `evaluateVisibilityPredicate(newSchema.visible, 'visible')` | declared, `boolean`/`string`/`object` | holds/fails pair for a boolean, a bare CEL string and the envelope | | `disabled` | `useCondition(toPredicateInput(schema.disabled), recordData)` feeding `disabled={hostDisabled \|\| (hasDeclaredVisibilityGate(schema.disabled) ? … )}` | declared, `boolean`/`string`/`object` | holds/fails pair per arm, control on screen in both rows | | `params` | forward `Array.isArray(schema.params) ? { actionParams: schema.params … }`; `ActionRunner.execute` hands the list to `this.paramCollectionHandler(paramDefs, action)` | declared, `array` (the list to collect; static values stay on `properties.params`, and a node-level object is ignored under objectui#10289 ruling A) | the collection handler gets the list whole and in order; a collected value reaches the executor under its member `name` | | `description` | forward `description: (schema as any).description`; the console `paramCollectionHandler` reads `action?.description`; on `action:icon` also the tooltip `schema.label \|\| schema.description` | declared, `string` | the collection handler's action carries it; control `undefined` | | `openIn` | `executeUrl`: `const openIn = source.openIn ?? action.openIn` | declared, enum `self`/`new-tab` | `new-tab` gives `newTab: true`; control navigates in place | | `endpoint` | `executeAPI`: `action.api \|\| action.endpoint \|\| action.target`; the console `apiHandler` reads `const target = action.target \|\| action.name` and never `endpoint` | **held** | probe: on a runner with no `api` handler the request goes to the endpoint; the census below finds no console read | | `method` | `executeAPI`: `method = action.method \|\| 'POST'`; console `(action.method \|\| 'POST').toUpperCase()` | declared, `string` | request verb `PUT`; control `POST` | | `bodyExtra` | `buildApiRequestBody`: `const bodyExtra = asRecord(action.bodyExtra)`; console `Object.assign(body, …)` | declared, `object` | merged last over a static value of the same name; control without it | | `bodyShape` | `buildApiRequestBody`: `const shape = action.bodyShape`, then `{ [wrap]: base ?? {}, ...bodyExtra }` | declared, `enum` (`flat`) or `object` | `{ wrap: 'data' }` nests; `flat` and absent send the same flat body | | `operation` | `isUpdateOperationAction`: `action.operation === 'update'`, then `executeUpdateOperation` | declared, enum `update` | `patch` reaches the `script` dispatch; control without `operation` merges nothing | | `patch` | `executeUpdateOperation`: `const params = { ...patch, ...collected }` | declared, `object` | merged under supplied values; a supplied value of the same name wins | | `confirmText` | `ActionRunner.execute`: `if (action.confirmText)` then the confirm handler | declared, `string` | asked first, a refusal stops the action; control asks nothing | | `successMessage` | `handlePostExecution`: `dyn \|\| action.successMessage \|\| …` | declared, `string` | success toast text; control is the runner's default | | `errorMessage` | `handlePostExecution`: `action.errorMessage \|\| result.error` | declared, `string` | replaces the raw error; control shows the raw error | | `refreshAfter` | `handlePostExecution`: `if (action.refreshAfter && result.success) result.reload = true` | declared, `boolean` | the provider's result carries `reload: true`; control falsy | | `undoable` (button) | `executeUpdateOperation`: `if (action.undoable && rowRecord && writtenFields.length > 0)`; console runtime `action.undoable && obj && recId && rowRecord`; `RecordDetailView` `apiHandler` `action.undoable && isThisRecord && pageRecord` | **held** | probe: the def the block forwards gets `undo: null`; the same def plus a host `_rowRecord` stash gets an undo | | `recordIdField` (button) | `resolveServerActionRecordId`: `recordId = selected[0]?.[recordIdField]` | declared, `string` | with one selected row the POST carries its `code`; control sends `id` | | `locations` | `resolveServerActionRecordId` and the console `flowHandler`: `isRecordScopedAction(action)` | declared, `array` of `string` | a record-scoped list refuses to run with no record in scope (error toast, no request); control `list_toolbar` runs | | `toast` | `handlePostExecution`: `const showToast = action.toast ?? { … }` and `action.toast?.duration` | declared, `object` | `showOnSuccess: false`, `showOnError: false` and `duration` each asserted on the toast handler | | `resultDialog` | `handlePostExecution`: `this.resultDialogHandler(action.resultDialog!, result.data, action)` | declared, `object` | the dialog handler gets the spec whole with the data, and no success toast | | `onSuccess` | `handlePostExecution`: `readOnSuccessNavigation(action.onSuccess)`, then `navigateOnSuccess` | declared, `object` | `navigate` interpolated from the result, `openIn: newTab` opens a tab; control navigates nowhere | | `objectName` | `createServerActionHandler`: `action.objectName \|\| resolveObject?.(action, context) \|\| 'global'`; console handlers `action.objectName \|\| objApiName` | declared, `string` | the route becomes `/api/v1/actions/contact/…`; control uses the page object | `undoable` and `recordIdField` are not on `action:icon`'s spec row and its renderer does not forward them; the delivery probe shows `undefined` for both there, which is the probe's own lit zero. ### `action:group` | Key | Read site | Verdict | Evidence | |---|---|---|---| | `location` | `ActionGroupRenderer`: `declaredActions.filter(a => actionRendersAt(a, schema.location))` | declared, enum (`ACTION_LOCATIONS`) | only the matching member renders; control renders both | | `visible` | `useCondition(toPredicateInput(schema.visible), recordData)` and `if (schema.visible && !isVisible) return null`; the `SchemaRenderer` node gate | declared, `boolean`/`string`/`object` | holds/fails pair per arm | | `name` | none: `schema.name` is never read; inline mode only spreads leftover props onto the wrapping div element | **retired** | spec refuses it by name (`unrecognized_keys` naming `name`); the same members render identically with and without it, in both display modes | | `actions` (arm) | `const declaredActions: UIActionSchema[] = schema.actions \|\| []`, then `.filter` and `.map` | **narrowed**: `object` to `array` of `object` | spec refuses the object kind (`invalid_type` at `actions`) and accepts a list; member pin | | `size` (arm) | dropdown `schema.size === 'md' ? 'default' : (schema.size \|\| 'default')`; inline `action.size === 'md' ? 'default' : (action.size \|\| size \|\| 'sm')` | **narrowed** to `default`/`sm`/`lg`/`icon` | spec refuses `md` (`invalid_value` at `size`); sizes asserted on inline members and the trigger | ### `action:menu` | Key | Read site | Verdict | Evidence | |---|---|---|---| | `size` | `ActionMenuRenderer`: `const size = schema.size \|\| 'icon'` | declared, enum `default`/`sm`/`lg`/`icon` | trigger `lg`; control icon-sized | | `visible` | `useCondition(toPredicateInput(schema.visible), recordData, { throwOnError: true, … })` and `if (schema.visible && !isVisible) return null` | declared, `boolean`/`string`/`object` | holds/fails pair per arm | | `actions` (arm) | `const actions: UIActionSchema[] = schema.actions \|\| []`, then `.map` | **narrowed**: `object` to `array` of `object` | spec refuses the object kind and accepts a list; member pin | ## Ledger arithmetic (`registry-inputs-spec-parity.test.ts`) | `OBJECTUI_11111_LEDGER_CAPS` | before | struck here | after | |---|---|---|---| | `unjudgedBlocks` | 4 | 0 | 4 (the four lazy blocks, later slices) | | `offSpecInputs` | 1 | 1 (`action:group.name`) | 0 | | `unpublishedKeys` | 57 | 43 (button 20, icon 19, group 2, menu 2) | 14 (this card 3, objectui#8652 2, objectui#8649 9) | | `refusedArms` | 5 | 3 (the three `action:*` arms) | 2 (`element:definition-list`, `object-form`) | | `memberPins` | 6 | 2 (`action:group.actions`, `action:menu.actions`) | 4 (the `element:*` bookings) | - Owner-count pin: objectui#11168 62 to 13 (49 struck); objectui#8652 2 and objectui#8649 9 unchanged, and none of their rows is touched. - `MEMBER_PIN_EXEMPTION_CEILING` 7 to 5; `OFF_SPEC_EXEMPTIONS` is pinned empty again. - `MEMBER_PINS` gains 24 entries: the ten leaf keys on each of `action:button` and `action:icon` (one claim per key, written once and spelled per block) and `visible` / `actions` on each container. - objectui#8649 moves the same `unpublishedKeys` cap line and owner-count pin in parallel. Whichever PR lands second merges `main` and re-derives those two lines. ## Held keys Each still carries its booked entry (reason text now states the measurement), so the guard keeps it visible. - **`endpoint`** (`action:button`, `action:icon`). The block forwards it and the runner's built-in `api` executor reads it (probe: the request goes to the endpoint). The console registers its own `api` handler, and that handler reads `target`, never `endpoint`. On the console, an `api` action carrying `endpoint` therefore never calls it. The spec's own `ActionSchema` alias table already maps `endpoint` to `target`. Publishing it would advertise a key the product drops. - **`undoable`** (`action:button`). The block forwards it. The runner's `operation: update` path, which is the spec's declared form of an update action, offers Undo only with a `_rowRecord` stash that hosts write and this block never does (probe: `undo: null`; the same def plus a stash gives an undo). The console runtime reads it under the same guard. Only the record page's own `api` handler honours it, and only for a logical target on that page. The options and a recommendation are in the report on the card. ## Instruments - **Delivery probe** (one-time, not committed): each forwarded key on a node through the real `SchemaRenderer` inside a real `ActionProvider`, with the button clicked and the value read where it arrives. All 20 forwarded `action:button` keys arrived intact, and all 18 of `action:icon`'s. The probe's lit zero is icon's `undoable` and `recordIdField`, which arrive as `undefined`. `visible` and `disabled` are consumed by the renderer itself and are measured by the pin rows instead. - **Checker census**: `runtimeReadKeys` / `forwardedKeys` from `scripts/check-action-forward-parity.mjs` over its four runtime consumers, with `target` and `label` as lit controls and a nonsense key as the zero. `locations` read `-` on all four consumers. That is the census's documented blind spot (a read behind a helper): `isRecordScopedAction` in `serverActionHandler.ts` reads it, and the pin measures it directly. - **Pins**: `action-button-icon-inputs-11168.test.tsx` (47 tests) and `action-group-menu-inputs-11168.test.tsx` (20 tests). Every row mounts through `SchemaRenderer`. Where the runner hands a key to a host handler, the handler is the real `createServerActionHandler` from `@object-ui/core` (for `objectName`, `locations`, `recordIdField`), or a recording double. Refusal rows assert the spec issue's `code` and path, not a bare `success: false`. ## Tests (commands from the repo root; verdicts read from vitest's own summary) - Components suite, 341 files in three batches under the verify lock: `packages/components/src/renderers/ ui/ notifications/` 151 passed (1466 tests, 17 skipped); `src/__tests__` halves 94 passed plus 1 skipped, and 95 passed. All exit 0. - Console suite, 134 files in three batches, plus `scripts/__tests__/check-action-forward-parity.test.ts` and `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`: 45, 47 and 44 files passed. All exit 0. - At the final head `05d9cfdfa`: `registry-inputs-spec-parity`, `public-block-binding-reach`, everything under `packages/components/src/renderers/action/`, `action-group.test.tsx` and the forward-parity script tests: 33 files, 775 tests passed. - Type check: `@object-ui/components` `type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) and `@object-ui/console` `type-check` both exit 0, after building each dependency closure. `--listFilesOnly` shows both pin files in the components test project, and the guard and reach test in the console project. - Lint, declared narrowing: `eslint --format json` on the eight touched TypeScript files gives 0 errors. The warnings are pre-existing `no-explicit-any` and `only-export-components` on untouched lines; the diff adds no `any`. `eslint.config.js` sets no `parserOptions.project` or `projectService`, so linting is not type-aware and the diff cannot move a verdict on an untouched file. Whole-package `eslint .` is CI's. ## Ablations (each predicted before running; `ablation-replace` with anchor and blob proof, restore proven blob == HEAD with `git diff HEAD` empty) - **A. Unpublish `confirmText` on `action:button`.** Predicted: the published-input pin and the guard's reverse direction go red; the behaviour row stays green. Observed: exactly 2 red, `every declared key is a published input …` ("action:button does not publish confirmText") and `action:button publishes every top-level key its spec props schema declares`; 275 green. Anchor x1 to x0; blob `fce9b14a0a47` to `97074e6ff9e1`. - **B. Re-admit `md` on `action:group.size`.** Predicted: the size pin and the guard's arm direction go red. Observed: exactly 2 red, `` `size` publishes exactly the sizes the spec accepts … `` and `action:group declares no arm the spec refuses outright`; 248 green. Blob `26b31ec9a251` to `01c4266dad38`. - **C. Drop `confirmText` from `action:button`'s forward.** Predicted: only `action:button`'s behaviour row goes red, and `check:action-forward-parity` goes red. Observed: 1 of 47 red (`` `confirmText` is asked first … `` for `action:button`), and the gate reports "does not forward 1 key the runtime reads: `confirmText`". Blob `fce9b14a0a47` to `09421448fe69`. ## Gates (at `05d9cfdfa`, exit codes captured before any pipe) - Exit 0: `check-changeset-presence`, `check-changeset-no-major`, `check-changeset-fixed`, `check-changeset-overwrite`, `check-changeset-claims` (report-only), `check-pending-changeset-literals`, `check:control-bytes`, `check:new-line-citations` (0 new), `check:spec-symbols`, `check:action-forward-parity`, `check:doc-snippets` (679 of 679 blocks, against a built closure), `check:doc-types`, `check:prompt-keys`, `check:handler-key-reads`, `check:installed-pin-claims`, `check:test-path-roots`, `check:phantom-deps`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:i18n-keys`, `check:element-data-source-declaration`, `check:esm-specifiers`, `check:side-effects-array`, `check:self-import`, `check:component-surface-parity` (report-only; no `inputs` finding on the four blocks). - `check-governed-queue-guard --test` on the ten changed paths: NOT GOVERNED. - Control-byte self-scan of every changed file: no hit. - NOT MEASURED: `check-spec-range-floors` (reason: it needs a whole-workspace build, and the diff touches no `package.json`, which is the gate's only input). `check:sdui-registration-pins` (reason: it needs a console bundle, and the diff touches no `sideEffects` array). Repo-wide `pnpm type-check`, `pnpm test` and `pnpm lint` are CI's. ## Acceptance notes (observations, not filed) - `scripts/check-action-forward-parity.mjs` says in its header that `locations` "is read only off the AUTHORED action … never off the forwarded def". `isRecordScopedAction` in `packages/core/src/actions/serverActionHandler.ts` and the console `flowHandler` read it off the forwarded def, and `RUNTIME_CONSUMERS` does not list that file. The gate is one-directional and every surface forwards the key, so this is dormant. Carrier: none. - The same gate's `JUSTIFIED` entries for `undoable` on `action:icon` / `action:group` / `action:menu` reason only from the console runtime's `rowRecord` guard. `RecordDetailView`'s `apiHandler` reads `action.undoable && isThisRecord && pageRecord` with no stash. Unmeasured beyond that reading. Carrier: none. - The `action-forward-parity.test.tsx` header calls `recordIdField` inert on these renderers. This slice measures it honoured on the `script` path with a single selection. Carrier: none. - `packages/types/src/zod/public-blocks.zod.ts` still says the `action:*` blocks have "no spec row yet"; 17.5.0 carries them. Carrier: objectui#10872. - `@object-ui/types` exports an `ActionGroup` interface whose required `name` is the key this slice retires. Nothing imports it. Carrier: none. ## Base merge `main` at `39e625de2` is merged into this branch as `80503f5bf`, a merge commit with no rebase and no force-push, after PR objectstack-ai#11184 (objectui#8649) landed as `647908686`. The one conflict was in `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`, in three hunks. Every figure was re-derived by counting the merged ledger's booked entries per ledger and per owner. The same census first reproduced each parent's own pinned figures as a control. The ledger table above is the slice's arithmetic at `05d9cfdfa`; these are the figures at the merged head. - `OBJECTUI_11111_LEDGER_CAPS`: `unjudgedBlocks` 4, `offSpecInputs` 0, `unpublishedKeys` 5 (objectui#11168 3, objectui#8652 2, objectui#8649 0), `refusedArms` 2, `memberPins` 4. `MEMBER_PIN_EXEMPTION_CEILING` stays 5, because main did not touch that ledger. - Owner-count pin: objectui#11168 13, objectui#8652 2, objectui#8649 0. objectui#8649 stays in `OBJECTUI_11111_OWNERS` at 0. That is the convention its own landing used, and the file's header leaves removing the scaffold to the last owner card to land. - The `UNPUBLISHED_EXEMPTIONS` booking docblock takes main's wording as landed. Both PRs' struck rows stay struck. The net diff against `main` is still the same ten files. | Run at `80503f5bf` | Exit | Verdict | |---|---|---| | guard, `public-block-binding-reach`, `packages/components/src/renderers/action/` (both `-11168` pins), `action-group.test.tsx`, forward-parity script tests | 0 | 33 files, 775 tests passed | | `@object-ui/components` `type-check` | 0 | `tsc --noEmit && tsc -p tsconfig.test.json` | | `@object-ui/console` `type-check` | 0 | `tsc --noEmit && tsc -b tsconfig.node.json --force` | | `check:action-forward-parity` | 0 | 5 surfaces, 41 runtime-read keys, 22 justified omissions, 0 known gaps | | `check-changeset-presence`, `-no-major`, `-fixed`, `-overwrite`, `-claims` (report-only) | 0 each | 1 changeset declared for 8 source files of 2 released packages | | `check:control-bytes` | 0 | OK | | `check:new-line-citations` | 0 | 0 new | Ablation: `unpublishedKeys` set back to 14, this branch's pre-merge figure. Predicted and observed: exactly 1 red of the guard's 230 tests, `the objectui#11111 ledger is capped at exactly the entries it lists` ("expected 5 to be 14"). Anchor x1 to x0, blob `b26db645d11a` to `f60e7157b89e`; restored to blob == HEAD with `git diff HEAD` empty. The head moved, so a new contract-review record is owed before the queue. Session `https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm` --- _Generated by [Claude Code](https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #8649
Clause-②: yes — declaring
enforceFieldSecurity,redactFieldsandrequiredPermissionson the three record blocks' published props types and registry inputs widens the accepted authoring surface to exactly what@objectstack/spec17.5.0 accepts on those blocks, and no further. This PR waits as a draft for the director seat's contract review.What this does
The card's last open item: the nine reads of the field-security triple in
@object-ui/plugin-detail, onrecord:details,record:highlightsandrecord:related_list. PR objectui#9469 (Part of) took the other three reads and routed these nine to the platform as objectstack#18159. That card closedcompletedwith PR objectstack-ai/objectstack#19913 (ruling A), andmainnow installs@objectstack/spec17.5.0, so the routing has expired into "align the mirror":@object-ui/types:RecordDetailsComponentProps,RecordHighlightsComponentPropsandRecordRelatedListComponentPropseach gainenforceFieldSecurity?: boolean,redactFields?: string[]andrequiredPermissions?: string[].record-details.tsx,record-highlights.tsxandrecord-related-list.tsxlose their(schema as any)cast. For example,(schema as any).enforceFieldSecurity === truebecomesschema.enforceFieldSecurity === true. Therecord-related-list.tsxprops docblock that said "Do not reopen it to admit a key the renderer reads through a cast" is rewritten, because the contract now declares the three keys on that block.packages/plugin-detail/src/index.tsx): each of the three blocks publishes the three keys. The types are the contract's (boolean, orarrayofstring), and each description is that block's own.describe()text from the installed spec, verbatim.registry-inputs-spec-parity.test.ts): this card's nineOWED TO objectui#8649entries are struck.OBJECTUI_11111_LEDGER_CAPS.unpublishedKeysgoes from 57 to 48, the owner-count pin'objectui#8649'goes from 9 to 0, and the six new array inputs get member pins. No other owner's entry was touched.minoron@object-ui/typesand@object-ui/plugin-detail, stating the widening.⛔ No runtime permission, gating or masking behaviour changes (triage floor
5619608221). The proof is under "Honest types, same behaviour".Premises re-measured on
mainbefore any edit (Partition 2)1. What 17.5.0 declares. The installed
node_modules/@objectstack/spec/package.jsonreads17.5.0. Two instruments were run over the contract's own block-tag mapComponentPropsMap, and each was self-tested on known schemas first:unrecognized_keys..shape.user:profile. Its props schema isz.never(), so it refuses every key withoutunrecognized_keys, and instrument A read that as "not refused by name" for every key, including the nonsense control. That block is now excluded from A's population and named in the output, and the two instruments agree on every key.Declared shapes, identical on the three blocks:
enforceFieldSecurityisz.boolean(),redactFieldsisz.array(z.string())andrequiredPermissionsisz.array(z.string()). All three are optional with no default. TheenforceFieldSecurityandredactFieldsdescribes are block-specific. TherequiredPermissionsdescribe is the one ruling5798783314shares word for word withrecord:quick_actions.⇒ Premise holds: all nine keys are declared, on exactly those three blocks.
2. The read sites. The checker (membership, via
getPropertyOfTypeon each binding) and an expression probe (getTypeAtLocationon each read) were run over the three renderers. The probe refuses to report while anyTS2307is present: its first run in this fresh worktree had 48 of them and was discarded, the dependency closure was built, and the probe was re-run.requiredPermissionsandredactFieldsare each read twice in their ternary) are cast, typedany, and are not members of the binding.boolean | undefined,string[] | undefined, orstring[]inside theArray.isArraynarrowing.=== trueagainstz.boolean(), andArray.isArray(...) ? ... : []into astring[]againstz.array(z.string()). Both match, so there is no behaviour question.3. Honest types, same behaviour. Before and after, each renderer was transpiled with
removeComments:The
@object-ui/typeschange is interface-only.4. Registry inputs. The three blocks'
inputslive inpackages/plugin-detail/src/index.tsx.recordDetailsInputs.spec-parity.test.tsand its two siblings asserted only the forward half: no input that the spec does not accept. The reverse half lives in the console guard.5. The ledger. See "What this does".
6. objectui#10200's premise does not hold on 17.5.0. That card was written against 17.4.0 and says
record:details"honours three security keys the pinned spec REFUSES". On the installed 17.5.0:RecordDetailsPropsaccepts all three keys, by both instruments above, with value-level parses in the new pins.requiredPermissions) was withdrawn by ruling A on objectui#10281.Tests
New and updated pins:
detailRendererUndeclaredKeys-8649.test.tsis this card's own pin. It now carries:Equaltype pins for each block: the mirror members equal the contract'sComponentPropsInputmembers, spelled out, and each renderer binding carries them.record-details.hideFieldsUncast-9965.test.tsandrecord-related-list.relationshipValueFieldUncast-9475.test.tsxnow have empty cast ledgers, as both files said this card's landing would do.anyreads, which it used as calibration. It now measures a virtual control file in the same program, which pins that the checker reports a cast read asany, an un-cast read asstring[] | undefined, and an undeclared read asany.record-related-list.propsRefusal-9963.test.tsx: the three refusal rows become acceptance rows, with wrong-type and misspelling refusals kept as@ts-expect-errorcontrols.record*Inputs.spec-parity.test.tsfiles check each key. The key is declared on the block. The published type is proved on values: an accepted value parses, and a rejected value is refused at that key (redactFields: [1]is refused atredactFields.0, the member). The description equals the installed describe.RecordRelatedListRenderer.columnMembers.test.tsx: its CONTROL leg asserted the probe keys were unpublished. It now asserts that an input exists if and only ifRecordRelatedListPropsdeclares the key. Per triage carry5627847529, that file is still not cited as a declaration.record-highlights.fieldSecurity-8649.test.tsxis new. No test droveredactFieldsorenforceFieldSecurityonrecord:highlights. It pins which chips paint their value, with a control for each row.Behaviour unchanged, cited:
record-blocks.requiredPermissions-gate.test.tsx, under a realMePermissionsProvider.record-details.unresolvedIdentityFailClosed-9054.test.tsx(details);RecordRelatedListRenderer.redactedDerivation-9053.test.tsxandRecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx(related list);Runs at head
bf0385366(exit codes captured before any pipe):vitest run packages/types/: exit 0,Test Files 283 passed (283),Tests 6496 passed (6496).vitest run packages/plugin-detail/: exit 0,Test Files 225 passed | 1 skipped (226),Tests 2235 passed | 8 skipped (2243). The skipped file issummaryChip.dateOnlyZone-10183.test.tsx, which carries its own skip condition and is not touched here.Test Files 11 passed (11),Tests 452 passed (452). The files are the eightapps/consoletests that name the three blocks,RecordDetailView.pageHeaderTitleFls-10499.test.tsx,public-tier.test.tsandcheck-handler-key-read-sites.test.ts.@object-ui/types:pnpm --filter @object-ui/types run type-check(includingtsconfig.test.json) exits 0, andpnpm --filter @object-ui/plugin-detail run type-checkexits 0.@object-ui/typesexits 0 with 0 errors, and@object-ui/plugin-detailexits 0 with 0 errors.eslint --format jsonon the console guard file reports 1 file, 0 errors and 0 warnings.Ablations: direction predicted first, each mutation shown on disk, restored by blob-hash equality with
git diff HEADemptyAll three legs went through
ablation-replace.mjs(a literal anchor that must hit, a trap-armed restore on an absolute path) while holding the verify lock.schema.enforceFieldSecurity === truebecame(schema as any).enforceFieldSecurity === trueinrecord-highlights.tsx.enforceFieldSecurityun-cast leg goes red, and the emitted JS does not move.Tests 1 failed | 26 passed (27), and the failing case was that leg.record-highlights.tsx js IDENTICALunder the mutation, so the source-text pin is the only instrument that can see a re-cast.requiredPermissionsreads inrecord-related-list.tsxwas re-cast (? schema.requiredPermissionsbecame? (schema as any).requiredPermissions), so the liveness half still holds.Tests 2 failed | 35 passed (37). The first failure was "expected ... not to match" the cast matcher; the second wasexpected [ 'requiredPermissions' ] to deeply equal [].enforceFieldSecuritywas removed fromRecordDetailsComponentProps, then@object-ui/typeswas rebuilt, becauseplugin-detail'stscresolves it throughdist/.record-details.tsxstill compiles through its index signature, and the 9965 program guard goes red on the membership leg and the triple-type leg.dist/record-components.d.tswent from 3 to 2.tsc -p tsconfig.test.jsonexited 2 with six errors, all indetailRendererUndeclaredKeys-8649.test.ts: three TS2344 (the details mirror pin, the every-block shape pin and the details binding pin), the TS2339 pair that accompanies the first two, and TS2353 on the details fixture. There were zero errors in the renderer.Tests 2 failed | 8 passed (10)on exactly the two predicted legs.record:highlightsredactFieldsinput was replaced by a comment.Tests 4 failed | 239 passed (243). The four were those two parity legs,record:highlights publishes every top-level key its spec props schema declares, andevery member pin names a key that is still array/object-armed on a covered block.Gates run locally (verdict lines read from each gate)
Exit 0:
check-changeset-presence("1 changeset(s) added"),check-changeset-no-major,check-changeset-fixed,check-changeset-overwrite("0 modified"),check:changeset-claims(report-only, see the notes below),check:pending-changeset-literals.check:control-bytes,check:new-line-citations("0 new citation(s)"),check:spec-symbols,check:handler-key-reads,check:installed-pin-claims.check-governed-queue-guard --testover the diff reads "NOT GOVERNED", and--self-testpasses.check-type-check-coverage,check-lint-coverage.check:test-path-roots,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape.check:element-data-source-declaration,check:unreferenced-sources,check:phantom-deps,check:self-import.NOT MEASURED:
check:sdui-registration-pins: its prerequisite was not met (exit 2, "No console build to weigh"). This diff moves no registration array orsideEffectsentry.check:spec-floors -- --cross-checkandcheck:published-dist: both need a full-repo build, so they are left to CI.pnpm lintand the fullpnpm testfarm are CI's runs.Acceptance notes
requiredPermissionsinputs on the three record blocks, and therecord-related-list.tsxdocblock in carrier note5826463187. Both are done here. Itsrecord:quick_actionsdescription ask is not in this PR's scope, so objectui#10224 remains open for that ask. The seat can narrow it..changeset/8649-detail-renderer-undeclared-keys.md, from this card's first half, says the three keys are deliberately NOT declared. That was true against 17.4.0. The claim's file surface names one changeset, so that body is not edited here; this PR's changeset states the supersession instead. A prose-only correction of that body is a one-paragraph change once the surface allows it. It is raised as an open question in the report.unpublishedKeyscap and the owner-count pin, and whichever PR lands second mergesmainand re-derives them. The57in the bookings comment is now worded as "57 at the bump" and points at the cap constant, so it no longer needs re-deriving.record:quick_actions,record-reference-rail.tsx.requiredPermissionsgate say an unheld capability "hides the whole block". The renderer draws an insufficient-permissions notice, which is what the spec describe says. This is an observation only, not changed here. carrier: whoever takes objectui#10224'srecord:quick_actionsdescription ask, which has the same wording drift one block over.Round 2: text only, head
2b17f990bThis round follows the seat's ACCEPT
5907807607. It adds two commits on top ofbf0385366, with no rebase, no force-push and no rewrite of a pushed commit.731b1de1d, docs(plugin-detail). The docblocks above therequiredPermissionscapability gate inrecord-details.tsx,record-highlights.tsxandrecord-related-list.tsxsaid an unheld capability "hides the whole block / strip / section". Each now says the content is withheld and an insufficient-permissions notice (role="status") renders in its place. That is what the renderers do, and it is what the contract'srequiredPermissionsdescribe on these blocks says: "this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place". The related-list docblock also says that the automatic child-object read gate above it is a different gate, and that one does hide the section (it returnsnull). Comment text only.2b17f990b, chore(changeset). One dated correction note is appended to the end of each of two pending changesets, under the standing rule 「Allow the appended note (Recommended)」:.changeset/8649-detail-renderer-undeclared-keys.md: the paragraph that begins "Three keys are deliberately NOT declared", and the census under it, are superseded, because this PR declares the triple on the three blocks..changeset/10155-record-blocks-capability-gate.md: "hides the block" is corrected to the notice behaviour. The capability set and the fail-closed verdict stand. The note names the related list's child-object read gate as the one that does hide.Proof that the round is text only:
git diff --numstat origin/main -- FILEreads11 0for each changeset. The first 4927 bytes (8649) and the first 2269 bytes (10155) of the new files are byte-identical to the blobs onmain(cmpexit 0), so no existing line and no frontmatter moved.check-changeset-overwritelists both as modified, with the same declarations at base and now.removeComments,bf0385366against2b17f990b):The three JS hashes are the same ones round 1 measured after its change.
Runs at head
2b17f990b(exit codes captured before any pipe):vitest run packages/plugin-detail/: exit 0,Test Files 225 passed | 1 skipped (226),Tests 2235 passed | 8 skipped (2243).pnpm --filter @object-ui/plugin-detail run type-check, after building the package's dependency closure: exit 0.pnpm --filter @object-ui/plugin-detail run lint: exit 0 with 0 errors. The warning count on each of the three renderers equals round 1's.check-changeset-presence("1 changeset(s) added"),check-changeset-no-major,check-changeset-fixed.check-changeset-overwrite(report-only): "1 changeset(s) added, 2 modified, 0 deleted".check:changeset-claims(report-only): 6 pending changesets name a file this PR touches, against 7 in round 1. The one that left the list is the 8649 changeset. It left because this PR now modifies it, and the gate's went-false reading skips the changesets a change adds or modifies. That is not a verdict on its prose.check:pending-changeset-literals,check:control-bytes, andcheck:new-line-citations("0 new citation(s)").check-shell-escape-residue,check-doc-links, andcheck-governed-queue-guard --testover the five paths ("NOT GOVERNED"). Each exits 0.Round 2 acceptance notes:
*. It is left as is, because it is comment formatting outside this round's wording ask.record-blocks.requiredPermissions-gate.test.tsxstill say the gate "hides the WHOLE block" or "hides the block". Their assertions pin the notice: the refusal text is found and the block body is absent. They are left as is, because this round covers renderer comments and changesets only.Generated by Claude Code