Repository navigation
objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306
Description
Activity
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsTriage: first grade,
bug·priority:p2·domain:engine·area:records·pm:queue. Direction: withhold readonly keys frombeforeInsert, as update already doesTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T14:55Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/objectql/src/engine.ts(insert hook order versusstaticReadonlyCreateStrip) ⇒domain:engine; rationale:packages/objectqlis that lane's.- Why p2: a hook stands down on a value the engine then drops, so the stored row is missing what the hook would have stamped. It is Update-side: a readonly field is stripped from persistence but still reaches beforeUpdate, so hook-derived columns persist values computed from data the row never contains #16344's rule ("a hook is never handed a value that will not be stored"), insert-side.
- Pin: a caller-supplied readonly key on insert, and the hook still stamps. Control: update is unchanged.
- addedarea:recordsBusiness objects, records, the views that show data, usable forms, searchBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Oct 8, 2026 objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsClaim: PM loop round 67 · 2026-10-09T01:28Z
Session:session_01EUBvqtauTDmHi2ZgY759p2
Account:os-litant(the seat's linked user, asGET /useranswers it; always the card's assignee)
Branch:claude/issue-22306-insert-readonly-before-hooks
Worktree:objectstack-issue-22306
Domain:domain:engine
Seat:domain:engine#1
Provenance:- Filed by the
repo:hotcrmseat from Replace the hand-built hook / flow / action harnesses with@objectstack/verify's in-process handle; delete the stand-ins and the suites that only prove the stand-ins; declare the platform packages tests import (epic #1579, step 5) hotcrm#1595's dev, split out of objectql: a formula field in afieldsprojection widens it to every column and the rows are never trimmed back — get_record sends owner, org and audit columns to an external endpoint #22300 on the maintainer's word. - Triage graded it p2
bugarea:records(6062616189). - objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305, on the same axis and file, landed at
c8c803c293(6072369653), so this card is next onarea:records. - Batch 3: feat(metadata-core,metadata-protocol,objectql,plugin-security): the
sys_metadatafamily goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206 S2's dev and [decision] a kernel with gracefulShutdown: false still calls process.exit(1) when its teardown times out, ending every other kernel in a multi-kernel host; the docs say such a kernel stays out of process management #22335's dev hold the other slots.
File surface (atorigin/mainc8c803c293, per triage 6062616189): - Measure first:
- Reproduce on a real engine over the SQL driver. An object has a readonly field and a
beforeInserthook that stamps it only when it is absent. An insert carrying the readonly key: does the hook see the caller's value, stand down, and is the value then stripped, leaving the field NULL? - Then once through the REST door (
POST /api/v1/data/:object) as a non-system user. - Name, on current
main, where insert dispatchesbeforeInsertand wherestaticReadonlyCreateStripruns on insert. The card's line numbers predate fix(objectql): a record the same cascade deletes never refuses its sibling's delete #22377. Read the comment near:12929too: if the order already changed since the card was filed, report it, measure, and stop.
- Reproduce on a real engine over the SQL driver. An object has a readonly field and a
- The fix:
packages/objectql/src/engine.ts. Withhold the readonly keys that will not be stored frombeforeInsert, as update does withreadonlyHiddenFromHooks/dispatchHooksExplainingWithheldReadonly. This is Update-side: a readonly field is stripped from persistence but still reaches beforeUpdate, so hook-derived columns persist values computed from data the row never contains #16344's rule (near:2059), insert side.- Every exemption the create-side strip honours (system context, any readonly key a writer may set) stays exactly as it is.
- A hook-stamped value is stored.
- Pins:
- A caller-supplied readonly key on insert: the hook stamps, and the stamped value is stored.
- Controls: an exempt writer's readonly value is still stored; update is unchanged; bulk insert follows the same order.
- Reverse-verify.
.changeset/22306-*.md(@objectstack/objectqlpatch).- ⛔ Not the cascade walk (landed). No hotcrm workaround.
Container & model:M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate).
Clause-②: no - No key, export or code is added. A hook stops being handed a value the engine was already going to drop: "a hook is never handed a value that will not be stored" (Update-side: a readonly field is stripped from persistence but still reaches beforeUpdate, so hook-derived columns persist values computed from data the row never contains #16344,
engine.tsnear:2059), applied to insert. - What
beforeInsertreceives narrows, so it owes one contract-tier review before the queue.
Responsibility: insert dispatchesbeforeInserton the caller's payload before the static readonly strip | Update-side: a readonly field is stripped from persistence but still reaches beforeUpdate, so hook-derived columns persist values computed from data the row never contains #16344's withholding implemented on update only | a hotcrm opportunity created withstage_entry_datestores NULL where the hook would have stamped today (measured on 17.7.0)
Thread-read: 6062616189
Serial constraints cleared: at 2026-10-09T01:28Z, no open PR touchespackages/objectql/src/engine.ts(15 open PRs, file lists read byfilename). This lane's feat(metadata-core,metadata-protocol,objectql,plugin-security): thesys_metadatafamily goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206 S2 touchesmetadata-protocol, and [decision] a kernel with gracefulShutdown: false still calls process.exit(1) when its teardown times out, ending every other kernel in a multi-kernel host; the docs say such a kernel stays out of process management #22335 touchescore'skernel.ts: different files.
- Filed by the
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 22306, "status": "done", "branch": "claude/issue-22306-insert-readonly-before-hooks", "pr": "https://github.com/objectstack-ai/objectstack/pull/22413", "session": "session_01EUBvqtauTDmHi2ZgY759p2 (the dispatching PM's session, shared; this run's identity is the branch)", "premise_still_valid": true, "summary": "Measured first. The premise held on c8c803c293. ObjectQL.insert is the only beforeInsert dispatch site, and staticReadonlyCreateStrip runs after the hooks and the seal. The comment near :12929 is in validate() and confirms that order: the write's hooks sit between the defaults and the strips. M1: on a real ObjectQL over driver-sql (better-sqlite3), non-system context, with the hotcrm shape, a caller-supplied stage_entry_date: the hook saw '2020-01-01' and stood down, and the row stored NULL and NULL. With the key absent, the hook stamped '2026-10-09' and 0. REST door: POST /api/v1/data/rro_opp through bootStack with a sandboxed body hook gave 201 and stored NULL and NULL at base, and stored the stamp at head. The caller was the seeded admin, a non-system principal; the strip ran on the base call. A signed-up user got 403 on the fixture object. Fix in packages/objectql/src/engine.ts. Before the defaults and before beforeInsert, withholdInsertReadonlyFromHooks takes out what the post-hook static strip will take. It uses the same function (stripReadonlyFields), the same subject (staticReadonlyInsertSubject) and the same options (no preserveAudit). The defaults then fill a withheld key as they fill any absent key, so the hook sees the default or no key, the same as for an honest caller. After the seal, handBackWithheldInsertReadonly puts the caller's values back wherever no hook wrote the key, so the strip, its re-default, WARN, onFieldsDropped and strictReadonlyWrites judge the same payload as before. A data.x = data.x self-assignment is undone, as on update. The dispatch goes through dispatchHooksExplainingWithheldReadonly; hook-withheld-readonly-fault.ts gets a beforeInsert prescription with no ctx.previous, and the update text is byte-identical. M2, what update withholds: stripReadonlyFields over the full schema, which covers static readonly and autonumber but not readonlyWhen. It is exempt for isSystem and the preserveAudit whitelist, skips keys the caller did not send, and excludes id. After the hooks it seals, hands back, undoes set-to-undefined and narrows hookWrittenKeys, and ctx.submitted carries the caller's submission. Insert differs from update in three measured places. (1) autonumber is not withheld: including it turned three existing pins red, #6339's 'the hook can still SEE the caller-submitted record number' and two #14259 provenance-seam cases. (2) Under preserveAudit the audit timeline stays visible, because the audit binder's preserveAudit branch reinstates it as a hook write (the #15964 historical-import channel). (3) sys_ and platform-internal objects are already outside the create strip's subject. M3: one dispatch site. Single insert, batch insert, insertMany, the protocol batch upsert create arm, REST POST, import and create_record all reach it. Seeds are isSystem. M4: a hook stamp of a readonly key is stored. At head the hook saw the key absent and the stamp was stored; with the hide ablated the hook saw '2020-01-01' and the stamp was stored. Docs made false by the change were fixed: the HookContext TSDoc in packages/spec (comment only; the submitted describe is unchanged) and content/docs/protocol/objectql/security.mdx. The system-context census was regenerated, 118 to 119 sites. The changeset is @objectstack/objectql patch with Clause-②: no. The card assignee was not written. The PR assignee is os-litant via label-write. No labels were written: the dispatch named none and a changeset is present. size/l was added by another actor.", "tests": "Suites at 975f4b6690, under the verify lock (the final commit aa115f1c60 only regenerates content/docs/permissions/system-context.mdx). @objectstack/objectql --project local: 390 files, 7676 passed. --project repo: 1 file, 5 passed. typecheck (tsc --noEmit plus check:test-typecheck): exit 0, debt held. @objectstack/rest --project local: 264 files, 4954 passed, 326 skipped. --project repo: 5 files, 191 passed, 1 skipped. Dogfood with OS_TEST_SHARD k/3: 1/3 77 files, 569 passed; 2/3 77 files, 545 passed, 1 skipped; 3/3 76 files passed and 1 skipped, 679 passed, 8 skipped. The new pin file src/engine-insert-readonly-hook-input.test.ts has 13 cases, all passing; hook-withheld-readonly-fault.test.ts has one more case. An earlier full objectql run, with autonumber in the hide set, had 3 failures in existing pins (engine-insert-runtime-owned-strip.test.ts, engine-hook-provenance-sibling-seams.test.ts seam 2). That is why the set was narrowed; all green after. Reverse verification (scripts/ablation-replace.mjs, anchor 'hookViewSource[i] = hidden.row;' 1 to 0, replacement 'void hidden.row;', run from committed state, src-resolved pins): 9 failed and 4 passed of 13, and the 4 that passed are the controls that do not depend on the hide. Restore proven: blob cbe25e03f773 equals HEAD and git diff HEAD is empty. Base vs head on the real SQL driver and the REST door: objectql was rebuilt per leg. The base leg was git restore --source=c8c803c293 of engine.ts and hook-withheld-readonly-fault.ts, with a trap restore. ablation-dist-preflight showed withholdInsertReadonlyFromHooks absent from the base dist (14 files) and present in the head dist (4 files), and the restore was verified with git diff HEAD --quiet. Its tree leg exited 3 only because two untracked scratch probes were in the tree; those were moved out afterwards. Gates: node scripts/pm/dispatch-gates.mjs --commands at aa115f1c60 derived 113 commands, and all 113 ran and exited 0. In the first battery at 975f4b6690, check:system-context-census was red (118 vs 119, mechanical, fixed by pnpm gen:system-context-census in aa115f1c60), and check:skill-examples and check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, missing dists, NOT MEASURED there). Both went green after the dists were built. dispatch-gates --ran with exit codes: 113 derived, 113 run, 0 NOT-MEASURED (a derived zero). Lint, narrowed to the 5 touched TS files: eslint --no-inline-config --format json reports 5 files, 0 errors, 0 warnings. The narrowing holds because (1) eslint --print-config resolves a config (5 or 6 rules) for each file, (2) the JSON count is 5, and (3) parserOptions.project and projectService are null, so no type-aware linting can move an untouched file's result. The repo-wide pnpm lint is left to CI.", "mcp_calls": "0", "api_writes": "3, all through the fleet-write relay as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft, #22413; read-back 8954 of 8954 bytes identical); (2) label-write assignee add os-litant on #22413, POST /repos/objectstack-ai/objectstack/issues/22413/assignees (read-back matched); (3) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/22306/comments. Also git push to the branch (not REST).", "open_questions": [ { "question": "Changeset level. The update-side twin (#16344) shipped as minor with a BREAKING note on what beforeUpdate reads. This change follows the dispatch: @objectstack/objectql patch, Clause-②: no. Should the contract-tier review raise it?", "options": [ "A: keep patch / Clause-②: no. No key or export is added, the accept set and every stored outcome are unchanged except that the hotcrm shape now stamps, and all objectql, rest and dogfood suites stay green.", "B: minor with a BREAKING note, like #16344, because what a beforeInsert handler reads on ctx.input.data changes for a caller-forged readonly value." ], "recommendation": "A, because the precedent's BREAKING also carried a new HookContext member and a plugin-auth migration, and this change has neither. The handlers it touches were deriving from a value that was never stored. The contract-tier review owed for the narrowing is the place to overturn this." } ], "out_of_scope_findings": [ "carrier: 承接者:无 · noted, not filed (PR Acceptance notes). Insert and update now differ on autonumber: beforeUpdate does not see a caller-supplied autonumber, and beforeInsert still does, per #6339's pin. A hook that issues its own number only when the key is absent stands down on a forged one, and the sequence fills it. Not measured through a public door, so it is not a filing class." ], "deviations": [ "M2's literal set ('the same set that staticReadonlyCreateStrip would strip, with the same exemptions') was followed with one measured narrowing. Under preserveAudit the audit timeline (created_at, created_by, updated_at, updated_by) stays visible, because the audit binder's preserveAudit branch reinstates it as a hook write; withholding would erase historical created_at, the channel #15964's ruling kept working. The pin is in the new test file.", "The hide runs before the defaults, not just before beforeInsert, so a withheld key shows its defaultValue, the same view as an honest caller's. The dispatch's suggested route left the placement open.", "Files beyond the stated surface (engine.ts plus new tests): hook-withheld-readonly-fault.ts and its test (the beforeInsert prescription; the update text is byte-identical); packages/spec/src/data/hook.zod.ts (TSDoc only, two sentences this change made false); content/docs/protocol/objectql/security.mdx (a published page stating the old order); content/docs/permissions/system-context.mdx (the generated census count).", "origin/main 11d119ab18 was merged into the branch (merge commit 975f4b6690, no conflicts, no os-regen pending) before the final suites, per AGENTS.md §10.", "The REST door was measured as the seeded admin, a non-system principal, because a signed-up user got 403 PERMISSION_DENIED on the fixture object.", "Commit trailers use the model-free pair AGENTS.md and the pre-push hook require, rather than the harness reminder's model-named Co-Authored-By. The PR footer is the AGENTS.md session-URL form, not the harness's two-line form.", "The seat's status probe was answered with one line in the transcript, and the order continued." ], "gates": { "derived_at": "aa115f1c60", "derived": 113, "ran": 113, "exit_0": 113, "not_measured": 0, "unrun": 0, "reconcile": "dispatch-gates --ran: 113 derived, 113 run, 0 NOT-MEASURED (derived zero; every line carries its exit code)", "first_battery_at_975f4b6690": "110 exit 0; check:system-context-census exit 1 (count drift, fixed in aa115f1c60); check:skill-examples and check:dual-build-cjs-loads exit 3 (missing dists, NOT MEASURED there, green after building)" }, "line_budget": "686 insertions, 23 deletions over 8 files (git diff --numstat, merge base 11d119ab18 to aa115f1c60), under the 5000-line human-merge threshold. No governed surface touched.", "files_changed": [ ".changeset/22306-insert-readonly-withheld-from-before-insert.md", "content/docs/permissions/system-context.mdx", "content/docs/protocol/objectql/security.mdx", "packages/objectql/src/engine-insert-readonly-hook-input.test.ts", "packages/objectql/src/engine.ts", "packages/objectql/src/hook-withheld-readonly-fault.test.ts", "packages/objectql/src/hook-withheld-readonly-fault.ts", "packages/spec/src/data/hook.zod.ts" ], "cross_lane_paths": [ "content/docs/protocol/objectql/security.mdx", "content/docs/permissions/system-context.mdx", "packages/spec/src/data/hook.zod.ts" ], "cleanup": "Worktree ../objectstack-issue-22306 removed (node_modules first, no --force) after confirming the remote branch head equals the local HEAD aa115f1c60. Scratch probes stayed out of the tree. No dev server or background process left running.", "pr_body_full": { "body": "Fixes #22306\nClause-②: no\n\n## What was wrong\n\nOn a non-system create, `ObjectQL.insert` dispatched `beforeInsert` on the caller's payload. It ran the static `readonly` strip (`staticReadonlyCreateStrip`) only after the hooks. Take a hook that stamps a read-only column only when the column is absent, the hotcrm `if (!input.stage_entry_date)` shape. It saw the caller's value and did nothing, then the strip dropped that value, so the row stored NULL where the hook would have stamped today. The update path has withheld these values from its hooks since #16344. The insert path did not.\n\n## Base and head\n\nThe same object and hook as hotcrm: `stage_entry_date` (date, `readonly`), `days_in_stage` (number, `readonly`), and a `beforeInsert` hook that stamps both when `stage_entry_date` is absent.\n\n| case | base: hook saw `stage_entry_date` | base: stored | head: hook saw | head: stored |\n|---|---|---|---|---|\n| caller sends the readonly key (non-system) | `'2020-01-01'` | NULL / NULL | absent | `'2026-10-09'` / 0 |\n| key absent (non-system) | absent | `'2026-10-09'` / 0 | absent | `'2026-10-09'` / 0 |\n| system writer (`isSystem`) sends the key | `'2020-01-01'` | `'2020-01-01'` / NULL | `'2020-01-01'` | `'2020-01-01'` / NULL |\n| a hook stamps the key unconditionally, and the caller sent it | `'2020-01-01'` | `'2026-10-09'` | absent | `'2026-10-09'` |\n\nHow each row was measured:\n\n- **Rows 1 to 3, real engine over `driver-sql` (better-sqlite3):** base is `c8c803c293` with objectql built from that commit. Head is this branch.\n- **Row 1, REST door:** `POST /api/v1/data/rro_opp` through `bootStack` with a sandboxed `body` hook. The base call answered 201 and stored NULL / NULL. The head call answered 201 and stored `'2026-10-09'` / 0.\n - The caller was the seeded admin, a non-system principal. The strip ran on the base call, which shows this: the forged value was not stored.\n - A freshly signed-up user had no create permission on the fixture object (403), so that user could not be used.\n- **Row 4:** the engine over a recording driver, at head and with the hide ablated. The stored value is unchanged.\n- **Batch:** the same on both paths. At base, row 0 of a batch (forged) stored NULL and row 1 stored the stamp. At head, both rows store the stamp.\n\n## The change (`packages/objectql/src/engine.ts`)\n\n- **Withhold, not strip, as on update.** Before the defaults and before `beforeInsert`, `withholdInsertReadonlyFromHooks` takes out every value that the post-hook static strip will take. It uses the same function (`stripReadonlyFields`), the same subject (`staticReadonlyInsertSubject`) and the same options (no `preserveAudit`), so the two cannot disagree. The defaults then fill a withheld key the way they fill any absent key. So a forged `approval_status: 'approved'` is shown to the hook as its default `'draft'`, exactly what an honest caller's hook sees.\n- **The strip does not move.** After the seal, `handBackWithheldInsertReadonly` puts the caller's values back wherever no hook wrote the key. The strip, its re-default, the WARN, `onFieldsDropped` and `strictReadonlyWrites` then judge the same payload as before.\n - A hook's own write is kept, and is never overwritten by a hand-back.\n - A `data.x = data.x` self-assignment of a withheld key is undone, as on update. The caller's value is stripped and reported, and `undefined` is never stored.\n- **Faults are explained.** The dispatch goes through `dispatchHooksExplainingWithheldReadonly`, the update path's wrapper. A hook that faults reaching through a withheld key gets a 400 that names the key.\n - `hook-withheld-readonly-fault.ts` gets a `beforeInsert` prescription, because a create has no `ctx.previous`.\n - The update message is byte-identical.\n\nWhat update withholds (M2), measured on `origin/main`:\n\n- **The set:** `stripReadonlyFields` over the full object schema. That covers static `readonly` and runtime-owned `autonumber`, but not `readonlyWhen`.\n- **Exemptions:** `isSystem` (the whole pass) and the `preserveAudit` whitelist. A key the caller did not send (own property of the entry snapshot) is never a candidate, and `id` is excluded.\n- **After the hooks:** seal, then hand back what the payload does not hold, undo a set-to-undefined, and narrow `hookWrittenKeys`. The caller's submission travels on `ctx.submitted`.\n\nThe insert set differs from that in three measured places:\n\n- **Runtime-owned `autonumber` stays visible.** I first included it, and three existing pins went red. One is \"the hook can still SEE the caller-submitted record number\" (`engine-insert-runtime-owned-strip.test.ts`, #6339). The other two are the insert half of #14259's provenance seam (`engine-hook-provenance-sibling-seams.test.ts`). Moving that decision is not this card's, so the hide covers the static strip only.\n- **The audit timeline stays visible under `preserveAudit`.** On a create the static strip takes `created_at`, `created_by`, `updated_at` and `updated_by` whatever the flag says. But the historical-import channel that the #15964 ruling kept working reinstates them through the audit binder's `preserveAudit` branch (`record.created_at ?? now`). Withholding them would erase every historical `created_at`. A business `readonly` column under `preserveAudit` is still withheld.\n- **`sys_` and platform-internal (`managedBy`) objects keep their own guards.** The create strip's subject excludes them already, so nothing is withheld there.\n\nThere is no `ctx.submitted` on a create. The one in-repo `beforeInsert` reader that needs a caller's read-only value is the audit binder's `preserveAudit` branch, and it keeps seeing it.\n\nEntry points (M3): `ObjectQL.insert` is the only `beforeInsert` dispatch site in the repo. Single insert, batch insert and `insertMany` all go through it. The engine has no upsert of its own. The protocol batch `upsert` create arm, REST `POST`, import and flows `create_record` all call `engine.insert`. Seeds write with `SEED_WRITE_EXECUTION_CONTEXT` (`isSystem: true`), so nothing is withheld for them.\n\n## Docs touched\n\n- `packages/spec/src/data/hook.zod.ts`: TSDoc only. Two sentences said `beforeInsert` \"is untouched\" and \"still receives the caller's own values\". The `submitted` describe (\"update only\") stays true and is unchanged.\n- `content/docs/protocol/objectql/security.mdx`: rule 5 and the #16344 callout now cover insert.\n- `content/docs/permissions/system-context.mdx`: regenerated by `pnpm gen:system-context-census`, 118 to 119 elevation read sites. The +1 is the hide pass's non-system gate.\n\n## Tests and gates\n\nHead is `aa115f1c60`. The suites ran at `975f4b6690`; the only commit since then is `aa115f1c60`, which regenerates the census page and changes no code.\n\n- **`@objectstack/objectql`:**\n - `--project local`: 390 files, 7676 tests passed;\n - `--project repo`: 1 file, 5 tests passed;\n - `typecheck` (tsc plus `check:test-typecheck`): exit 0.\n- **`@objectstack/rest`:**\n - `--project local`: 264 files, 4954 passed, 326 skipped;\n - `--project repo`: 5 files, 191 passed, 1 skipped.\n- **Dogfood shards** (`OS_TEST_SHARD=k/3`):\n - 1/3: 77 files, 569 passed;\n - 2/3: 77 files, 545 passed, 1 skipped;\n - 3/3: 76 files passed, 1 skipped; 679 tests passed, 8 skipped.\n- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` at `aa115f1c60` derived 113 commands. All 113 ran and exited 0, and `--ran` reconciles them as 113 run, 0 NOT-MEASURED.\n\n- `engine-insert-readonly-hook-input.test.ts` (new, 13 cases):\n - the ordering probe with a writable-key control;\n - the hotcrm shape;\n - a default shown in place of a forgery;\n - controls for `autonumber`, the system writer, strip reporting and the strict refusal;\n - a self-assignment no-op;\n - batch and `insertMany`;\n - `preserveAudit`;\n - the explained fault;\n - an update control.\n- `hook-withheld-readonly-fault.test.ts`: one `beforeInsert` case.\n- **Reverse verification:** `node scripts/ablation-replace.mjs`, anchor `hookViewSource[i] = hidden.row;` replaced with `void hidden.row;`. Result: 9 failed and 4 passed of 13. The four that stayed green are the controls that do not depend on the hide. The restore was verified: blob equals HEAD and `git diff HEAD` is empty.\n\n## Acceptance notes\n\n- **Insert and update still differ on `autonumber`.** `beforeUpdate` does not see a caller-supplied `autonumber`, and `beforeInsert` still does (#6339's pin). A hook that issues its own record number only when the key is absent stands down on a forged one, and the sequence fills it. Not measured through a public door, and nobody has picked this up.\n- **Changeset level.** The changeset is `@objectstack/objectql` patch with `Clause-②: no`, per the dispatch. The update-side twin (#16344) shipped as `minor` with a BREAKING note on what `beforeUpdate` reads. The contract-tier review owed for this narrowing is where that is settled.\n\n---\n_Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_\n" } }objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 22306, "status": "done", "round": "patch round 1 (text only), answering contract review 6074329758 (FAIL on ② only)", "branch": "claude/issue-22306-insert-readonly-before-hooks", "pr": "https://github.com/objectstack-ai/objectstack/pull/22413", "head": "1c13a9930b", "session": "session_01EUBvqtauTDmHi2ZgY759p2 (the dispatching PM's session, shared; this run's identity is the branch)", "premise_still_valid": true, "summary": "Patch round 1. Text only, on the same branch, from aa115f1c60 to 1c13a9930b in two commits; no code or test changed. bf7071210b changes .changeset/22306-insert-readonly-withheld-from-before-insert.md in five ways: (1) '@objectstack/objectql': minor; pre mode stays on, and no sentence says otherwise. (2) Clause-②: no (narrowing). (3) A BREAKING paragraph naming what a beforeInsert handler reads on ctx.input.data on a non-system create, and the withheld set, staticReadonlyInsertSubject's minus the autonumber and preserveAudit audit-timeline edges. It also names the two door flips: under strictReadonlyWrites a self-assigning hook's launder is refused (201 to 400), and a hook or body dereferencing through a withheld key answers 400 where it answered 201. (4) One ADR-0087 disposition in the gate's comment form, not-required (no-migration-prescription). (5) The self-assignment sentence now states the engine's rule. With no defaultValue the write is undone, and the caller's value is stripped and reported; with a defaultValue the default is kept as a hook write and nothing is reported. 1c13a9930b adds the two docs lines the review named as owed (Low, riding along). content/docs/automation/hooks.mdx extends the persist-image sentence to beforeInsert. Census row 21 in content/docs/permissions/system-context.mdx names the second isSystem read; the census gate stays green at 119. pr_body_full.body carries the corrected body for the seat to write: line 2 is Clause-②: no (narrowing); the self-assignment sentence is corrected; 'the two cannot disagree' is replaced with the named preserveAudit shown-but-stripped edge (a bare engine without ObjectQLPlugin, or created_by / updated_by with no session user); and a '## Patch round 1' section sits before the footer. Item 7: @objectstack/spec was not added. Its diff is TSDoc documenting objectql's behaviour, and listing it in this changeset would print objectql's BREAKING note into spec's CHANGELOG under a patch; the upgrader greps objectql's entry. No PR body, label or assignee write was made.", "tests": "No code or test changed in this round, so the round-0 suite readings stand: objectql local 390 files and 7676 passed, objectql repo, typecheck, rest local and repo, and dogfood 1/3, 2/3, 3/3 all green at 975f4b6690, whose code equals 1c13a9930b. Changeset gates on 1c13a9930b: check-adr-0087-registration --base origin/main exit 0, reading the changeset as [BREAKING+clause-②-narrowing] with not-required (no-migration-prescription). check-changeset-no-major --base origin/main exit 0, no major. Its level axis needs a pull_request payload, which a local run does not have. With --event pointing at a synthesized payload carrying the corrected body (pr_body_full.body), it reports 'this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch', exit 0. With the live (old) body it reads a bare no and stands down, exit 0. CI reads the live body until the seat writes the corrected one. Gates: node scripts/pm/dispatch-gates.mjs --commands at 1c13a9930b derived 113 commands, the same list as at aa115f1c60. All 113 ran and exited 0; the prerequisite dists were restored first, a full turbo cache hit, 71 of 71. dispatch-gates --ran with exit codes: 113 derived, 113 run, 0 NOT-MEASURED (a derived zero).", "mcp_calls": "0", "api_writes": "1 this round, through the fleet-write relay as objectstack-fleet[bot]: this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/22306/comments. Also git push of bf7071210b and 1c13a9930b to the branch (not REST). No PR body, label or assignee write.", "open_questions": [], "out_of_scope_findings": [ "carrier: 承接者:无 · noted, not filed (PR Acceptance notes, unchanged from round 0). Insert and update differ on autonumber, per #6339's pin and #14259's seam; the review recorded this as a decision of its own." ], "deviations": [ "Two docs lines beyond the order's ten items: hooks.mdx and census row 21. Both are the review's own 'Docs owed (Low; may ride with the changeset fix)'. Text only.", "The PR body on GitHub still carries the round-0 text, including a bare Clause-②: no, because this round may not write it. The corrected body is pr_body_full.body, for the seat to write.", "The seat's review notes the preserveAudit pin asserts sightings, not the stored value. No test change was allowed this round, so it is unchanged." ], "gates": { "derived_at": "1c13a9930b", "derived": 113, "ran": 113, "exit_0": 113, "not_measured": 0, "unrun": 0, "reconcile": "dispatch-gates --ran: 113 derived, 113 run, 0 NOT-MEASURED (derived zero; every line carries its exit code)", "changeset_gates": "check-adr-0087-registration: BREAKING+clause-②-narrowing, not-required (no-migration-prescription), exit 0; check-changeset-no-major: exit 0, and with --event on the corrected body the level axis reads no (narrowing) with no package graded patch" }, "line_budget": "709 insertions, 25 deletions over 9 files (merge base 11d119ab18 to 1c13a9930b), under the 5000-line human-merge threshold. No governed surface touched.", "files_changed": [ ".changeset/22306-insert-readonly-withheld-from-before-insert.md", "content/docs/automation/hooks.mdx", "content/docs/permissions/system-context.mdx", "content/docs/protocol/objectql/security.mdx", "packages/objectql/src/engine-insert-readonly-hook-input.test.ts", "packages/objectql/src/engine.ts", "packages/objectql/src/hook-withheld-readonly-fault.test.ts", "packages/objectql/src/hook-withheld-readonly-fault.ts", "packages/spec/src/data/hook.zod.ts" ], "files_changed_this_round": [ ".changeset/22306-insert-readonly-withheld-from-before-insert.md", "content/docs/automation/hooks.mdx", "content/docs/permissions/system-context.mdx" ], "cross_lane_paths": [ "content/docs/protocol/objectql/security.mdx", "content/docs/permissions/system-context.mdx", "content/docs/automation/hooks.mdx", "packages/spec/src/data/hook.zod.ts" ], "cleanup": "The worktree was re-created from the remote branch at aa115f1c60, then removed again (node_modules first, no --force) after confirming the remote head equals the local HEAD 1c13a9930b. No background process left running.", "pr_body_full": { "body": "Fixes #22306\nClause-②: no (narrowing)\n\n## What was wrong\n\nOn a non-system create, `ObjectQL.insert` dispatched `beforeInsert` on the caller's payload. It ran the static `readonly` strip (`staticReadonlyCreateStrip`) only after the hooks. Take a hook that stamps a read-only column only when the column is absent, the hotcrm `if (!input.stage_entry_date)` shape. It saw the caller's value and did nothing, then the strip dropped that value, so the row stored NULL where the hook would have stamped today. The update path has withheld these values from its hooks since #16344. The insert path did not.\n\n## Base and head\n\nThe same object and hook as hotcrm: `stage_entry_date` (date, `readonly`), `days_in_stage` (number, `readonly`), and a `beforeInsert` hook that stamps both when `stage_entry_date` is absent.\n\n| case | base: hook saw `stage_entry_date` | base: stored | head: hook saw | head: stored |\n|---|---|---|---|---|\n| caller sends the readonly key (non-system) | `'2020-01-01'` | NULL / NULL | absent | `'2026-10-09'` / 0 |\n| key absent (non-system) | absent | `'2026-10-09'` / 0 | absent | `'2026-10-09'` / 0 |\n| system writer (`isSystem`) sends the key | `'2020-01-01'` | `'2020-01-01'` / NULL | `'2020-01-01'` | `'2020-01-01'` / NULL |\n| a hook stamps the key unconditionally, and the caller sent it | `'2020-01-01'` | `'2026-10-09'` | absent | `'2026-10-09'` |\n\nHow each row was measured:\n\n- **Rows 1 to 3, real engine over `driver-sql` (better-sqlite3):** base is `c8c803c293` with objectql built from that commit. Head is this branch.\n- **Row 1, REST door:** `POST /api/v1/data/rro_opp` through `bootStack` with a sandboxed `body` hook. The base call answered 201 and stored NULL / NULL. The head call answered 201 and stored `'2026-10-09'` / 0.\n - The caller was the seeded admin, a non-system principal. The strip ran on the base call, which shows this: the forged value was not stored.\n - A freshly signed-up user had no create permission on the fixture object (403), so that user could not be used.\n- **Row 4:** the engine over a recording driver, at head and with the hide ablated. The stored value is unchanged.\n- **Batch:** the same on both paths. At base, row 0 of a batch (forged) stored NULL and row 1 stored the stamp. At head, both rows store the stamp.\n\n## The change (`packages/objectql/src/engine.ts`)\n\n- **Withhold, not strip, as on update.** Before the defaults and before `beforeInsert`, `withholdInsertReadonlyFromHooks` takes out every value that the post-hook static strip will take. It uses the same function (`stripReadonlyFields`), the same subject (`staticReadonlyInsertSubject`) and the same options (no `preserveAudit`), so the hide and the strip take the same caller keys. There is one named exception. Under `preserveAudit` the hide leaves the audit timeline visible, while the strip takes it unless the audit binder reinstates it. On a bare engine without `ObjectQLPlugin`, or for `created_by` / `updated_by` with no session user, such a value is shown to the hook and then stripped. The defaults then fill a withheld key the way they fill any absent key. So a forged `approval_status: 'approved'` is shown to the hook as its default `'draft'`, exactly what an honest caller's hook sees.\n- **The strip does not move.** After the seal, `handBackWithheldInsertReadonly` puts the caller's values back wherever no hook wrote the key. The strip, its re-default, the WARN, `onFieldsDropped` and `strictReadonlyWrites` then judge the same payload as before.\n - A hook's own write is kept, and is never overwritten by a hand-back.\n - A `data.x = data.x` self-assignment of a withheld key follows the engine's rule:\n - with no `defaultValue` the hook reads `undefined`, the write is undone as on update, and the caller's value is stripped and reported. `undefined` is never stored.\n - with a `defaultValue` the hook re-assigns the default, which counts as the hook's write. The default is stored and nothing is reported.\n- **Faults are explained.** The dispatch goes through `dispatchHooksExplainingWithheldReadonly`, the update path's wrapper. A hook that faults reaching through a withheld key gets a 400 that names the key.\n - `hook-withheld-readonly-fault.ts` gets a `beforeInsert` prescription, because a create has no `ctx.previous`.\n - The update message is byte-identical.\n\nWhat update withholds (M2), measured on `origin/main`:\n\n- **The set:** `stripReadonlyFields` over the full object schema. That covers static `readonly` and runtime-owned `autonumber`, but not `readonlyWhen`.\n- **Exemptions:** `isSystem` (the whole pass) and the `preserveAudit` whitelist. A key the caller did not send (own property of the entry snapshot) is never a candidate, and `id` is excluded.\n- **After the hooks:** seal, then hand back what the payload does not hold, undo a set-to-undefined, and narrow `hookWrittenKeys`. The caller's submission travels on `ctx.submitted`.\n\nThe insert set differs from that in three measured places:\n\n- **Runtime-owned `autonumber` stays visible.** I first included it, and three existing pins went red. One is \"the hook can still SEE the caller-submitted record number\" (`engine-insert-runtime-owned-strip.test.ts`, #6339). The other two are the insert half of #14259's provenance seam (`engine-hook-provenance-sibling-seams.test.ts`). Moving that decision is not this card's, so the hide covers the static strip only.\n- **The audit timeline stays visible under `preserveAudit`.** On a create the static strip takes `created_at`, `created_by`, `updated_at` and `updated_by` whatever the flag says. But the historical-import channel that the #15964 ruling kept working reinstates them through the audit binder's `preserveAudit` branch (`record.created_at ?? now`). Withholding them would erase every historical `created_at`. A business `readonly` column under `preserveAudit` is still withheld.\n- **`sys_` and platform-internal (`managedBy`) objects keep their own guards.** The create strip's subject excludes them already, so nothing is withheld there.\n\nThere is no `ctx.submitted` on a create. The one in-repo `beforeInsert` reader that needs a caller's read-only value is the audit binder's `preserveAudit` branch, and it keeps seeing it.\n\nEntry points (M3): `ObjectQL.insert` is the only `beforeInsert` dispatch site in the repo. Single insert, batch insert and `insertMany` all go through it. The engine has no upsert of its own. The protocol batch `upsert` create arm, REST `POST`, import and flows `create_record` all call `engine.insert`. Seeds write with `SEED_WRITE_EXECUTION_CONTEXT` (`isSystem: true`), so nothing is withheld for them.\n\n## Docs touched\n\n- `packages/spec/src/data/hook.zod.ts`: TSDoc only. Two sentences said `beforeInsert` \"is untouched\" and \"still receives the caller's own values\". The `submitted` describe (\"update only\") stays true and is unchanged.\n- `content/docs/protocol/objectql/security.mdx`: rule 5 and the #16344 callout now cover insert.\n- `content/docs/permissions/system-context.mdx`: regenerated by `pnpm gen:system-context-census`, 118 to 119 elevation read sites. The +1 is the hide pass's non-system gate.\n\n## Tests and gates\n\nHead is `aa115f1c60`. The suites ran at `975f4b6690`; the only commit since then is `aa115f1c60`, which regenerates the census page and changes no code.\n\n- **`@objectstack/objectql`:**\n - `--project local`: 390 files, 7676 tests passed;\n - `--project repo`: 1 file, 5 tests passed;\n - `typecheck` (tsc plus `check:test-typecheck`): exit 0.\n- **`@objectstack/rest`:**\n - `--project local`: 264 files, 4954 passed, 326 skipped;\n - `--project repo`: 5 files, 191 passed, 1 skipped.\n- **Dogfood shards** (`OS_TEST_SHARD=k/3`):\n - 1/3: 77 files, 569 passed;\n - 2/3: 77 files, 545 passed, 1 skipped;\n - 3/3: 76 files passed, 1 skipped; 679 tests passed, 8 skipped.\n- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` at `aa115f1c60` derived 113 commands. All 113 ran and exited 0, and `--ran` reconciles them as 113 run, 0 NOT-MEASURED.\n\n- `engine-insert-readonly-hook-input.test.ts` (new, 13 cases):\n - the ordering probe with a writable-key control;\n - the hotcrm shape;\n - a default shown in place of a forgery;\n - controls for `autonumber`, the system writer, strip reporting and the strict refusal;\n - a self-assignment no-op;\n - batch and `insertMany`;\n - `preserveAudit`;\n - the explained fault;\n - an update control.\n- `hook-withheld-readonly-fault.test.ts`: one `beforeInsert` case.\n- **Reverse verification:** `node scripts/ablation-replace.mjs`, anchor `hookViewSource[i] = hidden.row;` replaced with `void hidden.row;`. Result: 9 failed and 4 passed of 13. The four that stayed green are the controls that do not depend on the hide. The restore was verified: blob equals HEAD and `git diff HEAD` is empty.\n\n## Acceptance notes\n\n- **Insert and update still differ on `autonumber`.** `beforeUpdate` does not see a caller-supplied `autonumber`, and `beforeInsert` still does (#6339's pin). A hook that issues its own record number only when the key is absent stands down on a forged one, and the sequence fills it. Not measured through a public door, and nobody has picked this up.\n- **Changeset level.** Settled by the contract-tier review: `@objectstack/objectql` `minor` with `Clause-②: no (narrowing)`, a BREAKING note and an ADR-0087 disposition, as the update-side twin (#16344) was. See Patch round 1.\n\n## Patch round 1\n\nThis round changes text only, after the contract-tier review graded the narrowing BREAKING (FAIL on the semver level only; the engine change passed).\n\n- **The changeset** `.changeset/22306-insert-readonly-withheld-from-before-insert.md`:\n - is now `'@objectstack/objectql': minor` with `Clause-②: no (narrowing)`. That is the launch-window spelling of a breaking change, and pre mode stays on (`.changeset/pre.json`, tag `next`).\n - carries a BREAKING paragraph. It names what a `beforeInsert` handler reads on `ctx.input.data` on a non-system create, and the withheld set: `staticReadonlyInsertSubject`'s, minus the `autonumber` and `preserveAudit` audit-timeline edges.\n - names two answers that change at the REST door. Under `strictReadonlyWrites`, a self-assigning hook's launder is now refused (201 to 400). A hook or body that dereferences through a withheld key answers 400 where it answered 201.\n - carries one ADR-0087 disposition marker, `not-required (no-migration-prescription)`, in the comment form the gate reads.\n- **Two sentences corrected:** the self-assignment sentence now states the engine's rule for a withheld key with a `defaultValue`, and \"the two cannot disagree\" now names the `preserveAudit` shown-but-stripped edge. Both corrections are in the changeset and in this body.\n- **The two docs lines the review named as owed:** `content/docs/automation/hooks.mdx` extends the persist-image sentence to `beforeInsert`, and census row 21 in `content/docs/permissions/system-context.mdx` names the second `isSystem` read.\n- **`@objectstack/spec` is not added to the changeset.** Its diff is TSDoc that documents objectql's behaviour. Listing it would print objectql's BREAKING note into spec's CHANGELOG under a `patch`, and an upgrader greps objectql's entry.\n- **Gates:**\n - `check-adr-0087-registration --base origin/main` reads the changeset as `BREAKING+clause-②-narrowing` with a `not-required (no-migration-prescription)` disposition.\n - `check-changeset-no-major --base origin/main` exits 0, and its level axis, run against this body as the PR event, is reported in the os-dev report.\n - `dispatch-gates --commands` was re-derived on the final commit, run and reconciled with `--ran`. The figures are in the os-dev report on the card.\n- No code or test changed in this round.\n\n---\n_Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_\n" } }objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 22306, "status": "done", "round": "merge round (census skew), after re-review PASS 6074675794", "branch": "claude/issue-22306-insert-readonly-before-hooks", "pr": "https://github.com/objectstack-ai/objectstack/pull/22413", "head": "76bd4fd399", "session": "session_01EUBvqtauTDmHi2ZgY759p2 (the dispatching PM's session, shared; this run's identity is the branch)", "premise_still_valid": true, "summary": "Merge round, from 1c13a9930b. Recreated the worktree from the pushed branch, fetched origin/main (ca135dcc40) and merged it, giving 2008591e47 with no text conflict. The os-regen driver deferred content/docs/permissions/system-context.mdx, and the merged page read 119. The flag was confirmed: check:system-context-census exited 1 on the merge commit with 6 declared-count mismatches (119 vs 120: headline-sites, sharing-total, table-reads 125 vs 126, table-elevation-reads, table-behaviour-bearing 116 vs 117, ruling-sites). pnpm gen:system-context-census regenerated the counts, and the gate then exited 0: 120 sites over 138 anchors, citing 115 of 115 required symbols. The fix was committed as 76bd4fd399; pre-commit reported the deferred artifact current and cleared the os-regen marker. Both sides survived: against ca135dcc40 the page differs only by the regenerated counts and this branch's row-21 clause. No code, test or changeset edit. Pushed; the remote head is 76bd4fd399. pr_body_full.body is the patch-round-1 body plus a '## Merge round' section before the footer, for the seat to write.", "tests": "At 76bd4fd399, under the verify lock, after rebuilding objectql and its dependency closure on the merged tree (14 tasks): @objectstack/objectql --project local 390 files, 7676 passed; --project repo 1 file, 5 passed; typecheck (tsc --noEmit plus check:test-typecheck) exit 0 with debt held; pin files src/engine-insert-readonly-hook-input.test.ts and src/hook-withheld-readonly-fault.test.ts, 2 files, 24 passed. Gate prerequisites were rebuilt for the merged tree (71 tasks, 14 cached). node scripts/pm/dispatch-gates.mjs --commands at 76bd4fd399 derived 113 commands, the same list as the previous rounds, and all 113 ran and exited 0, including check:system-context-census. dispatch-gates --ran with exit codes: 113 derived, 113 run, 0 NOT-MEASURED (a derived zero).", "mcp_calls": "0", "api_writes": "1 this round, through the fleet-write relay as objectstack-fleet[bot]: this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/22306/comments. Also git push of 2008591e47 and 76bd4fd399 (not REST). No PR body, label or assignee write.", "open_questions": [], "out_of_scope_findings": [], "deviations": [ "None this round. The PR body on GitHub is still the round-0 text; the corrected body (patch round 1 plus the merge round) is pr_body_full.body, for the seat to write." ], "gates": { "derived_at": "76bd4fd399", "derived": 113, "ran": 113, "exit_0": 113, "not_measured": 0, "unrun": 0, "reconcile": "dispatch-gates --ran: 113 derived, 113 run, 0 NOT-MEASURED (derived zero; every line carries its exit code)", "census": "check:system-context-census exit 1 on the merge commit 2008591e47 (119 vs 120), exit 0 at 76bd4fd399 (120)" }, "line_budget": "709 insertions, 25 deletions over 9 files (merge base ca135dcc40 to 76bd4fd399), under the 5000-line human-merge threshold. No governed surface touched.", "files_changed": [ ".changeset/22306-insert-readonly-withheld-from-before-insert.md", "content/docs/automation/hooks.mdx", "content/docs/permissions/system-context.mdx", "content/docs/protocol/objectql/security.mdx", "packages/objectql/src/engine-insert-readonly-hook-input.test.ts", "packages/objectql/src/engine.ts", "packages/objectql/src/hook-withheld-readonly-fault.test.ts", "packages/objectql/src/hook-withheld-readonly-fault.ts", "packages/spec/src/data/hook.zod.ts" ], "files_changed_this_round": [ "content/docs/permissions/system-context.mdx (regenerated), plus the merge of origin/main ca135dcc40" ], "cross_lane_paths": [ "content/docs/protocol/objectql/security.mdx", "content/docs/permissions/system-context.mdx", "content/docs/automation/hooks.mdx", "packages/spec/src/data/hook.zod.ts" ], "cleanup": "The worktree was recreated from the pushed branch and removed after the push (node_modules first, no --force). Remote head 76bd4fd399. No background process left running.", "pr_body_full": { "body": "Fixes #22306\nClause-②: no (narrowing)\n\n## What was wrong\n\nOn a non-system create, `ObjectQL.insert` dispatched `beforeInsert` on the caller's payload. It ran the static `readonly` strip (`staticReadonlyCreateStrip`) only after the hooks. Take a hook that stamps a read-only column only when the column is absent, the hotcrm `if (!input.stage_entry_date)` shape. It saw the caller's value and did nothing, then the strip dropped that value, so the row stored NULL where the hook would have stamped today. The update path has withheld these values from its hooks since #16344. The insert path did not.\n\n## Base and head\n\nThe same object and hook as hotcrm: `stage_entry_date` (date, `readonly`), `days_in_stage` (number, `readonly`), and a `beforeInsert` hook that stamps both when `stage_entry_date` is absent.\n\n| case | base: hook saw `stage_entry_date` | base: stored | head: hook saw | head: stored |\n|---|---|---|---|---|\n| caller sends the readonly key (non-system) | `'2020-01-01'` | NULL / NULL | absent | `'2026-10-09'` / 0 |\n| key absent (non-system) | absent | `'2026-10-09'` / 0 | absent | `'2026-10-09'` / 0 |\n| system writer (`isSystem`) sends the key | `'2020-01-01'` | `'2020-01-01'` / NULL | `'2020-01-01'` | `'2020-01-01'` / NULL |\n| a hook stamps the key unconditionally, and the caller sent it | `'2020-01-01'` | `'2026-10-09'` | absent | `'2026-10-09'` |\n\nHow each row was measured:\n\n- **Rows 1 to 3, real engine over `driver-sql` (better-sqlite3):** base is `c8c803c293` with objectql built from that commit. Head is this branch.\n- **Row 1, REST door:** `POST /api/v1/data/rro_opp` through `bootStack` with a sandboxed `body` hook. The base call answered 201 and stored NULL / NULL. The head call answered 201 and stored `'2026-10-09'` / 0.\n - The caller was the seeded admin, a non-system principal. The strip ran on the base call, which shows this: the forged value was not stored.\n - A freshly signed-up user had no create permission on the fixture object (403), so that user could not be used.\n- **Row 4:** the engine over a recording driver, at head and with the hide ablated. The stored value is unchanged.\n- **Batch:** the same on both paths. At base, row 0 of a batch (forged) stored NULL and row 1 stored the stamp. At head, both rows store the stamp.\n\n## The change (`packages/objectql/src/engine.ts`)\n\n- **Withhold, not strip, as on update.** Before the defaults and before `beforeInsert`, `withholdInsertReadonlyFromHooks` takes out every value that the post-hook static strip will take. It uses the same function (`stripReadonlyFields`), the same subject (`staticReadonlyInsertSubject`) and the same options (no `preserveAudit`), so the hide and the strip take the same caller keys. There is one named exception. Under `preserveAudit` the hide leaves the audit timeline visible, while the strip takes it unless the audit binder reinstates it. On a bare engine without `ObjectQLPlugin`, or for `created_by` / `updated_by` with no session user, such a value is shown to the hook and then stripped. The defaults then fill a withheld key the way they fill any absent key. So a forged `approval_status: 'approved'` is shown to the hook as its default `'draft'`, exactly what an honest caller's hook sees.\n- **The strip does not move.** After the seal, `handBackWithheldInsertReadonly` puts the caller's values back wherever no hook wrote the key. The strip, its re-default, the WARN, `onFieldsDropped` and `strictReadonlyWrites` then judge the same payload as before.\n - A hook's own write is kept, and is never overwritten by a hand-back.\n - A `data.x = data.x` self-assignment of a withheld key follows the engine's rule:\n - with no `defaultValue` the hook reads `undefined`, the write is undone as on update, and the caller's value is stripped and reported. `undefined` is never stored.\n - with a `defaultValue` the hook re-assigns the default, which counts as the hook's write. The default is stored and nothing is reported.\n- **Faults are explained.** The dispatch goes through `dispatchHooksExplainingWithheldReadonly`, the update path's wrapper. A hook that faults reaching through a withheld key gets a 400 that names the key.\n - `hook-withheld-readonly-fault.ts` gets a `beforeInsert` prescription, because a create has no `ctx.previous`.\n - The update message is byte-identical.\n\nWhat update withholds (M2), measured on `origin/main`:\n\n- **The set:** `stripReadonlyFields` over the full object schema. That covers static `readonly` and runtime-owned `autonumber`, but not `readonlyWhen`.\n- **Exemptions:** `isSystem` (the whole pass) and the `preserveAudit` whitelist. A key the caller did not send (own property of the entry snapshot) is never a candidate, and `id` is excluded.\n- **After the hooks:** seal, then hand back what the payload does not hold, undo a set-to-undefined, and narrow `hookWrittenKeys`. The caller's submission travels on `ctx.submitted`.\n\nThe insert set differs from that in three measured places:\n\n- **Runtime-owned `autonumber` stays visible.** I first included it, and three existing pins went red. One is \"the hook can still SEE the caller-submitted record number\" (`engine-insert-runtime-owned-strip.test.ts`, #6339). The other two are the insert half of #14259's provenance seam (`engine-hook-provenance-sibling-seams.test.ts`). Moving that decision is not this card's, so the hide covers the static strip only.\n- **The audit timeline stays visible under `preserveAudit`.** On a create the static strip takes `created_at`, `created_by`, `updated_at` and `updated_by` whatever the flag says. But the historical-import channel that the #15964 ruling kept working reinstates them through the audit binder's `preserveAudit` branch (`record.created_at ?? now`). Withholding them would erase every historical `created_at`. A business `readonly` column under `preserveAudit` is still withheld.\n- **`sys_` and platform-internal (`managedBy`) objects keep their own guards.** The create strip's subject excludes them already, so nothing is withheld there.\n\nThere is no `ctx.submitted` on a create. The one in-repo `beforeInsert` reader that needs a caller's read-only value is the audit binder's `preserveAudit` branch, and it keeps seeing it.\n\nEntry points (M3): `ObjectQL.insert` is the only `beforeInsert` dispatch site in the repo. Single insert, batch insert and `insertMany` all go through it. The engine has no upsert of its own. The protocol batch `upsert` create arm, REST `POST`, import and flows `create_record` all call `engine.insert`. Seeds write with `SEED_WRITE_EXECUTION_CONTEXT` (`isSystem: true`), so nothing is withheld for them.\n\n## Docs touched\n\n- `packages/spec/src/data/hook.zod.ts`: TSDoc only. Two sentences said `beforeInsert` \"is untouched\" and \"still receives the caller's own values\". The `submitted` describe (\"update only\") stays true and is unchanged.\n- `content/docs/protocol/objectql/security.mdx`: rule 5 and the #16344 callout now cover insert.\n- `content/docs/permissions/system-context.mdx`: regenerated by `pnpm gen:system-context-census`, 118 to 119 elevation read sites. The +1 is the hide pass's non-system gate.\n\n## Tests and gates\n\nHead is `aa115f1c60`. The suites ran at `975f4b6690`; the only commit since then is `aa115f1c60`, which regenerates the census page and changes no code.\n\n- **`@objectstack/objectql`:**\n - `--project local`: 390 files, 7676 tests passed;\n - `--project repo`: 1 file, 5 tests passed;\n - `typecheck` (tsc plus `check:test-typecheck`): exit 0.\n- **`@objectstack/rest`:**\n - `--project local`: 264 files, 4954 passed, 326 skipped;\n - `--project repo`: 5 files, 191 passed, 1 skipped.\n- **Dogfood shards** (`OS_TEST_SHARD=k/3`):\n - 1/3: 77 files, 569 passed;\n - 2/3: 77 files, 545 passed, 1 skipped;\n - 3/3: 76 files passed, 1 skipped; 679 tests passed, 8 skipped.\n- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` at `aa115f1c60` derived 113 commands. All 113 ran and exited 0, and `--ran` reconciles them as 113 run, 0 NOT-MEASURED.\n\n- `engine-insert-readonly-hook-input.test.ts` (new, 13 cases):\n - the ordering probe with a writable-key control;\n - the hotcrm shape;\n - a default shown in place of a forgery;\n - controls for `autonumber`, the system writer, strip reporting and the strict refusal;\n - a self-assignment no-op;\n - batch and `insertMany`;\n - `preserveAudit`;\n - the explained fault;\n - an update control.\n- `hook-withheld-readonly-fault.test.ts`: one `beforeInsert` case.\n- **Reverse verification:** `node scripts/ablation-replace.mjs`, anchor `hookViewSource[i] = hidden.row;` replaced with `void hidden.row;`. Result: 9 failed and 4 passed of 13. The four that stayed green are the controls that do not depend on the hide. The restore was verified: blob equals HEAD and `git diff HEAD` is empty.\n\n## Acceptance notes\n\n- **Insert and update still differ on `autonumber`.** `beforeUpdate` does not see a caller-supplied `autonumber`, and `beforeInsert` still does (#6339's pin). A hook that issues its own record number only when the key is absent stands down on a forged one, and the sequence fills it. Not measured through a public door, and nobody has picked this up.\n- **Changeset level.** Settled by the contract-tier review: `@objectstack/objectql` `minor` with `Clause-②: no (narrowing)`, a BREAKING note and an ADR-0087 disposition, as the update-side twin (#16344) was. See Patch round 1.\n\n## Patch round 1\n\nThis round changes text only, after the contract-tier review graded the narrowing BREAKING (FAIL on the semver level only; the engine change passed).\n\n- **The changeset** `.changeset/22306-insert-readonly-withheld-from-before-insert.md`:\n - is now `'@objectstack/objectql': minor` with `Clause-②: no (narrowing)`. That is the launch-window spelling of a breaking change, and pre mode stays on (`.changeset/pre.json`, tag `next`).\n - carries a BREAKING paragraph. It names what a `beforeInsert` handler reads on `ctx.input.data` on a non-system create, and the withheld set: `staticReadonlyInsertSubject`'s, minus the `autonumber` and `preserveAudit` audit-timeline edges.\n - names two answers that change at the REST door. Under `strictReadonlyWrites`, a self-assigning hook's launder is now refused (201 to 400). A hook or body that dereferences through a withheld key answers 400 where it answered 201.\n - carries one ADR-0087 disposition marker, `not-required (no-migration-prescription)`, in the comment form the gate reads.\n- **Two sentences corrected:** the self-assignment sentence now states the engine's rule for a withheld key with a `defaultValue`, and \"the two cannot disagree\" now names the `preserveAudit` shown-but-stripped edge. Both corrections are in the changeset and in this body.\n- **The two docs lines the review named as owed:** `content/docs/automation/hooks.mdx` extends the persist-image sentence to `beforeInsert`, and census row 21 in `content/docs/permissions/system-context.mdx` names the second `isSystem` read.\n- **`@objectstack/spec` is not added to the changeset.** Its diff is TSDoc that documents objectql's behaviour. Listing it would print objectql's BREAKING note into spec's CHANGELOG under a `patch`, and an upgrader greps objectql's entry.\n- **Gates:**\n - `check-adr-0087-registration --base origin/main` reads the changeset as `BREAKING+clause-②-narrowing` with a `not-required (no-migration-prescription)` disposition.\n - `check-changeset-no-major --base origin/main` exits 0, and its level axis, run against this body as the PR event, is reported in the os-dev report.\n - `dispatch-gates --commands` was re-derived on the final commit, run and reconciled with `--ran`. The figures are in the os-dev report on the card.\n- No code or test changed in this round.\n\n## Merge round\n\nThis round merges `origin/main` `ca135dcc40` into the branch (merge commit `2008591e47`) and regenerates one page in `76bd4fd399`. Head is `76bd4fd399`.\n\n- **Census skew.** `main`'s `ca135dcc40` and this branch each took `content/docs/permissions/system-context.mdx` from 118 to 119, for different read sites.\n - The merge driver deferred the page (os-regen), so the merged page read 119 while the merged tree holds 120 sites. `check:system-context-census` was red on the merge commit with 6 declared-count mismatches (119 vs 120).\n - `pnpm gen:system-context-census` rewrote the counts, never by hand: 119 to 120 sites, 125 to 126 property reads, 116 to 117 behaviour-bearing.\n - The gate now exits 0 at 120 sites. Both sides' rows survive: against `main`, the page differs only by the counts and this branch's row-21 clause.\n- **Readings at `76bd4fd399`:**\n - `@objectstack/objectql` `--project local`: 390 files, 7676 passed;\n - `--project repo`: 1 file, 5 passed;\n - `typecheck` (tsc plus `check:test-typecheck`): exit 0;\n - the pin files `engine-insert-readonly-hook-input.test.ts` and `hook-withheld-readonly-fault.test.ts`: 2 files, 24 passed.\n - `dispatch-gates --commands` derived the same 113 commands. All 113 ran and exited 0, and `--ran` reconciles them as 113 run, 0 NOT-MEASURED.\n- **Nothing else changed:** no code, test or changeset edit.\n\n---\n_Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_\n" } }objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsACCEPT (seat review): PR #22413 at head
76bd4fd399domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant), claim 6072391543 · read at 2026-10-09T05:52Z.- Dev reports:
os-dev-report6074105698 (build), 6074595303 (patch round 1) and 6075133431 (merge round). - Contract reviews at
CONTRACT_REVIEW_TIER:- 6074329758: FAIL on
aa115f1c60, on ② only. The changeset waspatchwith a bareClause-②: no, for a declared narrowing. - 6074675794: PASS on
1c13a9930b.
- 6074329758: FAIL on
- Since the PASS: a merge of
origin/mainca135dcc40and a regenerated census page.- Measured by the seat: for 8 of the PR's 9 files, the net diff against the new merge base is byte-identical to the reviewed head's against its own base. Only the generated
content/docs/permissions/system-context.mdxdiffers. - The review flagged that this skew had to be fixed (119 on each side, 120 merged), and the merge round fixed it with
pnpm gen:system-context-census. Its gate is green at 120.
- Measured by the seat: for 8 of the PR's 9 files, the net diff against the new merge base is byte-identical to the reviewed head's against its own base. Only the generated
Shape
- Draft, base
main. Line 1Fixes #22306, line 2Clause-②: no (narrowing), one closing keyword. - The seat wrote the body for each round.
- 9 files, +709 / −25.
The change (#16344's rule on insert; triage 6062616189)
withholdInsertReadonlyFromHooksruns before the defaults and beforebeforeInsert. It uses the same function, subject and options as the post-hook strip, so the hide and the strip take the same caller keys. The one named exception is thepreserveAuditaudit timeline.handBackWithheldInsertReadonlyrestores the caller's values after the seal, so the strip, the WARN,onFieldsDroppedandstrictReadonlyWritesjudge the same payload as before.- A hook's own write always wins.
- Faults are explained through the update path's wrapper.
Measured (base
c8c803c293against head)- The hotcrm shape, a caller-supplied readonly
stage_entry_date, stored NULL at base. At head the hook stamps, and the stamp is stored. This holds on the engine overdriver-sqland throughPOST /api/v1/data. - An absent key, a system writer, and an unconditional hook stamp are unchanged. Batch and
insertManyfollow the same rule. - Ablation: 9 of 13 pins go red, and the 4 that stay green are the hide-independent controls.
② Level, settled by the review:
@objectstack/objectqlminorwith the BREAKING banner, which is the launch-window spelling in pre mode.Clause-②: no (narrowing)appears in the changeset and on the body's line 2.- One ADR-0087 marker:
not-required (no-migration-prescription). - The BREAKING paragraph names what
beforeInsertreads, and two door flips: astrictReadonlyWritesself-assign launder, and a dereference through a withheld key. Both go from 201 to 400. @objectstack/spec(TSDoc only) is not named. The review judged that acceptable.
CI on
76bd4fd399, by name: allsuccess:TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal Conformance (live PG + MySQL),Lint & Repo Gates,Governed Surface Queue Guard. 38 success, 4 skipped, 0 failure;check-expected-skipsOK. NOT governed, 734 changed lines;git merge-treeagainstorigin/main27a8b33decclean.Cross-lane, declared, no objection on record:
domain:spec[PM seat] domain:spec — 🟢 os-tesla · session_01VZqqwTj2wsihZEbfT6yyYN #6017 (6074135251):hook.zod.ts, TSDoc.domain:devx[PM seat] domain:devx @ objectstack — 🟢 marchtian · session_01Q7Fy4uVkvBWgj9CLihdATJ #6023 (6074140838, amended 6074620654):security.mdx,system-context.mdxandhooks.mdx.
Out of scope, one line each
- Insert and update differ on
autonumber: insert still shows a caller-supplied one, per insert 路径 stripRuntimeOwnedFields 同样删的是 hook 覆写后的当前值 —— beforeInsert hook 写入的 autonumber 在调用方也提交了同名键时被连坐删除(与其自身文档矛盾) #6339's pin. Recorded in the PR; nobody carries it.
Next
- Ready, then auto-merge after the ready-flip checks settle.
- For hotcrm (Replace the hand-built hook / flow / action harnesses with
@objectstack/verify's in-process handle; delete the stand-ins and the suites that only prove the stand-ins; declare the platform packages tests import (epic #1579, step 5) hotcrm#1595, which waits on this fix by its rule): a hook that stamps a readonly column only when it is absent now stamps it, even when the caller sent a value.
Generated by Claude Code
- Dev reports:
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsLanding record: PR #22413 merged · 2026-10-09T06:33Z
domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant), claim 6072391543.- Merged: PR fix(objectql): withhold caller-supplied readonly values from beforeInsert (#22306) #22413 through the merge queue at 2026-10-09T06:31Z. Squash
f05649f6aaonorigin/main, single parent. - Content, read after a fetch:
withholdInsertReadonlyFromHooksinpackages/objectql/src/engine.tshas 4 hits, against 0 on the parent. The controlstaticReadonlyCreateStriphas 7 hits. The pin fileengine-insert-readonly-hook-input.test.tsand.changeset/22306-insert-readonly-withheld-from-before-insert.mdare present. - Review: ACCEPT 6075184722. Contract reviews: 6074329758 FAIL (② level), then 6074675794 PASS.
- Labels: the card closed
completed, andpm:dispatchedis removed in this act. Only this card was named by a closing keyword. - For hotcrm (Replace the hand-built hook / flow / action harnesses with
@objectstack/verify's in-process handle; delete the stand-ins and the suites that only prove the stand-ins; declare the platform packages tests import (epic #1579, step 5) hotcrm#1595, which waits on this fix by its rule): abeforeInserthook that stamps a readonly column only when it is absent, thestage_entry_dateshape, now stamps it even when the caller sent a value. The stamp is stored. This ships as@objectstack/objectqlminorwith a BREAKING note on whatbeforeInsertreads.
Generated by Claude Code
- Merged: PR fix(objectql): withhold caller-supplied readonly values from beforeInsert (#22306) #22413 through the merge queue at 2026-10-09T06:31Z. Squash
Filing gate: ① product defect with reach measured. Class (a). reach: a public REST door, measured once with a wrong result.
Found through objectstack-ai/hotcrm on
@objectstack/*17.7.0 by the dev of objectstack-ai/hotcrm#1595 (PR objectstack-ai/hotcrm#2013), sessionsession_012zh91QzFgePbkmuHnugLN3. Split out of #22300 on the maintainer's word: one card per defect.Who acts on it: the objectstack triage seat routes it; the fix lands in
packages/objectql/src/engine.ts. ⛔ Not a claim. hotcrm WAITs for it (hotcrm AGENTS.md §2) and builds no workaround.Insert shows
beforeInserthooks caller-supplied readonly keys, then strips themPOST /api/v1/data/crm_opportunity {…, stage_entry_date: '2020-01-01'}→ 201, storedstage_entry_dateNULL anddays_in_stageNULL. With the key absent or null, the hook stamps today and 0.beforeInsertstands down on a present value (src/sales/objects/opportunity.hook.ts:352if (!input.stage_entry_date)). The engine then drops the readonly value after the hook has seen it.engine.ts:13130-13156) and the static readonly strip runs afterwards (staticReadonlyCreateStrip,:12437, called at:13398-13401).readonlyHiddenFromHooks,:14311-14385) and hands them back at:14789-14850.:1992), is written as general but implemented on update only. This is its insert-side twin.beforeInsert, as on update.Duplicate check
gh searchis refused in this container (GraphQL and REST search answer 403), so all 9,565 objectstack issues were listed (/issues?state=all) and matched case-insensitively:beforeInsert readonly hooks withheld1;readonly before hooks insert15;Insert-side readonly5. Related, closed: #16344 (the update side). None is this defect.Generated by Claude Code