From d070954de5efaf6676c08d41c3384b8a434d6cc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:12:34 +0000 Subject: [PATCH 1/4] docs(skills): an approval's decision outputs are enforced at the decision in the automation skill The decisionOutputs teaching in objectstack-automation's state-machines-and-approvals reference said a typed declaration renders a record picker. It now says what the runtime enforces: type and multiple are judged on approve and reject before any write, a picker kind resolves through the expression approver's resolvers, multiple: true takes a list of distinct values, anything else is 400 VALIDATION_FAILED naming outputs.KEY, and the Console sends a list for a multiple text output. The token ceiling is paid in place by cutting three restatements whose homes stay in the same file. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01J8VVAkWdcYL2ftVcWZ2scY --- .../state-machines-and-approvals.md | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/skills/objectstack-automation/references/state-machines-and-approvals.md b/skills/objectstack-automation/references/state-machines-and-approvals.md index 1539df1501c..d5c8a4e964b 100644 --- a/skills/objectstack-automation/references/state-machines-and-approvals.md +++ b/skills/objectstack-automation/references/state-machines-and-approvals.md @@ -219,6 +219,15 @@ reject) — enforced before any write, with no elevation bypass, so the run cannot resume past the node with the key a later `expression` approver reads still missing. +`type` and `multiple` are enforced there too, on approve and reject alike, +before any write — they are not widget hints. A picker kind must resolve +through the `expression` approver's resolvers in the request's organization: +`user`, `department` and `team` by record id, `position` by a held position's +machine name. `multiple: true` takes a list of distinct values (every kind, +text and bare keys included); without it, one value. Anything else is +`400 VALIDATION_FAILED` naming `outputs.KEY`. The Console sends a list for a +`multiple: true` text output. The `lead_review` node below is the example. + ### Reading the lock A page over a record under approval must not offer an edit the server then @@ -245,7 +254,7 @@ an id that does not exist gets. | `manager` | The submitter's manager (`sys_user.manager_id`) | | `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user | | `queue` | Members of ownership queue `value` at node entry; an empty queue adds nobody | -| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph | +| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. The optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph | ### Dynamic approvers (`type: 'expression'`) @@ -307,12 +316,9 @@ export const DynamicApprovalFlow = defineFlow({ config: { objectName: 'expense', triggerType: 'record-after-update', condition: "status == 'submitted'" }, }, { - // Node A declares what a decision may hand to the flow. The TYPED - // declaration renders a multi-select sys_user picker in the decision - // dialog; the lead approves with outputs: + // Node A declares what a decision may hand to the flow; the lead + // approves with outputs: // POST …/approve { outputs: { next_reviewers: ['u2', 'u3'] } } - // `required: true` is enforced by the runtime on APPROVE (never on - // reject) — node B below has nobody to route to without it. id: 'lead_review', type: 'approval', label: 'Lead Review', config: { approvers: [{ type: 'org_membership_level', value: 'owner' }], @@ -320,7 +326,6 @@ export const DynamicApprovalFlow = defineFlow({ }, }, { - // Node B resolves them at entry from the lead's decision outputs. id: 'co_sign', type: 'approval', label: 'Co-sign', config: { approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }], @@ -362,7 +367,7 @@ Object-hook `ctx` is a different vocabulary — see **objectstack-data** | `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) | | `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through), `fallback` (request opens on the sibling `fallbackApprovers` instead — same shape as `approvers`, required by this policy and refused under any other; a fallback that itself resolves to nobody degrades to `admin_rescue`). Declare it explicitly on any node with an `expression` approver (linted) | | `excludeSubmitter` | `true` drops the run's submitter (`submitter_id`) from the resolved slate, whatever approver type produced them — segregation of duties; a slate it empties takes the `onEmptyApprovers` route, never a silent pass. `onEmptyApprovers: 'auto_approve'` is **refused** beside it (it would approve exactly the request this exists to stop): use `admin_rescue`, `fail` or `fallback`. Default `false` | -| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `.` variables; undeclared keys reject the decision; `decision`/`requestId` reserved | +| `decisionOutputs` | Decision outputs a decision may carry: bare keys (free text) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }`, enforced at the decision — see *Recording a decision* | | `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `timeoutHours` is **calendar (wall-clock) hours** — nights, weekends and holidays count; the platform ships no business-hours calendar. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) | | `maxRevisions` | Send-backs per run before auto-reject (ADR-0044) — see *Send-back for revision* above | From 1912f519df8d8c2a0c87a87c80cebc095e81e50b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:14:02 +0000 Subject: [PATCH 2/4] docs(skills): name the decision as where decision outputs are enforced Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01J8VVAkWdcYL2ftVcWZ2scY --- .../references/state-machines-and-approvals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/objectstack-automation/references/state-machines-and-approvals.md b/skills/objectstack-automation/references/state-machines-and-approvals.md index d5c8a4e964b..cf063907f54 100644 --- a/skills/objectstack-automation/references/state-machines-and-approvals.md +++ b/skills/objectstack-automation/references/state-machines-and-approvals.md @@ -219,7 +219,7 @@ reject) — enforced before any write, with no elevation bypass, so the run cannot resume past the node with the key a later `expression` approver reads still missing. -`type` and `multiple` are enforced there too, on approve and reject alike, +`type` and `multiple` are enforced at the decision too, on approve and reject alike, before any write — they are not widget hints. A picker kind must resolve through the `expression` approver's resolvers in the request's organization: `user`, `department` and `team` by record id, `position` by a held position's From 597efdf414ce9b30c6773d48a526326e72b15e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 14:28:44 +0000 Subject: [PATCH 3/4] docs(skills): scope decision-output resolution per kind and add the skills changeset Answers the contract review of 1912f519: the organization scope now applies only to the kinds the approvals service scopes (a user id from any account resolves), the paragraph keeps that a typed entry renders the matching picker, a blank value is left to required, and the change ships an '@objectstack/skills' patch changeset since skills/** publishes. Paid by three example comments that restate the Node Config table rows. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01J8VVAkWdcYL2ftVcWZ2scY --- ...omation-skill-decision-outputs-enforced.md | 10 ++++++++++ .../state-machines-and-approvals.md | 20 ++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 .changeset/29512-automation-skill-decision-outputs-enforced.md diff --git a/.changeset/29512-automation-skill-decision-outputs-enforced.md b/.changeset/29512-automation-skill-decision-outputs-enforced.md new file mode 100644 index 00000000000..4b9a142aba8 --- /dev/null +++ b/.changeset/29512-automation-skill-decision-outputs-enforced.md @@ -0,0 +1,10 @@ +--- +'@objectstack/skills': patch +--- + +docs(skills): the automation skill teaches that an approval's decision outputs are enforced at the decision + +Clause-②: no +Metadata-protocol: none + +The `decisionOutputs` teaching in objectstack-automation's state-machines-and-approvals reference said only that a typed entry renders a record picker in the decision dialog. It now also says what the approvals service enforces, on approve and reject alike and before any write: a picker value must resolve through the `expression` approver's resolvers (`user` by any account's record id, `department` by record id in the request's organization, `team` by record id not owned by another organization, `position` by the machine name of a position someone in that organization holds); `multiple: true` takes a list of distinct values for every kind, bare keys included, and without it one value; any other non-blank value is refused with `400 VALIDATION_FAILED` naming `outputs.KEY`. The Console sends a list for a `multiple: true` text output. No schema, key or behaviour changes. diff --git a/skills/objectstack-automation/references/state-machines-and-approvals.md b/skills/objectstack-automation/references/state-machines-and-approvals.md index cf063907f54..f50b872cbc0 100644 --- a/skills/objectstack-automation/references/state-machines-and-approvals.md +++ b/skills/objectstack-automation/references/state-machines-and-approvals.md @@ -96,9 +96,9 @@ is one diagram a reviewer (or AI) can read end-to-end. label: 'Sales Manager Review', config: { approvers: [{ type: 'position', value: 'sales_manager' }], - behavior: 'first_response', // or 'unanimous' / 'quorum' / 'per_group' - lockRecord: true, // lock the record while pending - approvalStatusField: 'approval_status', // mirror pending|approved|rejected|recalled onto the row + behavior: 'first_response', + lockRecord: true, + approvalStatusField: 'approval_status', }, }, // Decision routing lives on the OUT-EDGES, not in node config: the engine @@ -219,12 +219,14 @@ reject) — enforced before any write, with no elevation bypass, so the run cannot resume past the node with the key a later `expression` approver reads still missing. -`type` and `multiple` are enforced at the decision too, on approve and reject alike, -before any write — they are not widget hints. A picker kind must resolve -through the `expression` approver's resolvers in the request's organization: -`user`, `department` and `team` by record id, `position` by a held position's -machine name. `multiple: true` takes a list of distinct values (every kind, -text and bare keys included); without it, one value. Anything else is +A typed entry renders the matching picker in the decision dialog, and `type` +and `multiple` are also enforced at the decision, on approve and reject alike, +before any write. A picker value must resolve through the `expression` +approver's resolvers: `user` by any account's record id, `department` by record +id in the request's organization, `team` by record id (not another +organization's), `position` by the machine name of a position someone in that +organization holds. `multiple: true` takes a list of distinct values (every +kind, bare keys included); without it, one value. Any other non-blank value is `400 VALIDATION_FAILED` naming `outputs.KEY`. The Console sends a list for a `multiple: true` text output. The `lead_review` node below is the example. From a6110351346b9786510d8504aba02429b094641b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 14:29:55 +0000 Subject: [PATCH 4/4] docs(skills): name the decision-outputs changeset after the card's subject and give it the Before/Now shape Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01J8VVAkWdcYL2ftVcWZ2scY --- .changeset/29512-approval-decision-outputs-skill.md | 11 +++++++++++ ...9512-automation-skill-decision-outputs-enforced.md | 10 ---------- 2 files changed, 11 insertions(+), 10 deletions(-) create mode 100644 .changeset/29512-approval-decision-outputs-skill.md delete mode 100644 .changeset/29512-automation-skill-decision-outputs-enforced.md diff --git a/.changeset/29512-approval-decision-outputs-skill.md b/.changeset/29512-approval-decision-outputs-skill.md new file mode 100644 index 00000000000..351005244e4 --- /dev/null +++ b/.changeset/29512-approval-decision-outputs-skill.md @@ -0,0 +1,11 @@ +--- +'@objectstack/skills': patch +--- + +docs(skills): the automation skill teaches that an approval's decision outputs are enforced at the decision + +Clause-②: no +Metadata-protocol: none + +- **Before.** `skills/objectstack-automation/references/state-machines-and-approvals.md` said only that a typed `decisionOutputs` entry renders the matching record picker in the decision dialog (`multiple` collects an id array). An agent writing an approval flow from it could read `type` and `multiple` as widget hints and not know that a value the next step cannot use is refused. +- **Now.** The *Recording a decision* section says what the approvals service enforces, on approve and reject alike and before any write: a picker value must resolve through the `expression` approver's resolvers (`user` by any account's record id, `department` by record id in the request's organization, `team` by record id not owned by another organization, `position` by the machine name of a position someone in that organization holds); `multiple: true` takes a list of distinct values for every kind, bare keys included, and without it one value; any other non-blank value is refused with `400 VALIDATION_FAILED` naming `outputs.KEY`. The Console sends a list for a `multiple: true` text output. The existing `lead_review` node is the example. The file stays within its token ceiling (5724 of 5730). diff --git a/.changeset/29512-automation-skill-decision-outputs-enforced.md b/.changeset/29512-automation-skill-decision-outputs-enforced.md deleted file mode 100644 index 4b9a142aba8..00000000000 --- a/.changeset/29512-automation-skill-decision-outputs-enforced.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): the automation skill teaches that an approval's decision outputs are enforced at the decision - -Clause-②: no -Metadata-protocol: none - -The `decisionOutputs` teaching in objectstack-automation's state-machines-and-approvals reference said only that a typed entry renders a record picker in the decision dialog. It now also says what the approvals service enforces, on approve and reject alike and before any write: a picker value must resolve through the `expression` approver's resolvers (`user` by any account's record id, `department` by record id in the request's organization, `team` by record id not owned by another organization, `position` by the machine name of a position someone in that organization holds); `multiple: true` takes a list of distinct values for every kind, bare keys included, and without it one value; any other non-blank value is refused with `400 VALIDATION_FAILED` naming `outputs.KEY`. The Console sends a list for a `multiple: true` text output. No schema, key or behaviour changes.