You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(engine)!: findOne, update and delete declare what they answer, and their hook seams are guarded (#16231) (#16783)
* wip(engine): narrow findOne/update/delete result declarations — census measurement leg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* feat(engine): declare findOne/update/delete result shapes and guard their hook seams
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* test(engine): pin the three declarations and repair the census consumers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* chore(engine): changeset for the verb result declarations
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* chore(spec): regenerate the error-code ledger docs for the three new codes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* fix(engine): keep the tracker id out of the update refusal's runtime prose
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* fix(engine): the seam refusal names the seam, not a culprit; repair the off-contract driver doubles
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* fix(changeset): grade the three repaired consumers `minor`, as the clause-② declaration requires
`Check Changeset`'s level axis is red on this PR: it declares `Clause-②: yes`
and grades three packages whose `src/**` the diff moves at `patch`. A purely
additive widening of a published package's public surface takes at least
`minor` (maintainer ruling 2026-09-04, decision batch #35, on #15294).
`@objectstack/metadata` and `@objectstack/metadata-protocol` are the two the
gate can name. `@objectstack/plugin-auth` rises for the same reason and is NOT
graded by the gate: `PUBLISHED_SOURCE_PATH` is anchored `^packages/([^/]+)/src/`
and this package's changed source is `packages/plugins/plugin-auth/src/` — one
directory level deeper, so it never enters the gate's "grown" set. That is the
blind spot carded as #16713. The level floor comes from the act the PR declares,
not from what the instrument happens to measure.
No source, test or config byte moves; the level axis is the only thing this
commit answers for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* test(spec): pin the three narrowed verb declarations, not only their seam guards
Ruling A has two halves — the declarations and the seam guards — and only the
guard half was pinned. Reverting `findOne` / `update` / `delete` to
`Promise<any>` while keeping the guards reddened nothing: every consumer repair
the census produced compiles identically against `any`, so those repairs record
that a narrowing once happened, not that it still holds. That is ADR-0049's
enforce-or-remove target.
Three `@ts-expect-error` cases under `check:test-typecheck` close it, on the
mechanism the neighbouring #12248 block already relies on: each directive is
resolved by tsc today, so a widening back to `Promise<any>` leaves it UNUSED,
which is itself an error in a file whose debt ledger is exact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* docs(changeset): state the runtime FROM/TO per door, and name both sources
The type FROM/TO was already per verb; the runtime half was one sentence for
three doors and named only the handler. The refusals' own `developerMessage`
names TWO sources — an `after*` handler that assigned an off-declaration value,
and a driver whose exit answered off `IDataDriver` — and the second one is the
source the seven test-double repairs in this PR actually came from, which is why
the refusal sentence names the seam instead of accusing the handler.
Three per-door lines now carry FROM (what the dispatch left, returned silently,
and who read it first) to TO (the registered 500 code raised at that seam).
Driver limbs cited are read off `packages/spec/src/contracts/data-driver.ts`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* chore(docs): regenerate the api reference from the merged tree
The merge of `origin/main` brought fourteen newly registered error codes into
`ERROR_CODE_LEDGER`; this branch adds three. Neither side's bytes can be text
merged into the other — both files are generated — so `os-regen-merge.sh` took
main's side in the merge commit and this commit re-derives them from the merged
source with `gen:schema && gen:docs`.
`contract.mdx`'s `Enum<... +N more>` counter reads `+325` = main's `+322` plus
this branch's three. `error-code-ledger.mdx` carries both sides' rows; all four
hook-result codes (the three added here and the `find()` sibling) are present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231)
10
+
11
+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves. No spec key, no authored metadata property, no config field, no accepted request shape and no stored artifact changes spelling or shape; `objectstack migrate meta` has nothing to rewrite, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. What moves is the declared RETURN TYPE of three TypeScript methods (`packages/spec/src/contracts/data-engine.ts`, its `scoped-context.ts` mirrors, and `ObjectQL` itself) plus three new registered ADR-0112 error codes. The rewrite this ships — add the null check the type now demands — is addressed to a TYPESCRIPT CONSUMER and is delivered by the compiler at their own call site, which is the audience the ADR-0087 ledger explicitly does not serve. `type-surface-only` is the category built for exactly this class and it is NOT claimed here, because its predicate 2 (`no-spec-diff`) is mechanically false for this PR: the surface the maintainer ruling names IS `packages/spec/src/contracts/**`. That gap is reported on the card rather than worked around, and the `**BREAKING**` banner below is carried rather than dropped. -->
12
+
13
+
**BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise<any>` and now declare the answers they have always given:
-`update` → `Promise<Record<string, any> | number | null>`
17
+
-`delete` → `Promise<boolean | number>`
18
+
19
+
`any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own.
20
+
21
+
**Why.**`engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise<any[]>` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise<any>` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused).
22
+
23
+
The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record<string, unknown> | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record<string, any>`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise<any[]>`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`.
24
+
25
+
**What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives.
26
+
27
+
**Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here.
28
+
29
+
**What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`:
30
+
31
+
-`findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise<Record<string, unknown> | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`.
32
+
-`update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise<Record<string, unknown> | null>` / `Promise<number>`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`.
33
+
-`delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise<boolean>` / `Promise<number>`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers.
34
+
35
+
The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler.
Copy file name to clipboardExpand all lines: content/docs/references/api/contract.mdx
+4-1Lines changed: 4 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
27
27
28
28
| Property | Type | Required | Description |
29
29
| :--- | :--- | :--- | :--- |
30
-
|**code**|`Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 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' \| … +325 more>`| ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31
31
|**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) |
|**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`. |
@@ -134,6 +134,7 @@ const result = ApiErrorSchema.parse(data);
134
134
*`DATASET_INVALID`
135
135
*`DATASOURCE_ADMIN_ERROR`
136
136
*`DELEGABLE_SCOPE_FAILED`
137
+
*`DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
137
138
*`DELIVERY_NEVER_SENT`
138
139
*`DELIVERY_NOT_ELIGIBLE`
139
140
*`DESTRUCTIVE_CHANGE`
@@ -185,6 +186,7 @@ const result = ApiErrorSchema.parse(data);
185
186
*`FILTER_TOKEN_UNKNOWN`
186
187
*`FILTER_TOKEN_UNRESOLVED`
187
188
*`FIND_HOOK_RESULT_NOT_ARRAY`
189
+
*`FIND_ONE_HOOK_RESULT_NOT_RECORD`
188
190
*`FLOW_CONVERSION_CONFLICT`
189
191
*`FLOW_DISABLED`
190
192
*`FLOW_FAILED`
@@ -356,6 +358,7 @@ const result = ApiErrorSchema.parse(data);
0 commit comments