Skip to content

Commit 8595b71

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15325-ratify-hook-body-subpath-export
2 parents 8f1cf05 + 900334a commit 8595b71

22 files changed

Lines changed: 2519 additions & 137 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
feat(service-automation): an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149)
7+
8+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: the `assignments` map and every value form it accepted still parse. The only newly refused shape is a malformed CEL value envelope, a spelling declared one day earlier in #15113 and offered by no authoring surface before it, so `objectstack migrate meta` has nothing to rewrite and this changeset carries no rewrite instructions for a consumer to follow. -->
9+
10+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
11+
(the lockstep convention; the level also follows the 2026-09-04 bump ruling —
12+
this adds `AutomationEngine.evaluateValueEnvelope` to a published surface, and an
13+
additive widening is at least `minor`). No ADR-0087 conversion: no authorable key
14+
is renamed or retired, and the shape this refuses was never a shape any surface
15+
offered.
16+
17+
The maintainer's 2026-09-02 ruling on #14149 made an assignment value able to be
18+
a CEL **value** expression, so the declared stdlib (`joinNonEmpty`, `map`, `size`
19+
…) is finally reachable from metadata — until now CEL was only ever asked for a
20+
boolean. The spec half landed the contract (PR #15113); this is the half that
21+
makes it do something.
22+
23+
```yaml
24+
# before: written into the variable verbatim, and rendered by `notify` as
25+
# {"dialect":"cel","source":"joinNonEmpty(...)"}
26+
# now: evaluated — digest is "Renewal due\nInvoice overdue"
27+
assignments:
28+
digest: { dialect: cel, source: 'joinNonEmpty(rows.map(r, r.subject), "\n")' }
29+
```
30+
31+
- **Evaluated at run time.** The built-in `assignment` executor evaluates a
32+
`value`-role envelope with the expression engine and assigns the result, in the
33+
same CEL scope a flow predicate is evaluated in (one shared scope builder, so a
34+
predicate and a value expression cannot disagree about what `rows` means). A
35+
plain string keeps today's `{token}` interpolation, and every other literal is
36+
still assigned as data.
37+
- **Refused at three doors.** A malformed envelope now stops the flow registering
38+
(`registerFlow` throws, the severity a malformed predicate gets) and surfaces as
39+
a located `error` finding naming the node and the author's own variable —
40+
`config.assignments.digest` — both at `objectstack validate` and at the runtime
41+
publish gate a Studio / REST / MCP flow write goes through
42+
(`validateStackExpressions` is registered `CLI_AND_RUNTIME`, `runtimeTypes:
43+
['flow']`). Malformed is a composition, not a fixed list: whatever
44+
`AssignmentValueSchema` refuses in the envelope's shape — among them a missing,
45+
empty or non-string `source`, a dialect other than `cel`, a non-object `meta` —
46+
and then CEL that does not parse. All three doors derive that set from the same
47+
two published validators, so none refuses a shape the executor would have run,
48+
and a registered flow never faults for a shape those validators judge malformed.
49+
Two shapes sit outside what either validator can judge — an `ast`-only envelope
50+
and a whitespace-only `source` (it passes `min(1)` and reads as "not authored"
51+
to the validator, while the CEL engine parses it untrimmed) — and those fault
52+
loudly at run time rather than assigning a value. Both are pinned and tracked in
53+
#15430.
54+
- **Only the canonical map.** The ledger declares `assignment.assignments.*` and
55+
nothing else, so the two legacy shapes the executor still normalizes — the
56+
`assignments: [{ variable, value }]` array and the bare `{ <variable>: <value> }`
57+
config — keep every meaning they had, envelope-shaped values included.
58+
`AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the
59+
array form: refusing it would break flows that register today, and that refusal
60+
is a maintainer ruling rather than a lane's call (#15137 ask 3).
61+
62+
**What changes silently, and how far it reaches.** A flow that today authors an
63+
envelope-shaped object *as data* in the canonical `assignments` map now evaluates
64+
it — no error on either side, a different value. The discriminator is the spec's
65+
own `isExpressionEnvelopeShaped`: a plain object naming a **string** `dialect`,
66+
in the declared map only. Data that names no `dialect`, names a non-string one,
67+
nests the envelope one level down, or sits in either legacy shape is untouched
68+
and byte-identical. The remaining overlap — a well-formed
69+
`{ dialect: 'cel', source: … }` written as data in the canonical map — is exactly
70+
the spelling the ruling reinterprets; every near-miss the two validators can
71+
judge now refuses loudly at registration instead of changing value in silence.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/plugin-approvals": minor
4+
"@objectstack/rest": minor
5+
---
6+
7+
An approval decision that lands while its flow run strands now says so in fields, not only in prose.
8+
9+
`POST /api/v1/approvals/requests/{id}/reject` — and its sibling decision doors — could produce three coexisting outcomes from one call: the caller read HTTP 500, the request row **was** in its terminal status and had left the pending inbox, and the workflow run was stranded. A caller reading 500 has one honest inference available — "the rejection did not happen" — and it was the wrong one, so scripts and operators retried or escalated against a decision that was already durable. The only carrier of the truth was English prose in `error`, so finding the affected run meant regexing a run id out of a sentence, and nothing said whether that run could be repaired at all.
10+
11+
The 500 stays. A recorded decision whose flow never advances is still a failure and is still reported as one; the door does not become atomic and no decision is ever rolled back. What changed is that it stops discarding what the engine already said:
12+
13+
- **The `RESUME_FAILED` body gains four fields**, additively — `finalized` (always `true`: the decision stands), `decision`, `runId`, and `repairable`. Existing consumers see the same `code`, the same `error` and the same status.
14+
- **`repairable` carries the engine's own discriminator**`AutomationResult.status === 'stranded'`, the state stamped on exactly the exit that journals a repair snapshot. `false` is the answer for every other failure, including a lost run: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise.
15+
- **`serviceResume` carries `status`** through to the door. It previously read only `success` / `code` / `error`, and the stranded exit reports a `status` and no `code` at all — so the platform's own repairability signal died one line before the envelope was built.
16+
17+
`@objectstack/types` gains `strandedDecisionFailure` / `strandedDecisionDetails` and the `StrandedDecisionDetails` type — the constructor and its recogniser in one module, so the producing service and the REST door cannot drift. A `RESUME_FAILED` raised without that carrier answers exactly the body it always did; the door never synthesises the envelope.

content/docs/automation/flows.mdx

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -199,21 +199,30 @@ or missing-`required` violation (#4277). A node type that publishes no
199199
A value's **shape** selects its form — there is no mode key. A plain string is
200200
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
201201
CEL); an object that names a `dialect` is an expression envelope and must be a
202-
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
203-
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
204-
objects are assigned as literals. A later `notify` node renders the variable as
205-
any other: `message: '{digest}'`.
206-
207-
<Callout type="warn" title="The contract landed first; the executor half follows">
208-
209-
The envelope form is what `@objectstack/spec` declares — the expression
210-
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
211-
[#14149]. Until the matching `@objectstack/service-automation` change lands,
212-
the built-in `assignment` executor still writes an envelope object into the
213-
variable verbatim and `notify` renders it as JSON. For a digest body today,
214-
call a registered function from a `script` node.
215-
216-
[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149
202+
valid `cel` one — a missing, empty or non-string `source`, or a `template` /
203+
`cron` dialect, is refused at the variable's path. Numbers, booleans, arrays
204+
and plain objects are assigned as literals. A later `notify` node renders the
205+
variable as any other: `message: '{digest}'`, and it renders the **evaluated**
206+
value.
207+
208+
<Callout type="info" title="Where a malformed envelope is refused">
209+
210+
A malformed envelope never reaches run time silently: the same refusal runs at
211+
`objectstack validate` (a located finding naming the variable), at the runtime
212+
publish gate a Studio / REST / MCP flow write goes through, and at
213+
`registerFlow`, which refuses to register the flow. All three ask the same two
214+
questions in the same order — is it a valid `cel` envelope
215+
(`AssignmentValueSchema`), and does its source parse as CEL
216+
(`validateExpression`) — so a flow that registers is a flow whose envelopes
217+
those two validators accept.
218+
219+
Two shapes sit outside what either validator can judge and fault loudly at run
220+
time instead of assigning a value: an `ast`-only envelope (no `source` — the
221+
CEL engine evaluates `source`), and a whitespace-only `source`, which passes
222+
`min(1)` and reads as "not authored" to the validator while the engine parses
223+
it untrimmed. Both are tracked in [#15430].
224+
225+
[#15430]: https://github.com/objectstack-ai/objectstack/issues/15430
217226

218227
</Callout>
219228

content/docs/permissions/system-context.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ not on any flag.
6464
## How the flag is set
6565

6666
`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
67-
cannot set it (`packages/rest/src/rest-server.ts:1548`, `:1577`), and neither
67+
cannot set it (`packages/rest/src/rest-server.ts:1553`, `:1582`), and neither
6868
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
6969
written by internal callers only, as an option on the engine call:
7070

@@ -103,7 +103,7 @@ that silently does not happen.
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
104104
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
105105
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
106-
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1580` |
106+
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1585` |
107107

108108
### 2. Write pipeline and data integrity
109109

@@ -145,7 +145,7 @@ The largest single consumer — **17 of the 106 sites**.
145145
|:--|:---|:---|:---|:---|
146146
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
147147
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
148-
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3196`, `:3342`, `:3509`, `:3580`, `:3769`, `:3809` |
148+
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3248`, `:3396`, `:3564`, `:3635`, `:3824`, `:3864` |
149149
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
150150
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
151151
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4967`, `:6381`, `:6629`, `:7060`, `:7253` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4972`, `:6386`, `:6634`, `:7065`, `:7258` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) |
200200
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280``281` |
201201
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
202-
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1548`, `:1577`; `domains/actions.ts:414` |
202+
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1553`, `:1582`; `domains/actions.ts:414` |
203203

204204
---
205205

0 commit comments

Comments
 (0)