You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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"
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.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+5-5Lines changed: 5 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -64,7 +64,7 @@ not on any flag.
64
64
## How the flag is set
65
65
66
66
`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
68
68
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
69
69
written by internal callers only, as an option on the engine call:
70
70
@@ -103,7 +103,7 @@ that silently does not happen.
103
103
| 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`|
104
104
| 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`|
105
105
| 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`|
107
107
108
108
### 2. Write pipeline and data integrity
109
109
@@ -145,7 +145,7 @@ The largest single consumer — **17 of the 106 sites**.
145
145
|:--|:---|:---|:---|:---|
146
146
| 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`|
147
147
| 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`|
149
149
| 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`|
| 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`|
162
162
| 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`|
163
163
| 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`|
164
164
| 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.
199
199
| "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) |
200
200
| "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`|
201
201
| "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`|
0 commit comments