From 008f250bfbe7063e614c6c6c501d75b9ddf9ffaa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:12:26 +0000 Subject: [PATCH 1/3] feat(spec): record:path declares the allowStageChange opt-in A record:path block may opt in to changing the record's stage by pressing a stage on the path. Off unless authored, no schema default; the spec declares the opt-in and its meaning only. The console parity gate books the key as unpublished until the path renderer reads it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxNQzFQH8nBAaoSUkx --- .../28061-record-path-allow-stage-change.md | 16 ++++++++++ packages/spec/src/ui/component.test.ts | 32 +++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 18 +++++++++++ .../registry-inputs-spec-parity.test.ts | 6 ++++ 4 files changed, 72 insertions(+) create mode 100644 .changeset/28061-record-path-allow-stage-change.md diff --git a/.changeset/28061-record-path-allow-stage-change.md b/.changeset/28061-record-path-allow-stage-change.md new file mode 100644 index 00000000000..a9f4e63f6f5 --- /dev/null +++ b/.changeset/28061-record-path-allow-stage-change.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a `record:path` block may opt in to `allowStageChange`, so a stage can be pressed to move the record there + +Clause-②: yes (widening) +Metadata-protocol: added (record:path allowStageChange) + +A `record:path` block draws the status field's stages as a progress rail, and nothing on it can be pressed: moving a deal to the next stage means opening the edit form and changing the picklist. + +- **Spec.** `RecordPathProps` (`ComponentPropsMap['record:path']`) declares an optional boolean `allowStageChange`. Off unless authored, with no schema default, so every existing path keeps its read-only look and parses exactly as before. Any value other than a boolean is refused at the key. +- **Meaning.** With the key on, each stage the reader may move the record to is a button, offered only to a reader who may edit `statusField`. A press asks for confirmation, then saves `statusField` through the same record update `record:details` uses, carrying the record's concurrency token, so the object's state machine validation and the field's edit rights decide the move like any other edit. A refused move (an illegal transition, a missing required field) is shown in place. Nothing new is judged on the server. +- **Renderer.** The console does not read the key yet; the path stays a readout until the `plugin-detail` path renderer honours it. The block's registry does not publish the input until then. + +Nothing to do for existing pages. Once the console honours it, add `allowStageChange: true` to a `record:path` node's properties to let readers change the stage from the path. diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 6b353b86f58..3ab8a12f1c3 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -2602,6 +2602,38 @@ describe('RecordPathProps stages[].terminal — declared because the path render }); }); +// #28061 — `allowStageChange` is the opt-in that turns the path's stages into +// presses requesting that status. Off unless authored, so the readout keeps its +// non-interactive look. +describe('RecordPathProps allowStageChange — opt-in, off unless authored', () => { + it('accepts the opt-in and keeps the authored value', () => { + const result = RecordPathProps.safeParse({ statusField: 'stage', allowStageChange: true }); + expect(result.success).toBe(true); + expect(result.data!.allowStageChange).toBe(true); + }); + + it('leaves an existing path unchanged: no default is written', () => { + const authored = { + statusField: 'status', + stages: [ + { value: 'todo', label: 'To Do' }, + { value: 'done', label: 'Done', terminal: 'won' as const }, + ], + }; + const result = RecordPathProps.parse(authored); + expect(result).toEqual(authored); + expect('allowStageChange' in result).toBe(false); + }); + + it('refuses a non-boolean at the key, with the code and path', () => { + const result = RecordPathProps.safeParse({ statusField: 'stage', allowStageChange: 'yes' }); + expect(result.success).toBe(false); + expect(result.error!.issues).toEqual([ + expect.objectContaining({ code: 'invalid_type', path: ['allowStageChange'] }), + ]); + }); +}); + // --------------------------------------------------------------------------- // Enhanced RecordActivityProps (Unified Timeline) // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e713b4f7e81..22f62598f22 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2082,6 +2082,24 @@ export const RecordPathProps = strictObject({ */ terminal: z.enum(['won', 'lost']).optional().describe('Mark this stage a terminus and its kind — overrides the renderer\'s value/label token heuristic'), })).optional().describe('Explicit stage definitions (if not using field metadata)'), + /** + * Opt in to changing the record's stage from the path itself. Off unless + * authored: an unset key is the read-only progress rail, so every existing + * path keeps its non-interactive look. No schema default, for the + * `RecordChatterProps` reason — a default would write `false` onto every + * parsed component and turn "the author said nothing" into "the author + * asked for it". + * + * The spec declares the opt-in and its meaning only; nothing new is judged + * on the server. With the key on, each stage the reader may move the record + * to is a button, offered only when the reader may edit `statusField`. A + * press asks for confirmation, then writes `statusField` through the same + * record update `record:details` uses, carrying the record's concurrency + * token, so the object's `state_machine` validation (ADR-0020) and the + * field's edit rights judge the move like any other edit; a refusal (an + * illegal transition, a missing required field) is shown in place. + */ + allowStageChange: z.boolean().optional().describe('Let the reader change the record\'s stage by pressing a stage on the path (default: off — the path is a read-only progress rail). Each press asks for confirmation and saves `statusField` through the ordinary record update, so the object\'s state machine validation and the field\'s edit rights decide; stages appear pressable only to readers who may edit `statusField`, and a refused move is shown in place.'), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); diff --git a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 8d80c0d7d00..327b6794723 100644 --- a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -1663,6 +1663,12 @@ const UNPUBLISHED_EXEMPTIONS: Record = { 'Unpublished by measurement (objectui#13047): read by nothing on a page `list-view` node. The node draws, queries and hands its view the same thing with and without it — the view\'s own `name` is its identity in metadata, which an embedded list has no use for.', 'list-view.sharing': 'Unpublished by measurement (objectui#13047): `sharing.type` draws a Share button with no click handler, and the sharing config is enforced nowhere in the renderer. Publishing it would advertise a dead control (objectui#13047\'s report).', + // Spec-first opt-in (objectstack#28061): the spec half landed before the + // console half, so the path renderer does not read it yet. The console card + // that makes `record-path.tsx` honour it publishes the input, and the stale + // check above then demands this entry's deletion in the same change. + 'record:path.allowStageChange': + 'Unpublished until the renderer reads it (objectstack#28061): the spec declares the opt-in that turns path stages into presses requesting that status, and `record-path.tsx` still draws a read-only rail. Publishing it now would advertise a control that does nothing.', }; /** From 467caf08b19c92201ef55f5661d0ba9672f2400d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:13:15 +0000 Subject: [PATCH 2/3] fix(spec): allowStageChange describe says absent or false is the readout Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxNQzFQH8nBAaoSUkx --- packages/spec/src/ui/component.zod.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 22f62598f22..3d31d5893d9 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2083,9 +2083,9 @@ export const RecordPathProps = strictObject({ terminal: z.enum(['won', 'lost']).optional().describe('Mark this stage a terminus and its kind — overrides the renderer\'s value/label token heuristic'), })).optional().describe('Explicit stage definitions (if not using field metadata)'), /** - * Opt in to changing the record's stage from the path itself. Off unless - * authored: an unset key is the read-only progress rail, so every existing - * path keeps its non-interactive look. No schema default, for the + * Opt in to changing the record's stage from the path itself. Absent or + * `false` is the read-only progress rail, as today, so every existing path + * keeps its non-interactive look. No schema default, for the * `RecordChatterProps` reason — a default would write `false` onto every * parsed component and turn "the author said nothing" into "the author * asked for it". @@ -2099,7 +2099,7 @@ export const RecordPathProps = strictObject({ * field's edit rights judge the move like any other edit; a refusal (an * illegal transition, a missing required field) is shown in place. */ - allowStageChange: z.boolean().optional().describe('Let the reader change the record\'s stage by pressing a stage on the path (default: off — the path is a read-only progress rail). Each press asks for confirmation and saves `statusField` through the ordinary record update, so the object\'s state machine validation and the field\'s edit rights decide; stages appear pressable only to readers who may edit `statusField`, and a refused move is shown in place.'), + allowStageChange: z.boolean().optional().describe('Let the reader change the record\'s stage by pressing a stage on the path. Absent or `false`: the path is a read-only progress rail, as today. Each press asks for confirmation and saves `statusField` through the ordinary record update, so the object\'s state machine validation and the field\'s edit rights decide; stages appear pressable only to readers who may edit `statusField`, and a refused move is shown in place.'), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); From d9b26f0210d05ccca78b41e0b2ba28c6e6279b79 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:17:44 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the authorable surface and reference docs for allowStageChange Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxNQzFQH8nBAaoSUkx --- content/docs/references/ui/component.mdx | 1 + packages/spec/authorable-surface/ui.json | 1 + 2 files changed, 2 insertions(+) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 1a0516dda67..c42b2d95408 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1882,6 +1882,7 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **statusField** | `string` | ✅ | Field name representing the current status/stage | | **stages** | `{ value: string; label: string \| Record; terminal?: Enum<'won' \| 'lost'> }[]` | optional | Explicit stage definitions (if not using field metadata) | +| **allowStageChange** | `boolean` | optional | Let the reader change the record's stage by pressing a stage on the path. Absent or `false`: the path is a read-only progress rail, as today. Each press asks for confirmation and saves `statusField` through the ordinary record update, so the object's state machine validation and the field's edit rights decide; stages appear pressable only to readers who may edit `statusField`, and a refused move is shown in place. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordPathProps.stages[number]` diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 8653b7dcf6a..83004ff8ec6 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1196,6 +1196,7 @@ "ui/RecordLineItemsProps:sort", "ui/RecordLineItemsProps:title", "ui/RecordLineItemsProps:totalField", + "ui/RecordPathProps:allowStageChange", "ui/RecordPathProps:aria", "ui/RecordPathProps:stages", "ui/RecordPathProps:statusField",