Repository navigation
Commit 9cfc575
fix(service-automation,runtime): a refused write inside a flow reaches every FLOW_FAILED door as details.innerRefusal and a top-level userMessage, message unchanged (#25147)
Fixes #24914
Clause-②: yes (widening)
Metadata-protocol: none
Step (2) of ruling B′ on #24914 (the maintainer's 「同意」, ruling record
6303788292, decision batch #379 item 5). The spec half landed in #24996
(PR #25037): `AutomationResult.userMessage`,
`AutomationResult.innerRefusal` (`FlowInnerRefusal`) and one
`FlowFailureDetailsSchema` for the four doors. This PR emits exactly
what those declare.
The console half is objectstack-ai/objectui#13285 (step 3). It stays
open; this PR does not touch objectui. The object API name inside the
unique-refusal sentence is #24951 (engine), unchanged here.
## What changes
**Before:** a refused write inside a flow reached the caller as one
string. The node's catch kept only the message, the engine prefixed the
node id, and each door served that string as the `FLOW_FAILED` message:
> Node 'create_account' failed: create_record(crm_account) failed:
Duplicate record refused on 'crm_account': …
**After:** the same 400 also carries the refusal structurally. `message`
is byte-identical to before.
```json
{ "success": false, "error": {
"code": "FLOW_FAILED", "httpStatus": 400,
"message": "Node 'create_account' failed: create_record(crm_account) failed: Duplicate record refused on 'crm_account': …",
"userMessage": "(present only when the producer marked one, e.g. an app hook)",
"details": { "summary": { "...": "..." },
"innerRefusal": { "code": "UNIQUE_VIOLATION", "message": "Duplicate record refused on 'crm_account': …" } } } }
```
### `@objectstack/service-automation`
- **New `src/inner-refusal.ts`.** `refusalCarriedBy(err)` is the one
helper the four CRUD catch arms call. It returns `{ userMessage?,
innerRefusal? }`:
- `userMessage` comes from `declaredUserMessage(err)`
(`@objectstack/types`), the one read every boundary uses.
- `innerRefusal` is set only for a refusal, meaning a throw that
declared a 4xx answer (`resolveThrownHttpError(err).declaredStatus`). A
fault carries none: that covers an undeclared throw and a declared 5xx.
- Its `code` is the one the data door's `errorFromThrown` answers for
the same throw. That is the resolver's code (`VALIDATION_FAILED`,
`PERMISSION_DENIED`, or the member an app hook's declared status names),
except that the engine's duplicate envelope takes the ruled wire
spelling. That spelling is read off the shared arm
(`structuredCodeAnswer`), never typed here.
- Nothing is read from message text.
- **`builtin/crud-nodes.ts`.** The four catch arms (`get_record`,
`create_record`, `update_record`, `delete_record`) spread
`refusalCarriedBy(err)` beside an unchanged `error`. `create_record`
keeps its in-flow `code: 'DUPLICATE_RECORD'` for `$error.code`.
- **`engine.ts`.**
- `NodeExecutionResult` gains optional `userMessage` / `innerRefusal`.
- The returned-failure arm throws the same `Node 'ID' failed: …` error,
now branded with the members. This is a non-enumerable symbol brand, the
`partial-steps.ts` / `guard-refusal.ts` idiom, so identity, message and
`instanceof` are unchanged.
- The members are spread onto the result at four exits: `execute()`'s
failure exit, the `resumeInternal` stranded exit,
`executeWithoutRetry`'s failure exit, and `retryExecution`'s exhausted
exit. The last one reports the last attempt's members, paired with its
text.
- A `loop` / `parallel` frame rethrows the same object, so the brand
survives them.
### `@objectstack/runtime`
- **`flow-dispatch-status.ts`.** `flowFailureDetails(result)` is one
builder, typed as `FlowFailureDetails`, for the three declared keys.
`flowFailureEnvelope(result)` gives the top-level `userMessage`.
- **Trigger and resume doors** (`domains/automation.ts`), **`/actions`**
(`action-execution.ts`, plus its handler in `domains/actions.ts`) and
**declared endpoint** (`endpoint-executor.ts`). Each door builds its
`FLOW_FAILED` details through the shared builder and puts `userMessage`
at the envelope's top level (`ApiErrorSchema.userMessage`).
- `FlowActionRunDetails` is now the spec's `FlowFailureDetails`, so a
fourth key fails the build.
- `FlowActionRefusal` carries `userMessage` as a field, which is the
channel `declaredUserMessage` reads.
- **The `deps.error` slot.** `DomainHandlerDeps.error` gains an optional
fourth argument, `extra: { userMessage? }`
(`domain-handler-registry.ts`), and `http-dispatcher.ts` wires it to the
existing `extra` of the envelope builder. A call without it builds the
same envelope as before.
- **MCP `resume_run`** (`domains/mcp.ts`). `resumeDoorError` passes the
slot and copies the envelope's `userMessage` onto the thrown error.
`details.innerRefusal` already travels with `details`.
## Why `Clause-②: yes (widening)`, not the claim's `no`
The claim said `no` because #24996 already declared the wire members,
and it asked me to re-declare if the built declaration closure showed a
key beyond `FlowFailureDetailsSchema`. Measured on the built
`dist/index.d.ts` of both packages (built at the head this PR cites),
two published types widen:
- `@objectstack/runtime`'s `DomainHandlerDeps.error` gains the optional
`extra` argument (the slot the ruling names);
- `@objectstack/service-automation`'s exported `NodeExecutionResult`
gains optional `userMessage` / `innerRefusal`.
Both are additive and optional. No export is added. The wire members are
the ones #24996 declared. The changeset is `patch` for both packages,
with the same two declaration lines.
## Tests
New pins, each asserting `code` + `status` on refusal cases:
- `packages/services/service-automation/src/flow-inner-refusal.test.ts`
(13 tests). A real `AutomationEngine` and the real CRUD executors run
over a data double that throws the real classes (`DuplicateRecordError`,
`ValidationError`, `PermissionDeniedError`) after accepting the call
through the engine's dispatch predicates.
- Unique refusal: `UNIQUE_VIOLATION`, while in-flow stays
`DUPLICATE_RECORD`. Validation: `VALIDATION_FAILED`. Permission:
`PERMISSION_DENIED`.
- A marked app-hook refusal: `userMessage` arrives verbatim, with
`RESOURCE_CONFLICT` for its unregistered code.
- An unmarked refusal has no `userMessage` key. A fault and a declared
5xx carry neither member.
- The other three arms each carry the members.
- The exits: stranded resume (the card's write-behind-a-screen leg), the
retry ladder, a loop frame, and a fault edge that handles the failure
(no members).
- `error` is asserted byte-for-byte everywhere.
- `packages/runtime/src/flow-failed-inner-refusal.test.ts` (10 tests).
Covers trigger, resume, `/actions` and endpoint.
- `details.innerRefusal` parses through `FlowFailureDetailsSchema`, and
the error parses through `ApiErrorSchema`.
- `userMessage` is top-level only, never a details key. `message` keeps
each door's wording.
- A door-against-door drift pin, and the unrefused control.
- `packages/runtime/src/mcp-resume-run.test.ts`: one appended test. The
MCP `resume_run` bridge carries `innerRefusal` and `userMessage` exactly
as the REST resume door does.
- `packages/verify/src/automation-flow-inner-refusal.test.ts` (9 tests).
This runs end to end: real engine and CRUD executors, the real
`HttpDispatcher` trigger and resume doors, and the real error classes.
- Each inner `code` is compared with what the data door's
`errorFromThrown` answers for the same thrown value. This is
door-against-door parity with the record save.
The existing wording pins are untouched and green, including
`create-record-duplicate-code.test.ts`,
`try-catch-returned-failure-steps.test.ts`,
`actions-flow-dispatch-status.test.ts` (its exact-equality details pin
still describes a run that met no refusal),
`automation-resume-stranded-details.test.ts`,
`automation-trigger-route-status.test.ts` and
`endpoint-flow-dispatch-status.test.ts`.
### Local readings
These are on a shared box, so wall times are contention readings.
- Final head `0a2d128cd`. The targeted re-run is green:
- service-automation: 3 files / 29 tests, plus
`try-catch-returned-failure-steps.test.ts` 17/17;
- runtime: 6 files / 108 tests (the new suite, `mcp-resume-run`, and the
four door suites);
- verify: 1 file / 9 tests.
- Full suites at `bbca8a681`:
- `@objectstack/service-automation`: 198 files / 2618 tests passed;
- `@objectstack/runtime --project local`: 433 files / 6805 passed, 19
skipped.
The difference from the final head is a changeset, the
service-automation test file above (re-run green at the final head),
`scripts/engine-double-contract.pinned.json`, and a main merge that
touches only driver-sql tests.
- `pnpm --filter … typecheck` for service-automation, runtime and
verify: exit 0. That covers `tsc --noEmit` and each package's
test-typecheck ratchet.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 80 commands at `0a2d128cd`. All 80
ran after the final commit and exited 0, with each exit code captured
before any pipe. `--ran` reconciliation: "80 derived famil(ies)
accounted for — 80 run, 0 NOT-MEASURED (a DERIVED zero …)". On an
earlier head, `check-plugin-teardown-shape --self-test` needed its
pinned fixture commit fetched into this shallow clone.
`check:engine-double-contract` asked for the new test's double to be
pinned, which is done with `--write`.
- `test:repo` and repo-wide `pnpm lint` were not run locally; both are
CI's.
### Ablations
Each ablation went through `scripts/ablation-replace.mjs`, which wraps
the mutation, proves it landed on disk, restores it and proves the
restore by HEAD blob hash. `git diff HEAD` was empty after every leg.
Every leg turned the pins red:
| Leg | Mutation | Result |
|:--|:--|:--|
| SA1 | `isRefusal` forced false | 10 of 13 red; the 3 absence controls
stay green |
| SA2 | `userMessage` read dropped | 2 red |
| SA3 | engine brand dropped | 10 red |
| SA4 | `execute()` exit spread dropped | 8 red |
| SA5 | stranded exit spread dropped | 1 red |
| SA6 | retry exhausted carry dropped | 1 red |
| SA7 | per-attempt exit spread dropped | 1 red |
| RT1 | details `innerRefusal` dropped | 10 of 32 red |
| RT2 | envelope `userMessage` dropped | 6 red |
| RT3 | `deps.error` slot dropped | 5 red |
| RT4 | MCP copy-back dropped | 1 red |
| RT5 | `/actions` handler slot dropped | 2 red |
| V1 | verify suite, through service-automation's built `dist/` (SA1's
mutation, rebuilt) | 8 of 9 red |
- **Dist check for V1.** `ablation-dist-preflight` showed the marker
present in `dist/index.js` and `dist/index.cjs`. After restoring and
rebuilding, the marker was absent and the tree was clean, and the suite
was green again at 9 of 9.
- **First passes that did not run.** The first passes of SA4, SA5, SA7,
RT3 and RT5 were refused by the tool as no-ops, because their
replacement text already occurred in the file. They did not run, so
their readings were discarded. They were re-run with unique markers; the
results in the table are from those re-runs. The first re-run of RT5
never acquired the lock (exit 99) and was run again.
## Acceptance notes
- **File surface beyond the claim, and why.**
- `http-dispatcher.ts` and `domain-handler-registry.ts` are where the
`deps.error` slot is defined and wired.
- `domains/actions.ts` is where `/actions` turns `FlowActionRefusal`
into the envelope (one argument).
- `scripts/engine-double-contract.pinned.json` records the new test's
pinned double, written by the gate's `--write`.
- `packages/verify/src/automation-flow-inner-refusal.test.ts`:
`@objectstack/verify` is the one package depending on both the engine
and the doors, so it is where the end-to-end pin lives.
- **Containers that re-word a child failure do not forward the members
yet.** These are `try_catch` with no catch region, and `subflow` on both
legs (synchronous, and the resume delegation arm). The run then carries
no member, which the spec's "present only when a refusal was met"
allows. Carrier: none.
- **Refusals a CRUD node makes itself are not carried.** These are the
stored-metadata family guards, which return a `code` before any data
call. They are not catch arms, and the ruling names the four catch arms.
Carrier: none.
- **MCP `run_action`.** Its tool error still carries no run details, as
before (#9585 scoped the details to `/actions`). The refusal's
`userMessage` rides the thrown `FlowActionRefusal` field.
`packages/mcp`'s tool-error renderer shows code, message, status and
details, but not `userMessage`, on either bridge. Carrier: none.
- The step log still records `NODE_FAILURE` for a returned node failure,
unchanged.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent f35b4e1 commit 9cfc575
17 files changed
Lines changed: 1220 additions & 48 deletions
File tree
- .changeset
- packages
- runtime/src
- domains
- services/service-automation/src
- builtin
- verify/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| 45 | + | |
| 46 | + | |
45 | 47 | | |
46 | 48 | | |
47 | 49 | | |
| |||
50 | 52 | | |
51 | 53 | | |
52 | 54 | | |
53 | | - | |
| 55 | + | |
54 | 56 | | |
55 | 57 | | |
56 | 58 | | |
| |||
1044 | 1046 | | |
1045 | 1047 | | |
1046 | 1048 | | |
1047 | | - | |
1048 | | - | |
1049 | | - | |
1050 | | - | |
1051 | | - | |
1052 | | - | |
1053 | | - | |
1054 | | - | |
1055 | | - | |
| 1049 | + | |
| 1050 | + | |
| 1051 | + | |
| 1052 | + | |
| 1053 | + | |
| 1054 | + | |
| 1055 | + | |
| 1056 | + | |
| 1057 | + | |
1056 | 1058 | | |
1057 | | - | |
1058 | | - | |
1059 | | - | |
1060 | | - | |
1061 | | - | |
1062 | | - | |
| 1059 | + | |
1063 | 1060 | | |
1064 | 1061 | | |
1065 | 1062 | | |
| |||
1090 | 1087 | | |
1091 | 1088 | | |
1092 | 1089 | | |
1093 | | - | |
| 1090 | + | |
1094 | 1091 | | |
| 1092 | + | |
| 1093 | + | |
| 1094 | + | |
| 1095 | + | |
| 1096 | + | |
| 1097 | + | |
| 1098 | + | |
| 1099 | + | |
| 1100 | + | |
| 1101 | + | |
1095 | 1102 | | |
1096 | 1103 | | |
1097 | 1104 | | |
1098 | 1105 | | |
1099 | 1106 | | |
| 1107 | + | |
1100 | 1108 | | |
1101 | 1109 | | |
1102 | 1110 | | |
1103 | 1111 | | |
1104 | 1112 | | |
1105 | 1113 | | |
| 1114 | + | |
1106 | 1115 | | |
1107 | 1116 | | |
1108 | 1117 | | |
| |||
1290 | 1299 | | |
1291 | 1300 | | |
1292 | 1301 | | |
| 1302 | + | |
| 1303 | + | |
| 1304 | + | |
| 1305 | + | |
| 1306 | + | |
| 1307 | + | |
1293 | 1308 | | |
1294 | 1309 | | |
1295 | 1310 | | |
1296 | | - | |
1297 | | - | |
1298 | | - | |
1299 | | - | |
| 1311 | + | |
| 1312 | + | |
1300 | 1313 | | |
1301 | 1314 | | |
1302 | 1315 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
309 | 309 | | |
310 | 310 | | |
311 | 311 | | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
312 | 323 | | |
313 | | - | |
| 324 | + | |
314 | 325 | | |
315 | 326 | | |
316 | 327 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
824 | 824 | | |
825 | 825 | | |
826 | 826 | | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
827 | 831 | | |
828 | 832 | | |
829 | | - | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
830 | 837 | | |
831 | 838 | | |
832 | 839 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| 50 | + | |
| 51 | + | |
50 | 52 | | |
51 | 53 | | |
52 | 54 | | |
| |||
2033 | 2035 | | |
2034 | 2036 | | |
2035 | 2037 | | |
2036 | | - | |
2037 | | - | |
2038 | | - | |
2039 | | - | |
2040 | | - | |
2041 | | - | |
| 2038 | + | |
| 2039 | + | |
| 2040 | + | |
| 2041 | + | |
| 2042 | + | |
| 2043 | + | |
| 2044 | + | |
| 2045 | + | |
2042 | 2046 | | |
2043 | 2047 | | |
2044 | | - | |
| 2048 | + | |
| 2049 | + | |
| 2050 | + | |
| 2051 | + | |
2045 | 2052 | | |
2046 | 2053 | | |
2047 | 2054 | | |
| |||
2252 | 2259 | | |
2253 | 2260 | | |
2254 | 2261 | | |
| 2262 | + | |
| 2263 | + | |
| 2264 | + | |
| 2265 | + | |
| 2266 | + | |
| 2267 | + | |
| 2268 | + | |
| 2269 | + | |
2255 | 2270 | | |
2256 | 2271 | | |
2257 | 2272 | | |
| |||
2349 | 2364 | | |
2350 | 2365 | | |
2351 | 2366 | | |
| 2367 | + | |
| 2368 | + | |
| 2369 | + | |
| 2370 | + | |
| 2371 | + | |
| 2372 | + | |
2352 | 2373 | | |
2353 | 2374 | | |
2354 | 2375 | | |
2355 | 2376 | | |
2356 | 2377 | | |
2357 | | - | |
2358 | | - | |
| 2378 | + | |
2359 | 2379 | | |
2360 | 2380 | | |
| 2381 | + | |
2361 | 2382 | | |
2362 | 2383 | | |
2363 | 2384 | | |
| |||
3326 | 3347 | | |
3327 | 3348 | | |
3328 | 3349 | | |
3329 | | - | |
| 3350 | + | |
3330 | 3351 | | |
3331 | 3352 | | |
3332 | 3353 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
808 | 808 | | |
809 | 809 | | |
810 | 810 | | |
| 811 | + | |
811 | 812 | | |
812 | | - | |
813 | | - | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
814 | 817 | | |
815 | 818 | | |
816 | 819 | | |
817 | 820 | | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
818 | 826 | | |
819 | 827 | | |
820 | 828 | | |
| |||
1001 | 1009 | | |
1002 | 1010 | | |
1003 | 1011 | | |
1004 | | - | |
| 1012 | + | |
1005 | 1013 | | |
1006 | 1014 | | |
1007 | 1015 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
56 | 56 | | |
57 | 57 | | |
58 | 58 | | |
| 59 | + | |
| 60 | + | |
59 | 61 | | |
60 | 62 | | |
61 | 63 | | |
| |||
585 | 587 | | |
586 | 588 | | |
587 | 589 | | |
588 | | - | |
589 | | - | |
590 | | - | |
591 | | - | |
592 | | - | |
593 | | - | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
594 | 599 | | |
595 | 600 | | |
596 | 601 | | |
597 | 602 | | |
598 | 603 | | |
| 604 | + | |
599 | 605 | | |
600 | 606 | | |
601 | 607 | | |
| |||
0 commit comments