Skip to content

Commit 690ccf2

Browse files
huangyiireneclaude
andauthored
fix(objectql): gate by-id update/delete on record existence — 404 RECORD_NOT_FOUND instead of a 400 from further down the pipeline (#7867) (#7989)
* fix(objectql): gate by-id update/delete on record existence — 404 RECORD_NOT_FOUND instead of a 400 from further down the pipeline (#7867) Nothing on the action-body write path ever asked whether the target row existed. `ctx.api.object(name).update({ id, … })` reached `ObjectQL.update()`'s by-id branch through `buildSandboxApi` → `ObjectRepository`, and that branch had no existence gate at all: `engine.update()` on a ghost id was a silent no-op that resolved `null`, so the write ran on into validation, the driver and the hook chain and died on whichever complained first. Which one varied with the object's declarations — a `HookConditionError` 400 on a hooked object, a required-field `VALIDATION_FAILED` 400 on an unhooked one. The 400 class varied; the missing 404 was the constant. `delete()` had the same shape and was worse: it reported success for a row that was never there. The gate goes at the engine, in the by-id branches of `update()` and `delete()` — the one point all three action-body write faces funnel through (`ctx.api.object()`, its context-less repo-facade fallback, and `ctx.engine.update()`). Two sibling paths already gated correctly (`protocol.updateData`/`deleteData`, `callData`'s ObjectQL fallback); all three now throw the same `recordNotFoundError`, which moves to `@objectstack/core` so `engine.ts` can reach it without importing `@objectstack/metadata-protocol` (forbidden in the `/core` closure by ADR-0076 D2's boundary ratchet). `@objectstack/metadata-protocol` re-exports it unchanged. The `if (priorRecord) hookContext.previous = …` never-fabricate rule (ADR-0058 Addendum II / #4649) is untouched — it was behaving correctly on a path that should never have been entered, so the producer is removed rather than the message it produced specialized. Consequence worth knowing: the by-id prior-row read is now unconditional. The #5284 / #5929 narrowings asked "does anything CONSUME the prior row?" and skipped the read when nothing did; existence is a consumer that list never enumerated and the one consumer every by-id write has, and no cheaper question answers it. Their read-count pins are rewritten in place, each recording what changed and why. The dispatch half of both cards — the per-object question, `excludeObjects` subtraction, and the retired `sys_fetch_previous_*` builtins — is untouched and still pinned. `@objectstack/runtime`: the sandbox error passthrough also carries `status` now, so an error that names its own HTTP status keeps it across the QuickJS boundary. Without it the action surface served the right diagnosis at the wrong status (`{ code: 'RECORD_NOT_FOUND', httpStatus: 400 }`); `domains/actions.ts` already honoured `.status` first — the number never arrived. Scope: by-id only. A `multi: true` write matching zero rows still resolves "0 rows affected". Tests: a new `engine-write-not-found-gate.test.ts` covering hooked AND unhooked objects (the defect is not about hooks), the delete twin, the predicate-path scope line, and the shared-envelope check; `status` cases in the runtime's `error-passthrough.test.ts`; and an assertion on the dogfood fixture that has been passing 23/23 while logging this exact error through a required 3-shard gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRQ39odXrHFtxM777obo8t * test(plugin-audit): the #5860 SKIP_OBJECTS pin measures the read, not the skip (#7867) #5860's acceptance criterion — the per-object demand gate judges a SKIP_OBJECTS object unhooked — is unchanged and still asserted directly by the sibling cases. What changed is that the gate no longer decides whether the engine LOOKS at the row: #7867's not-found gate needs the by-id prior read unconditionally, so `sys_job_queue` pays one read like everything else. One, not two — an audit handler forcing its own read would still be caught, and the audit ledger is still empty for the skipped object. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRQ39odXrHFtxM777obo8t * test(objectql): keep the #7867 pin out of the TEST_DEBT ledger — type its fixtures instead of casting (#7867) `engine-write-not-found-gate.test.ts` added one raw tsc error to `@objectstack/objectql`'s hidden test layer (TS2554 — `registerObject` takes a required `packageId`), pushing TEST_DEBT from 355 to 356 and failing `check:type-check-debt --re-measure`. That ledger is a shrink-only ratchet (#5278), so the error is fixed rather than the number raised. Fixed by typing the two fixtures as `ServiceObject` and passing the package id — not by widening the cast. Typing them also surfaced that `primaryKey` is not a declared field property; the registry provisions the primary key itself, so the key was a no-op the compiler could not see while the fixture stayed untyped. Removed, with the reasoning in place. objectql's TEST_DEBT re-measures at 355 (its recorded number) and this file now contributes zero errors. The ledger entry in scripts/check-type-check-coverage.mjs is untouched, and --lower was not run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRQ39odXrHFtxM777obo8t * test(plugin-auth): the #5941 guard case deletes a REAL row, not a ghost id (#7867) `last-admin-guard.test.ts`'s 'deleting an unrelated row on another object is not this guard's business' deleted the `sys_account` id 'nope' — never seeded — and asserted it RESOLVED. It passed for a reason unrelated to the guard: `ObjectQL.delete()` had no existence gate on its by-id path, so a delete naming no row was a silent no-op reporting success. The case was asserting the absence of a guard by way of that defect, so #7867's gate turned it red. Rewritten to delete a REAL `sys_account` row (the fixture already seeds one via `accountProvider`), which states the same thing more strongly: the guard does not merely fail to fire on a write that touched nothing, it lets a write that really removes a row on this object through. The ghost-id half is KEPT as its own assertion — refused by the ENGINE with RECORD_NOT_FOUND, and explicitly not by the last-admin guard — so the two questions the case conflated are now answered separately. Reverse-verified: with the delete gate reverted the new assertion goes red (`expected undefined to be 'RECORD_NOT_FOUND'`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRQ39odXrHFtxM777obo8t --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a0a206f commit 690ccf2

21 files changed

Lines changed: 1305 additions & 135 deletions
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/core": patch
4+
"@objectstack/metadata-protocol": patch
5+
"@objectstack/runtime": patch
6+
"@objectstack/plugin-audit": patch
7+
"@objectstack/plugin-auth": patch
8+
---
9+
10+
fix(objectql): a by-id `update()`/`delete()` against a nonexistent record answers 404 `RECORD_NOT_FOUND` instead of a 400 from further down the pipeline (#7867)
11+
12+
Nothing on the action-body write path ever asked whether the target row existed.
13+
`ctx.api.object(name).update({ id, … })` reached `ObjectQL.update()`'s by-id
14+
branch through `buildSandboxApi``ObjectRepository`, and that branch had **no
15+
existence gate at all**: `engine.update()` on a ghost id was a silent no-op that
16+
resolved `null`, so the write ran on into validation, the driver and the hook
17+
chain and died on whichever complained first.
18+
19+
**Which one it died on varied with the object's declarations**, which is why the
20+
defect read as several unrelated bugs:
21+
22+
- a **hooked** object → `400` `HookConditionError`, from an `afterUpdate`
23+
condition reading `previous` on a row nobody read;
24+
- an **unhooked** object → `400` `VALIDATION_FAILED` "X is required", because
25+
with no prior row a PATCH is validated as if it were a whole record.
26+
27+
The 400 class varied; the missing 404 was the constant. Measured on one showcase
28+
stack, same id, same object, same second: `POST /actions/showcase_task/
29+
showcase_mark_done/<ghost>` answered 400 while `PATCH /data/showcase_task/
30+
<ghost>` answered 404. Both answer **404 `RECORD_NOT_FOUND`** now.
31+
32+
`delete()` had the same shape and was the worse of the two: with no gate it
33+
reported success for a row that was never there, so a typo'd id, an
34+
already-deleted row and a real deletion were indistinguishable.
35+
36+
**This is not a `previous`-binding bug.** `if (priorRecord) hookContext.previous
37+
= …` is correct and is untouched — ADR-0058 Addendum II / #4649 require that an
38+
absent row leave `previous` UNBOUND rather than fabricated. It was behaving
39+
correctly on a path that should never have been entered, so the fix removes the
40+
producer rather than specializing what it produced.
41+
42+
**Where the gate went, and why there.** At the engine, in the by-id branches of
43+
`update()` and `delete()` — the one point all three action-body write faces
44+
funnel through (`ctx.api.object()`, its context-less repo-facade fallback, and
45+
`ctx.engine.update()`). A repository-level gate would have closed one of the
46+
three and made `ql.update(o, { id })` and `ctx.api.object(o).update({ id })`
47+
answer one ghost id two different ways. Two sibling paths already gated
48+
correctly — `protocol.updateData`/`deleteData` (#4435) and `callData`'s ObjectQL
49+
fallback (#5138) — and all three now throw the **same** `recordNotFoundError`,
50+
which moved to `@objectstack/core` so the engine can reach it without importing
51+
`@objectstack/metadata-protocol` (forbidden in the `/core` closure by ADR-0076
52+
D2's boundary ratchet). `@objectstack/metadata-protocol` re-exports it unchanged.
53+
54+
Existence is asked with a pre-write read, never off the write's own result:
55+
`IDataDriver.update` declares no not-found signal, and the engine's post-write
56+
readback is `null` for a second reason (a write that moves the row out of the
57+
caller's row scope), so reading either would answer 404 to a write that landed.
58+
59+
**Behaviour change worth knowing about — the by-id prior-row read is now
60+
unconditional.** #5284 (update) and #5929 (delete) had narrowed it to "does
61+
anything CONSUME the prior row?", skipping the read for objects with no hook, no
62+
prior-reading validation rule and no roll-up. Existence is a consumer that
63+
demand list never enumerated and the one consumer every by-id write has, and no
64+
cheaper question answers it — so the skip and the gate are mutually exclusive.
65+
The measured cost is small: #5929's own record enumerates the global hook
66+
registrants (plugin-sharing, service-storage, plugin-auth, plugin-audit), so on
67+
any kernel that loads them the demand was already true for every object and the
68+
narrowing skipped nothing. The read is genuinely new only for a bare
69+
`@objectstack/objectql/core` embedder — which is buying a 404 it did not have.
70+
71+
Three read-count pins measured the old skip and now measure the read, each
72+
recording what changed and why at its own site: #5284's and #5929's in
73+
`packages/objectql`, and #5860's `sys_job_queue` case in `@objectstack/plugin-audit`.
74+
The DISPATCH half all three are actually about — the per-object `hasHooksFor`
75+
question, the `excludeObjects` subtraction, and the retired
76+
`sys_fetch_previous_*` builtins — is untouched and still pinned.
77+
78+
One further case encoded the old silent no-op as correct: `@objectstack/plugin-auth`'s
79+
#5941 last-admin-guard test deleted a `sys_account` id that was never seeded and
80+
asserted it RESOLVED, to show the guard does not write-guard that object. It now
81+
deletes a REAL row — which states the same thing more strongly — and separately
82+
pins that a ghost id there is refused by the ENGINE rather than by the guard.
83+
84+
**Scope.** By-id only. A `multi: true` predicate write matching zero rows still
85+
resolves "0 rows affected" — the same line both sibling paths draw.
86+
87+
`@objectstack/runtime`: the sandbox error passthrough now also carries `status`
88+
alongside `code` and `fields`, so an error that names its own HTTP status keeps
89+
it across the QuickJS boundary. Without it the action surface answered the right
90+
diagnosis at the wrong status (`{ code: 'RECORD_NOT_FOUND', httpStatus: 400 }`);
91+
`domains/actions.ts` already honoured `.status` first — the number simply never
92+
arrived. A permission refusal thrown inside a body likewise keeps its 403 now
93+
instead of flattening to 400.

packages/core/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export * from './utils/migration-journal.js';
4444
// Export the runtime filter-placeholder resolver (framework#3582)
4545
export * from './utils/filter-tokens.js';
4646

47+
// Export the shared single-record 404 (#4435/#5138, moved down here in #7867) —
48+
// the one `RECORD_NOT_FOUND` envelope `protocol.updateData`/`deleteData`,
49+
// `callData`'s ObjectQL fallback and the engine's own by-id write gate answer
50+
// with. `@objectstack/metadata-protocol` re-exports it from its original home.
51+
export * from './utils/record-not-found.js';
52+
4753
// Export in-memory fallbacks for core-criticality services
4854
export * from './fallbacks/index.js';
4955

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#4435] The 404 a single-record operation answers when the id names no row.
5+
*
6+
* Extracted so the READ and the two WRITE paths cannot disagree about it. They
7+
* did: `getData` answered `404 RECORD_NOT_FOUND` while `updateData` returned
8+
* `200 { record: null }` and `deleteData` returned `200 { success: true }` for
9+
* any string in the path — so a typo'd id, an already-deleted row and a real
10+
* deletion were indistinguishable, and a client PATCHing a concurrently deleted
11+
* record was told its write had landed.
12+
*
13+
* That is the same silent-no-op shape the v17 train removed everywhere else
14+
* this window (#4240/#4303/#4315 refuse missing fields, #4169 refuses unknown
15+
* params, #4190 stopped dropping filters) — a write that touched zero rows
16+
* reporting 200 is that shape one level up, on the verb where it costs the
17+
* most.
18+
*
19+
* [#5138] EXPORTED, for the same "cannot disagree about it" reason one layer
20+
* out. `@objectstack/runtime`'s `callData` is protocol-first with an ObjectQL
21+
* FALLBACK, and the fallback had reinvented this fact three incompatible ways
22+
* (`get` → `null`, `update` → a bare `Error` with no status ⇒ 500, `delete` →
23+
* no check at all ⇒ `200 { deleted: true }` for a row that never existed). It
24+
* now calls THIS function, so the two paths behind one `callData` answer a
25+
* missing id identically — which is the only reason a caller may stop caring
26+
* which of them served it. Re-spelling the envelope there would have been a
27+
* second not-found envelope; `RECORD_NOT_FOUND` (#5088) is the one this repo
28+
* has.
29+
*
30+
* ── [#7867] Why it lives in `@objectstack/core` and not where it was written ──
31+
*
32+
* Because the THIRD path that needed it could not reach the second one. An
33+
* action body's `ctx.api.object(name).update({ id, … })` traverses neither
34+
* `protocol.updateData` nor `callData`: it reaches `ObjectQL.update()`'s by-id
35+
* branch directly, which had no existence gate at all, so a ghost id was a
36+
* silent no-op that then died on whatever the pipeline complained about first
37+
* (a `HookConditionError` 400 on a hooked object, a required-field
38+
* `VALIDATION_FAILED` 400 on an unhooked one — the 400 class varied with the
39+
* object's declarations; the missing 404 was the constant).
40+
*
41+
* The gate for that path belongs in the engine, and `packages/objectql` cannot
42+
* import `@objectstack/metadata-protocol` where this function was written:
43+
* ADR-0076 D2's boundary ratchet (`core-boundary.ratchet.test.ts`) forbids the
44+
* whole `@objectstack/objectql/core` closure — `engine.ts` included — from
45+
* pulling that package in. So the choice was a FOURTH spelling of the envelope
46+
* or one home both layers already depend on. #5138's own sentence rules the
47+
* first out, so this is the second: the factory moved down to the lowest
48+
* package the three producers share, and `@objectstack/metadata-protocol`
49+
* re-exports it unchanged for every existing importer.
50+
*
51+
* This is the same move `engineCanRollBack` made for the same reason — a fact
52+
* two layers must agree on lives in the layer beneath both, not in a copy each.
53+
*/
54+
export function recordNotFoundError(object: string, id: string | number): Error {
55+
const err = new Error(`Record ${id} not found in ${object}`) as Error & {
56+
code?: string;
57+
status?: number;
58+
object?: string;
59+
};
60+
err.code = 'RECORD_NOT_FOUND';
61+
err.status = 404;
62+
err.object = object;
63+
return err;
64+
}

packages/metadata-protocol/src/protocol.ts

Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type {
44
DataProtocol, MetadataProtocol, PackageProtocol,
55
} from '@objectstack/spec/api';
6-
import { IDataEngine, engineCanRollBack } from '@objectstack/core';
6+
import { IDataEngine, engineCanRollBack, recordNotFoundError } from '@objectstack/core';
77
import { readEnvWithDeprecation, resolveTenancyPosture } from '@objectstack/types';
88
// [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?".
99
// `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering
@@ -640,43 +640,22 @@ export function zodIssuesToMetadataIssues(issues: unknown): MetadataIssueEntry[]
640640
}
641641

642642
/**
643-
* [#4435] The 404 a single-record operation answers when the id names no row.
644-
*
645-
* Extracted so the READ and the two WRITE paths cannot disagree about it. They
646-
* did: `getData` answered `404 RECORD_NOT_FOUND` while `updateData` returned
647-
* `200 { record: null }` and `deleteData` returned `200 { success: true }` for
648-
* any string in the path — so a typo'd id, an already-deleted row and a real
649-
* deletion were indistinguishable, and a client PATCHing a concurrently deleted
650-
* record was told its write had landed.
651-
*
652-
* That is the same silent-no-op shape the v17 train removed everywhere else
653-
* this window (#4240/#4303/#4315 refuse missing fields, #4169 refuses unknown
654-
* params, #4190 stopped dropping filters) — a write that touched zero rows
655-
* reporting 200 is that shape one level up, on the verb where it costs the
656-
* most.
657-
*
658-
* [#5138] EXPORTED, for the same "cannot disagree about it" reason one layer
659-
* out. `@objectstack/runtime`'s `callData` is protocol-first with an ObjectQL
660-
* FALLBACK, and the fallback had reinvented this fact three incompatible ways
661-
* (`get` → `null`, `update` → a bare `Error` with no status ⇒ 500, `delete` →
662-
* no check at all ⇒ `200 { deleted: true }` for a row that never existed). It
663-
* now calls THIS function, so the two paths behind one `callData` answer a
664-
* missing id identically — which is the only reason a caller may stop caring
665-
* which of them served it. Re-spelling the envelope there would have been a
666-
* second not-found envelope; `RECORD_NOT_FOUND` (#5088) is the one this repo
667-
* has.
643+
* [#4435/#5138] The 404 a single-record operation answers when the id names no
644+
* row — the repo's ONE `RECORD_NOT_FOUND` envelope.
645+
*
646+
* [#7867] The body moved to `@objectstack/core`
647+
* (`utils/record-not-found.ts` — full provenance lives there); this is a
648+
* re-export, so every existing importer of
649+
* `@objectstack/metadata-protocol`'s `recordNotFoundError` is unchanged and
650+
* the three producers still share one function object.
651+
*
652+
* ⛔ Do not re-declare it here. It moved because a THIRD producer needed it and
653+
* could not reach this package: `ObjectQL.update()`/`delete()`'s by-id gate
654+
* lives in `packages/objectql`, whose `/core` entry closure is forbidden by
655+
* ADR-0076 D2's boundary ratchet from importing `@objectstack/metadata-protocol`
656+
* at all. A local copy here would be the second spelling #5138 ruled out.
668657
*/
669-
export function recordNotFoundError(object: string, id: string | number): Error {
670-
const err = new Error(`Record ${id} not found in ${object}`) as Error & {
671-
code?: string;
672-
status?: number;
673-
object?: string;
674-
};
675-
err.code = 'RECORD_NOT_FOUND';
676-
err.status = 404;
677-
err.object = object;
678-
return err;
679-
}
658+
export { recordNotFoundError };
680659

681660
/**
682661
* A 400 for a `$filter` ARRAY that looks like a filter AST but is not one.

packages/objectql/src/engine-delete-dispatch.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,18 @@ function makeRecordingDriver() {
3535
supports: {},
3636
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
3737
async find() { return []; },
38-
async findOne() { return null; },
38+
// [#7867] Answers the row the by-id branch's not-found gate asks for.
39+
// This used to be `return null`, which — now that a by-id update/delete
40+
// refuses a ghost id with `RECORD_NOT_FOUND` — would make every by-id case
41+
// in this file die at the gate and never reach the driver: a DOUBLE looser
42+
// than the producer, hiding the very behaviour the file exists to observe
43+
// (#4434/#4550's shape). It echoes back whatever id it was asked for, so it
44+
// stays agnostic about the dispatch and can never make a `reject` case look
45+
// like a `by-id` one.
46+
async findOne(_o: string, ast: any) {
47+
const id = ast?.where?.id;
48+
return id === undefined || id === null ? null : { id, title: 'stored' };
49+
},
3950
async create(_o: string, data: Record<string, unknown>) { return { id: 'r1', ...data }; },
4051
async update(_o: string, id: string, data: Record<string, unknown>) { return { id, ...data }; },
4152
async delete(_o: string, id: string) { calls.push({ fn: 'delete', arg: id }); return true; },

0 commit comments

Comments
 (0)