Skip to content

Commit 21f8c61

Browse files
committed
feat(spec,rest): register READ_BACK_FAILED as a named wire row and map it in handleApprovalError
Part of #13182 (ruled Option B, aligned with the RESUME_FAILED precedent). Ledger row + handleApprovalError mapping arm + contract doc line + generated references docs + wire-code pin test. 500 semantics stay; additive only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N
1 parent 7404925 commit 21f8c61

7 files changed

Lines changed: 63 additions & 1 deletion

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
feat(spec,rest): register `READ_BACK_FAILED` as a named wire row and map it in `handleApprovalError` (#13182)
7+
8+
The `READ_BACK_FAILED:` refusal (#12769: an approval mutation succeeded but its
9+
post-write read-back is filtered out by the caller's organization scope) used
10+
to reach REST clients through each route's terminal 500 arm — a registered
11+
code (`APPROVAL_RECALL_FAILED` and siblings) whose name does not describe what
12+
happened, with the accurate sentence only in the body.
13+
14+
Following the `RESUME_FAILED` precedent (a genuine server-side inconsistency,
15+
but named), `READ_BACK_FAILED` is now registered in the ADR-0112 error-code
16+
ledger and `handleApprovalError` maps the `READ_BACK_FAILED:` message prefix to
17+
HTTP 500 with `code: 'READ_BACK_FAILED'` on the wire. The 500 semantics stay —
18+
it is genuinely a server-side inconsistency; the write is recorded and NOT
19+
rolled back, and the row can be read back with a system or
20+
matching-organization context. Additive vocabulary only: no existing code,
21+
status, or message changes.

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +290 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +291 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3232
| **message** | `string` | ✅ | Readable error message |
3333
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -261,6 +261,7 @@ const result = ApiErrorSchema.parse(data);
261261
* `PROJECT_PROVISIONING_FAILED`
262262
* `QUERY_OBJECT_MISMATCH`
263263
* `RAW_SQL_UNSUPPORTED`
264+
* `READ_BACK_FAILED`
264265
* `READ_SCOPE_COMPILE_FAILED`
265266
* `RECORD_GONE`
266267
* `RECORD_LOCKED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ const result = ErrorCode.parse(data);
365365
* `PROJECT_PROVISIONING_FAILED`
366366
* `QUERY_OBJECT_MISMATCH`
367367
* `RAW_SQL_UNSUPPORTED`
368+
* `READ_BACK_FAILED`
368369
* `READ_SCOPE_COMPILE_FAILED`
369370
* `RECORD_GONE`
370371
* `RECORD_LOCKED`

‎packages/rest/src/rest-approvals-wire-codes.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,31 @@ describe('approvals wire codes are registered vocabulary (#8885)', () => {
115115
).toBe(true);
116116
});
117117

118+
// [#13182] `READ_BACK_FAILED` is a NAMED wire row (the RESUME_FAILED
119+
// precedent: a genuine server-side inconsistency, but named): the write is
120+
// recorded and NOT rolled back, the read-back is org-filtered, and the
121+
// 500 semantics stay. Pinned here so the prefix→code mapping and ledger
122+
// membership cannot regress independently: without the mapping arm this
123+
// throw would ride the template-generated `APPROVAL_APPROVE_FAILED` arm —
124+
// a registered code whose name does not describe what happened.
125+
it('an org-filtered read-back on approve answers 500 READ_BACK_FAILED — named, not the template fallback', async () => {
126+
const rest = boot({
127+
decide: vi.fn().mockRejectedValue(new Error(
128+
"READ_BACK_FAILED: the write to approval request 'req_1' was recorded, but the updated row is "
129+
+ "not visible inside the caller's organization scope, so the result envelope cannot be built. "
130+
+ 'The write is NOT rolled back — read the request back with a system or matching-organization context.',
131+
)),
132+
});
133+
const answer = await drive(rest, 'POST', `${REQ}/approve`);
134+
expect(answer.status).toBe(500);
135+
expect(answer.body?.code).toBe('READ_BACK_FAILED');
136+
expect(answer.body?.error).toMatch(/^the write to approval request 'req_1' was recorded/);
137+
expect(
138+
ApiErrorSchema.safeParse({ code: answer.body?.code, message: answer.body?.error }).success,
139+
'READ_BACK_FAILED must be in StandardErrorCode ∪ ERROR_CODE_LEDGER',
140+
).toBe(true);
141+
});
142+
118143
it('an unmapped service fault on approve answers 500 APPROVAL_APPROVE_FAILED — the template-generated arm, live', async () => {
119144
const rest = boot({
120145
decide: vi.fn().mockRejectedValue(new Error('kaboom: not in the mapping table')),

‎packages/rest/src/rest-server.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10865,6 +10865,13 @@ export class RestServer {
1086510865
// server-side inconsistency, but named, so the client can say
1086610866
// which run needs an operator instead of showing a bare 500.
1086710867
[/^RESUME_FAILED/, 500, 'RESUME_FAILED'],
10868+
// The write IS recorded and NOT rolled back, but the updated
10869+
// row is invisible inside the caller's organization scope, so
10870+
// the result envelope cannot be built. Same class as
10871+
// RESUME_FAILED — a genuine server-side inconsistency, named
10872+
// (#13182): read the request back with a system or
10873+
// matching-organization context.
10874+
[/^READ_BACK_FAILED/, 500, 'READ_BACK_FAILED'],
1086810875
];
1086910876
for (const [re, status, code] of mapping) {
1087010877
if (re.test(msg)) {

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ export const ERROR_CODE_LEDGER = {
210210
'PROJECT_NOT_FOUND',
211211
'PROJECT_PROVISIONING', // project exists but is still provisioning
212212
'PROJECT_PROVISIONING_FAILED',
213+
'READ_BACK_FAILED', // approval write recorded, but its read-back is filtered by the caller's org scope — the result envelope cannot be built; the write is NOT rolled back
213214
'REPORTS_LIST_FAILED',
214215
'REPORT_DELETE_FAILED',
215216
'REPORT_GET_FAILED',

‎packages/spec/src/contracts/approval-service.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,12 @@ export interface ApprovalDecisionResult {
573573

574574
/**
575575
* Public contract — the node-era approval runtime.
576+
*
577+
* Every mutation echoes the row it just changed. A mutation whose post-write
578+
* read-back is filtered out by the caller's organization scope throws
579+
* `READ_BACK_FAILED` (HTTP 500 over REST, registered in the ADR-0112 ledger):
580+
* the write is recorded and NOT rolled back — read the request back with a
581+
* system or matching-organization context.
576582
*/
577583
export interface IApprovalService {
578584
/**

0 commit comments

Comments
 (0)