Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/read-back-failed-named-wire-row.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
25 changes: 25 additions & 0 deletions packages/rest/src/rest-approvals-wire-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
7 changes: 7 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
Loading