diff --git a/.changeset/read-back-failed-named-wire-row.md b/.changeset/read-back-failed-named-wire-row.md new file mode 100644 index 0000000000..d40643f49b --- /dev/null +++ b/.changeset/read-back-failed-named-wire-row.md @@ -0,0 +1,21 @@ +--- +"@objectstack/spec": minor +"@objectstack/rest": minor +--- + +feat(spec,rest): register `READ_BACK_FAILED` as a named wire row and map it in `handleApprovalError` (#13182) + +The `READ_BACK_FAILED:` refusal (#12769: an approval mutation succeeded but its +post-write read-back is filtered out by the caller's organization scope) used +to reach REST clients through each route's terminal 500 arm — a registered +code (`APPROVAL_RECALL_FAILED` and siblings) whose name does not describe what +happened, with the accurate sentence only in the body. + +Following the `RESUME_FAILED` precedent (a genuine server-side inconsistency, +but named), `READ_BACK_FAILED` is now registered in the ADR-0112 error-code +ledger and `handleApprovalError` maps the `READ_BACK_FAILED:` message prefix to +HTTP 500 with `code: 'READ_BACK_FAILED'` on the wire. The 500 semantics stay — +it is genuinely a server-side inconsistency; the write is recorded and NOT +rolled back, and the row can be read back with a system or +matching-organization context. Additive vocabulary only: no existing code, +status, or message changes. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 1b5f9f85e8..88ad133f51 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **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) | +| **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) | | **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) | | **message** | `string` | ✅ | Readable error message | | **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); * `PROJECT_PROVISIONING_FAILED` * `QUERY_OBJECT_MISMATCH` * `RAW_SQL_UNSUPPORTED` +* `READ_BACK_FAILED` * `READ_SCOPE_COMPILE_FAILED` * `RECORD_GONE` * `RECORD_LOCKED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 67f24b3b9b..18e39f4ab2 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -365,6 +365,7 @@ const result = ErrorCode.parse(data); * `PROJECT_PROVISIONING_FAILED` * `QUERY_OBJECT_MISMATCH` * `RAW_SQL_UNSUPPORTED` +* `READ_BACK_FAILED` * `READ_SCOPE_COMPILE_FAILED` * `RECORD_GONE` * `RECORD_LOCKED` diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index d1b7d684c7..3fbf4ebae9 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -4780,7 +4780,7 @@ export class ApprovalService implements IApprovalService { throw new Error( `READ_BACK_FAILED: the write to approval request '${requestId}' was recorded, but the updated row is ` + `not visible inside the caller's organization scope, so the result envelope cannot be built. The write ` - + `is NOT rolled back — read the request back with a system or matching-organization context (#12769).`, + + `is NOT rolled back — read the request back with a system or matching-organization context.`, ); } return fresh; diff --git a/packages/rest/src/rest-approvals-wire-codes.test.ts b/packages/rest/src/rest-approvals-wire-codes.test.ts index 23d2e93702..4745193b7e 100644 --- a/packages/rest/src/rest-approvals-wire-codes.test.ts +++ b/packages/rest/src/rest-approvals-wire-codes.test.ts @@ -115,6 +115,31 @@ describe('approvals wire codes are registered vocabulary (#8885)', () => { ).toBe(true); }); + // [#13182] `READ_BACK_FAILED` is a NAMED wire row (the RESUME_FAILED + // precedent: a genuine server-side inconsistency, but named): the write is + // recorded and NOT rolled back, the read-back is org-filtered, and the + // 500 semantics stay. Pinned here so the prefix→code mapping and ledger + // membership cannot regress independently: without the mapping arm this + // throw would ride the template-generated `APPROVAL_APPROVE_FAILED` arm — + // a registered code whose name does not describe what happened. + it('an org-filtered read-back on approve answers 500 READ_BACK_FAILED — named, not the template fallback', async () => { + const rest = boot({ + decide: vi.fn().mockRejectedValue(new Error( + "READ_BACK_FAILED: the write to approval request 'req_1' was recorded, but the updated row is " + + "not visible inside the caller's organization scope, so the result envelope cannot be built. " + + 'The write is NOT rolled back — read the request back with a system or matching-organization context.', + )), + }); + const answer = await drive(rest, 'POST', `${REQ}/approve`); + expect(answer.status).toBe(500); + expect(answer.body?.code).toBe('READ_BACK_FAILED'); + expect(answer.body?.error).toMatch(/^the write to approval request 'req_1' was recorded/); + expect( + ApiErrorSchema.safeParse({ code: answer.body?.code, message: answer.body?.error }).success, + 'READ_BACK_FAILED must be in StandardErrorCode ∪ ERROR_CODE_LEDGER', + ).toBe(true); + }); + it('an unmapped service fault on approve answers 500 APPROVAL_APPROVE_FAILED — the template-generated arm, live', async () => { const rest = boot({ decide: vi.fn().mockRejectedValue(new Error('kaboom: not in the mapping table')), diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3eca151d4b..d538f059f3 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -10865,6 +10865,13 @@ export class RestServer { // server-side inconsistency, but named, so the client can say // which run needs an operator instead of showing a bare 500. [/^RESUME_FAILED/, 500, 'RESUME_FAILED'], + // The write IS recorded and NOT rolled back, but the updated + // row is invisible inside the caller's organization scope, so + // the result envelope cannot be built. Same class as + // RESUME_FAILED — a genuine server-side inconsistency, named + // (#13182): read the request back with a system or + // matching-organization context. + [/^READ_BACK_FAILED/, 500, 'READ_BACK_FAILED'], ]; for (const [re, status, code] of mapping) { if (re.test(msg)) { diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 260cf2ec7b..b09da99697 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -210,6 +210,7 @@ export const ERROR_CODE_LEDGER = { 'PROJECT_NOT_FOUND', 'PROJECT_PROVISIONING', // project exists but is still provisioning 'PROJECT_PROVISIONING_FAILED', + '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 'REPORTS_LIST_FAILED', 'REPORT_DELETE_FAILED', 'REPORT_GET_FAILED', diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index 71c396857f..68ae52f175 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -573,6 +573,12 @@ export interface ApprovalDecisionResult { /** * Public contract — the node-era approval runtime. + * + * Every mutation echoes the row it just changed. A mutation whose post-write + * read-back is filtered out by the caller's organization scope throws + * `READ_BACK_FAILED` (HTTP 500 over REST, registered in the ADR-0112 ledger): + * the write is recorded and NOT rolled back — read the request back with a + * system or matching-organization context. */ export interface IApprovalService { /**