Skip to content

Commit ec4f180

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22161-s2-lint-slice-13-hints
2 parents 407cee3 + 018b077 commit ec4f180

23 files changed

Lines changed: 2006 additions & 130 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/verify': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
feat(verify): the reference-value shape family — every client-writable reference field refuses a value that is not a record id (ADR-0104 D1), runnable as `os verify --reference-shapes` (#15953)
7+
8+
Clause-②: yes
9+
10+
A third derived proof family beside `runCrudVerification` and `runRlsProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), reads what the declaration promises from `valueSchemaFor(field, 'stored')` (a record-id string, or an array of them when the field is multi-valued), and asks the engine whether three wrong-shaped values are refused: a boolean, a number, and an embedded record object (the expanded read shape written back). A multi-valued field gets each one as an array element. Every probe is written on insert and on update, through two doors: the REST data routes as the signed-in caller, and the engine's own write as the system principal.
11+
12+
A probe counts as refused only when the engine names the probed field in its refusal. A refusal that names something else is reported `unattributed`, so an engine that refused every write would not read as proven. A field is `proven` (every probe refused), `holes` (a probe was admitted), `failed` (a door faulted), `inconclusive` (a probe could not be attributed or written; the report says why) or `excluded` (declared `readonly` or `system`, which the caller does not write). `holes + failed` is the report's `hardFailures`.
13+
14+
**`@objectstack/verify` — new exports.**
15+
16+
- `runReferenceShapeProofs(stack, token, config, { doors? })` → `ReferenceShapeReport`. Run it on a fresh stack of its own: a door that admits a probe lands a row.
17+
- `deriveReferenceShapeCases(config)`, `referenceShapeProbeValue(kind, multiple, id)`, `formatReferenceShapeReport(report)`, `REFERENCE_SHAPE_PROBE_ID`.
18+
- Types: `ReferenceShapeCase`, `ReferenceShapeDoor`, `ReferenceShapeOperation`, `ReferenceShapeProbeKind`, `ReferenceShapeProbeResult`, `ReferenceShapeFieldStatus`, `ReferenceShapeFieldResult`, `ReferenceShapeReport`, `ReferenceShapeProofOptions`.
19+
20+
```ts
21+
import { bootStack, runReferenceShapeProofs, formatReferenceShapeReport } from '@objectstack/verify';
22+
23+
const stack = await bootStack(myApp);
24+
const report = await runReferenceShapeProofs(stack, await stack.signIn(), myApp);
25+
console.log(formatReferenceShapeReport(report));
26+
expect(report.summary.hardFailures).toBe(0);
27+
await stack.stop();
28+
```
29+
30+
**`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. It is opt-in because the engine still admits wrong-shaped elements on a `multiple: true` `lookup` or `user` field (#22908), so an app that declares one would fail the run until the engine refuses them. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql)!: a detail write asks the caller's read door for its master-detail header before judging it — a header the caller cannot read answers as a header that exists nowhere (#22519)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime read, in two places: the write's master-detail header read (the `parent` binding) and the write's reference check of a `master_detail` field now ask the caller's read door before their elevated read, so a write naming a header the caller cannot read is answered as a write naming a header that exists nowhere. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
A write of a master-detail DETAIL reads its header for two things: the reference check of the `master_detail` value the caller supplied, and the `parent` that a parent-scoped `requiredWhen` / `readonlyWhen` judges.
14+
15+
**FROM.** Both read the header elevated (row-level access bypassed, the caller's tenant kept), on the premise that the caller's right to write the detail settled its right to see the header. ADR-0055 §3 guarantees that only for a `controlled_by_parent` detail (its master-edit gate). For any other detail, a non-system caller who could write the detail but could not read its header met:
16+
17+
- a reference check that admitted the header id, where a header id that exists nowhere answers `reference_not_found`;
18+
- a parent-scoped rule judged against the header's stored state, so the write's answer depended on a row the caller cannot read.
19+
20+
**TO.** The header is read in two steps, the shape `validate()`'s preview already uses:
21+
22+
1. The caller's read door, under the caller's own context, so every enforcement layer in the middleware chain answers it. A header it does not return (hidden by row scope, a refused read of the header object, or a failed read) binds as a missing header binds: the reference check answers `reference_not_found` (`400 VALIDATION_FAILED`), and a parent-scoped `requiredWhen` / `readonlyWhen` meets an unbound `parent` (the requirement is refused as unevaluable, the lock holds), exactly as for a header id that exists nowhere.
23+
2. The elevated, tenant-kept read, only for the headers step 1 returned. A header the caller can read is still judged on its STORED values, a column field-level security hides or masks for the caller included. The preview's per-column masking stays the preview's own.
24+
25+
**Bindings.**
26+
27+
- Every detail, `controlled_by_parent` or not, on every write door: `insert` (single and batch), `insertMany`, update by id (a repoint included) and the predicate (bulk) update.
28+
- System callers (`isSystem`: seed replay, boot, package install) ask no read door and resolve exactly as before.
29+
- A plain `lookup`'s reference check keeps its elevated existence probe, unchanged.
30+
- No new error code and no status change: the answers are the ones a header that exists nowhere already got.
31+
- Cost: one id-projected read under the caller's context per header resolution (once per batch), and one per supplied `master_detail` value in the reference check.
32+
33+
**Fix, if a write relied on the old answer:** give the writer read access to the header — an object permission or sharing rule on the master, or declare the detail `controlled_by_parent` so the header's access governs it.
34+
35+
**Not measured.** Which deployed configurations let a caller write a detail whose header it cannot read was not measured: the rule only tightens, so it holds whatever that answer is.

‎.changeset/22782-objectql-after-hook-abort-rolls-back.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ Clause-②: yes (narrowing)
1212

1313
- **FROM:** `insert` / `update` / `delete` (REST `createData` / `updateData` / `deleteData`, `ctx.api`, the engine door) on an object with a blocking (not `async`) `after*` hook bound from metadata whose `onError` is `abort` or omitted. When that hook threw, the caller was refused, but the row stayed stored, changed or deleted, and so did every write the hook chain had already made through `ctx.api`. A client that retried the refused create made a duplicate.
1414
- **TO:** that write runs inside a unit of work, so the abort rolls back the row and the hook chain's engine writes together. The caller is refused exactly as before. Hook count and order are unchanged.
15-
- **Unchanged:** a write already inside a unit (a caller's `transaction()`, an atomic batch or cascade) joins it, and that unit's owner still decides. No unit is opened for an `onError: 'log'` hook, an `async: true` hook, a hook registered in code with `registerHook` (no declared `onError`), a write with `skipAutomations`, an object whose datasource is not the default one, or a default driver with no transactions (ADR-0119 D1, such as a libSQL remote transport). On those the row stays stored after an abort, as before.
16-
- **Inside the new unit, the rules of any unit apply.** An `after*` hook on such an object that writes a business row to an object on another datasource is now refused with `CrossDatasourceTransactionWriteError`, which the abort turns into a refused write. A MongoDB default store needs a replica set for this unit, as it does for `transaction()` already. If either applies to you, mark the hook `onError: 'log'` or `async: true`, or keep the objects on one datasource.
15+
- **Unchanged:** a write already inside a unit (a caller's `transaction()`, an atomic batch or cascade) joins it, and that unit's owner still decides. No unit is opened for an `onError: 'log'` or `async: true` hook that carries no `condition` (#22856), a hook registered in code with `registerHook` (no declared `onError`), a write with `skipAutomations`, an object whose datasource is not the default one, or a default driver with no transactions (ADR-0119 D1, such as a libSQL remote transport). On those the row stays stored after an abort, as before.
16+
- **Inside the new unit, the rules of any unit apply.** An `after*` hook on such an object that writes a business row to an object on another datasource is now refused with `CrossDatasourceTransactionWriteError`, which the abort turns into a refused write. A MongoDB default store needs a replica set for this unit, as it does for `transaction()` already. If either applies to you, mark the hook `onError: 'log'` or `async: true` and give it no `condition` (#22856), or keep the objects on one datasource.
1717
- **A sandboxed hook body's own `ctx.api.transaction`** on such an object, when no transaction is open around the write, used to open and own its transaction, so a throw from it rolled back only the body's own writes and the triggering row stayed stored. It now joins the door's unit, as it joins any open unit, and the abort rolls back the triggering row too.
1818
- **Work a unit starts and does not await** (an `async: true` hook) no longer inherits the unit's finished transaction handle. It used to fail with "Transaction query already complete" and lose its write. It now runs on its own connection, as it would outside any unit.
1919
- **Cost:** one transaction per write on the objects above only. Measured on the in-process SQLite driver: about 1.36x the per-insert time of the same object with an `onError: 'log'` hook. Objects without such a hook pay one map lookup.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql)!: a write refused because an `after*` hook's `condition` could not be evaluated no longer leaves its row behind when that hook is `onError: 'log'` or `async: true` (#22856)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. A hook that loaded before keeps its authored shape. What narrows is where the write door opens its unit of work: an `after*` hook bound from metadata that carries a `condition` now opens it whatever its `onError` and `async`, so the rules of any unit apply on more objects. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** stored-outcome narrowing, shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
A hook `condition` that cannot be evaluated aborts the operation whatever the hook's `onError` says. Examples are a condition reading `previous` on an insert, a key the object does not declare, or `null > null`. The condition is checked before an `async: true` hook detaches, so that hook aborts too.
14+
15+
- **FROM:** `insert` / `update` / `delete` (REST `createData` / `updateData` / `deleteData`, `ctx.api`, the engine door) on an object whose `after*` hooks bound from metadata are all `onError: 'log'` or `async: true`, with at least one carrying a `condition`. The write door opened no unit of work, because it opened one only for a blocking `onError: 'abort'` hook. So a condition that could not be evaluated refused the caller while the row stayed stored, changed or deleted. Measured on REST `POST /data/:object`: `500`, and the row was stored.
16+
- **TO:** an `after*` hook bound from metadata that carries a `condition` opens the write door's unit, whatever its `onError` and `async`. A condition that cannot be evaluated rolls back the row and the hook chain's engine writes together. The caller is refused exactly as before.
17+
- **Inside the new unit, the rules of any unit apply,** so writes that were accepted are now refused on objects carrying such a hook:
18+
- A blocking `onError: 'log'` hook whose body writes a business row to an object on another datasource has that write refused with `CrossDatasourceTransactionWriteError`. The hook's `onError: 'log'` logs the refusal and the row is not written, where it used to land. The caller's own write is still stored.
19+
- The same applies to the part of an `async: true` hook's body that runs before the unit's callback settles. A write the body makes after that lands as before.
20+
- A MongoDB default store without a replica set now refuses writes to objects carrying such a hook, because this unit needs a replica set, as `transaction()` already does.
21+
- **Fix, if one of these applies to you:** drop the hook's `condition` (and test it in the hook's body instead), or keep the objects on one datasource, or run MongoDB as a replica set.
22+
- **Unchanged:** a condition's verdict is read as before. `false` skips the hook, and with `true` an `onError: 'log'` hook's thrown error is logged and the caller's row is stored. A write already inside a unit joins it. No unit is opened with `skipAutomations`, off the default datasource, or on a default driver with no transactions. An `onError: 'log'` or `async: true` hook with no `condition` still opens no unit.
23+
- **Cost:** one transaction per write on objects that carry such a hook. This is the same per-write cost as the unit for `onError: 'abort'` hooks. In the showcase example this covers updates to `showcase_task` and `showcase_project`, whose `async: true` `afterUpdate` hooks carry conditions.

‎content/docs/automation/hooks.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ Four ordinary operations put a write inside such a unit:
296296

297297
| Operation | What is inside the transaction |
298298
| :--- | :--- |
299-
| Any write to an object with a blocking `after*` hook whose `onError` is `abort` (the default) | The write and every `after*` hook it dispatches, so that hook's abort rolls the row back. Not opened on an object outside the default datasource, or on a driver with no transactions: there the row stays stored after an abort |
299+
| Any write to an object with an `after*` hook that can abort it: a blocking one whose `onError` is `abort` (the default), or one carrying a `condition`, whatever its `onError` and `async` (a condition that cannot be evaluated aborts the write) | The write and every `after*` hook it dispatches, so that hook's abort rolls the row back. Not opened on an object outside the default datasource, or on a driver with no transactions: there the row stays stored after an abort |
300300
| A by-id `delete()` that cascades to dependent records | Each **cascaded child's** `afterDelete`. The parent's own `afterDelete` runs after that unit closes, unless the row above opened one around the whole delete |
301301
| `batchData` / `deleteManyData` with `atomic: true` | Every member's `after*` — the batch aborts and rolls back on the first failure |
302302
| Any write you wrapped yourself in `ctx.api.transaction(...)` or `engine.transaction(...)` | Everything in the callback |

‎content/docs/deployment/cli.mdx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,7 @@ only when the first passes.
19221922
os verify # Author-time rules, then CRUD round-trip fidelity
19231923
os verify --app ./objectstack.config.ts --rls # Also prove the cross-owner RLS invariant
19241924
os verify --rls --multi-tenant --json # Org-scoped boot, structured report
1925+
os verify --reference-shapes # Also prove reference fields refuse non-id values
19251926
```
19261927
19271928
1. **Author-time rules.** The rule registry `os validate` runs, over the stack
@@ -1943,7 +1944,14 @@ os verify --rls --multi-tenant --json # Org-scoped boot, structured rep
19431944
fidelity failure fails the run. With `--rls`, a second fresh boot proves the
19441945
cross-owner invariant — a member must not write what it cannot read — for a
19451946
probe persona and for one persona per declared position, and a hole fails
1946-
the run. `--multi-tenant` boots org-scoped so tenant-isolation RLS policies
1947+
the run. With `--reference-shapes`, another fresh boot proves that every
1948+
client-writable reference field (`lookup`, `master_detail`, `user`, `tree`)
1949+
refuses a value that is not a record id — a boolean, a number, an embedded
1950+
record object — on insert and update, through the REST door and the system
1951+
door; a field that admits one fails the run. It is opt-in because the engine
1952+
still admits wrong-shaped elements on a `multiple: true` `lookup` or `user`
1953+
field, so an app that declares one would fail until the engine refuses them.
1954+
`--multi-tenant` boots org-scoped so tenant-isolation RLS policies
19471955
apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or
19481956
`group`) asks for the same boot.
19491957
@@ -1970,8 +1978,8 @@ document carries depends on where the run ended:
19701978
`path`, `message`, `hint`, plus `package` for a finding raised inside one
19711979
package of the artifact), or the schema issues when the stack does not parse.
19721980
- **Reached the runtime stage:** the report — `app`, `config`, `multiTenant`,
1973-
`crud`, `rls` (with `--rls`) and `hardFailures`, the count the exit status is
1974-
read from.
1981+
`crud`, `rls` (with `--rls`), `referenceShapes` (with `--reference-shapes`)
1982+
and `hardFailures`, the count the exit status is read from.
19751983
- **Could not run:** `{ "error": "<sentence>" }`, plus `code` and `httpStatus`
19761984
when the failure carries them.
19771985

0 commit comments

Comments
 (0)