Skip to content

Commit d639673

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-7542-showcase-flow-isoutput
2 parents 5e7bc3f + d6f3f2f commit d639673

5 files changed

Lines changed: 737 additions & 7 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): a hook refusal that declares its status as `statusCode` reaches the wire with that status, not `500 INTERNAL_ERROR` (#7525)
6+
7+
**Observable behaviour change — read this if you alert or retry on `/api/v1/data` statuses.**
8+
A write refused by an engine lifecycle hook that declared an explicit status used
9+
to answer `500 INTERNAL_ERROR` with no `code`. It now answers the status the hook
10+
declared, carrying the hook's ADR-0112 `code`. Two refusals QA reproduced 2× each
11+
move from `500` to `409 RECORD_LOCKED` and `403 FORBIDDEN`. Monitoring that counted
12+
these as server faults will see a 5xx disappear and a 4xx appear, and a client
13+
retrying on 5xx will stop retrying a request that can never succeed.
14+
15+
## What was wrong
16+
17+
`mapDataError` — the error exit for the ~11 CRUD data routes, which bypass
18+
`resolveErrorResponse` entirely — opened its explicit-status passthrough on
19+
`typeof error.status === 'number'` and nothing else. An engine lifecycle hook
20+
declares its status as `statusCode`:
21+
22+
```ts
23+
// plugin-approvals/src/lifecycle-hooks.ts
24+
err.code = 'RECORD_LOCKED'; err.statusCode = 409; // a pending lockRecord approval
25+
err.code = 'FORBIDDEN'; err.statusCode = 403; // a delegation row the caller does not own
26+
```
27+
28+
so the refusal never entered that branch at all. It fell past every structured
29+
branch, matched no message heuristic, and left through `UNCLASSIFIED_FAULT` as
30+
`500 INTERNAL_ERROR` — for a deliberate, well-understood business refusal, with
31+
the correctly-shaped original sitting in the server log. The console never hit
32+
the record-lock case (the affordance is disabled while a lock is live); a direct
33+
API caller — script, integration, second-party client — got an unactionable 500.
34+
35+
**#5582 is not the fix and could not have been.** It widened this same
36+
passthrough's *range* (4xx → 400-599) for producers that declared `status`. The
37+
loss here is one question earlier: *whether* a status was declared at all.
38+
39+
## The fix, and why it is at the boundary
40+
41+
`status``statusCode` → default is what **every other HTTP exit in this repo**
42+
already reads — `runtime`'s `HttpDispatcher.errorFromThrown` (#3867),
43+
`dispatcher-plugin.errorResponseBase`, `endpoint-executor`, `domains/actions`,
44+
`plugin-hono-server`'s user endpoints. `mapDataError` was the single exit that
45+
read one spelling, which is why one thrown error came back as `403` through a
46+
dispatcher route and as `500` through `/api/v1/data`. The gate is now a named
47+
`declaredHttpStatus(error)` helper asking the same 400-599 band over both
48+
spellings.
49+
50+
Teaching the two approvals hooks to spell it `status` would have fixed two
51+
producers and left the boundary answering 500 for the next one — including
52+
`runtime`'s own `action-execution.ts` (`{ statusCode: 503 | 501 | 400 }`) and
53+
`metadata-protocol` (`{ statusCode: 404 }`). The hooks are unchanged.
54+
55+
## What deliberately did NOT change
56+
57+
-**`declaresServerFault`'s own read is still `status`-only.** #5811 ruled that
58+
a *disclosure* rule must not depend on a producer's spelling, and that is
59+
untouched. This is *status resolution*, a different question, and the one call
60+
site inside the passthrough hands the predicate the status this boundary just
61+
resolved — otherwise a `{ statusCode: 5xx, code }` producer would take the 5xx
62+
arm and then be told it declared no fault, dropping its code.
63+
- **The 5xx withhold is unconditional as before.** A `statusCode`-declared 5xx
64+
gets `INTERNAL_ERROR_MESSAGE` plus its code; no producer prose crosses the
65+
boundary, and the full text still reaches the operator.
66+
- **A hook that declares NO status is unchanged** — still judged by the
67+
classifiers, still the terminal sanitised `500 INTERNAL_ERROR`. Promoting a
68+
bare `code` to a 4xx would be consumer-side leniency; that belongs with #7463,
69+
not here.
70+
- **The structured branches keep their precedence.** `OBJECT_NOT_FOUND`,
71+
`DELETE_RESTRICTED`, `VALIDATION_FAILED` and the rest still sit above the
72+
passthrough and still win, `statusCode` or not.
73+
- **`resolveErrorResponse` still reads `status` only.** It delegates to
74+
`mapDataError` for everything it does not pass through, so both doors already
75+
give one wire answer without a second copy of the two-spelling read.
76+
77+
Coverage: `rest-hook-refusal-status-passthrough.test.ts` — 26 cases, including
78+
both reported requests walked in process on the real `PATCH /data/:object/:id`
79+
and `POST /data/:object` routes. Run against unmodified `main` the file is 15/26
80+
red; three further mutations cover the remaining 11, so no case is unfalsifiable.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): state that a `permissions` key on a skill is REJECTED, not stripped (#7567)
6+
7+
`SkillSchema`'s docblock carried a `NOTE` paragraph saying an authored
8+
`permissions` key "is unknown to this schema and silently stripped at parse
9+
time." That was true once, but the behaviour changed: `SkillSchema` is a
10+
`strictObject` whose `guidance.permissions` entry now REFUSES an authored
11+
`permissions` key outright, with a located message telling the author to gate
12+
at the agent level instead (`agent.access` / `agent.permissions`, enforced
13+
since #1884). The docblock's stale present tense was the only thing still
14+
saying "stripped" — a reader could conclude the authoring surface was still
15+
lax exactly where it is now strict.
16+
17+
Only the `NOTE` paragraph's wording changes: it now says the key is rejected
18+
at parse time and points at the located refusal message
19+
(`guidance.permissions`) below it in the same file. The refusal message
20+
itself is untouched — its own "this was stripped in silence" phrase narrates
21+
the historical reason for the refusal, not current behaviour, and stays
22+
correct as written. Every input parses byte-identically before and after this
23+
change (`git diff` touches only comment lines); `pnpm --filter @objectstack/spec
24+
typecheck` and the full spec test suite (9840 tests) are green, and
25+
`check:docs` reports all 231 generated files still in sync — this paragraph
26+
is an inner/property-level TSDoc comment, not the module-level blurb
27+
`build-docs.ts` renders, so no generated doc changes.
28+
29+
Adds a patch changeset for `@objectstack/spec`, following the #7444 / #7473 /
30+
#7565 precedent for describe/TSDoc-only spec docs fixes.

0 commit comments

Comments
 (0)