Repository navigation
fix(objectql)!: a caller-supplied formula value is stripped on every write path and reported as computed, and engine.validate runs the write doors (#20805) - #20834
Conversation
…ath and run the write doors in validate Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…d validate's doors, on memory and SQLite Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…rmula-write-strip
…drop reasons Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…ame the computed strip's exemption from it Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…rmula-write-strip
📓 Docs Drift CheckThis PR changes 3 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 26ac685b88680bf880090cbebe057513fa215d1a && git checkout 26ac685b88680bf880090cbebe057513fa215d1a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4b45afaed5e2a1afdbc8c4fd36005dce210baa4e b21d13b05b35ebae2e9cf60725a081ae5349e56c && git checkout -B drift-repro 4b45afaed5e2a1afdbc8c4fd36005dce210baa4e && git merge --no-ff b21d13b05b35ebae2e9cf60725a081ae5349e56c
node scripts/docs-audit/affected-docs.mjs --json 4b45afaed5e2a1afdbc8c4fd36005dce210baa4e
|
Contract reviewServed-tier: Inputs read: card #20805 (body, claim 5906928968, os-dev-report 5908848440); #20701 (body, triage 5897601448, claim 5905999986, dev report 5906449929, seat review 5906485725, the split ruling 5906762084); the cross-seat declaration 5906954118 on #18549; PR #20834 (body, 14-file list, the net diff against ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged against the claim's surface and stop lines:
Review faces, each factual sentence judged: the changeset — true throughout; the PR body — true, except one sentence named in ③ (PostgreSQL); ② Semver level
③ Boundary flagsDev flags, each answered:
Check-runs on the head as read for this record (2026-09-30T10:00Z): 32 runs — 15 success (among them Implemented-by: VERDICT: PASS Generated by Claude Code |
…ommits that decided them (objectstack-ai#20836) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the fifteenth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/services/service-settings/src/**` and nothing else. By the seat's claim (`5908460751`), it is the largest package left in the lane. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 14 (the latest is PR objectstack-ai#20816, landed as `73155fedc`). That is **11 sites on 11 lines in 9 files, covering 4 numbers**: - 4 census sites (every census site this package has at the base); - 7 sites in test comments, which the census defers. One of their numbers, `objectstack-ai#11318`, stands only in test files here; it was read on its own and answers 404. Each rewritten line now cites the commit in this repository that decided what the line describes, and says in its own words what was decided: **4 distinct commit shas**. No ADR records any of the four decisions (see the per-number table), so ruling C's commit rung applies. No number was dropped. Only comments changed. Every touched source file keeps its line count (11 lines out, 11 in, over 9 files), so no line citation into these files moves. All 11 changed lines carried a dead citation. No code token moves (see the guard below). **No citation number is added.** The only tracker number on an added line is the live `objectstack-ai#10251`, once, in `settings-prebind-read-warning.test.ts:17`. It already stood on that line, and it now sits beside the sha as the convenience link ruling C allows: 「(commit 1ec36b7, PR objectstack-ai#10251)」. `1ec36b730` is that pull request's squash commit. 2 dead sites are left on purpose: a test title and a test assertion message (see the list below). One more file: a `patch` changeset for `@objectstack/service-settings`, because the rewritten prose ships (see Changeset below). ## Census: `service-settings`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/services/service-settings/`. Each run counts as a reading only because its board frontier equals the newest issue or pull-request number, read by a separate request just before and just after the run. | reading | tree | board | whole-repo `allocated-but-absent` | service-settings sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `73155fedc`, run 2026-09-30T09:39:38Z to 09:43:17Z | enumerated, 187 pages, frontier objectstack-ai#20830 (newest objectstack-ai#20830 before and after) | 752 | **4** | 4 | 4 | 3 | | after | head `ac05607d6`, run 10:02:17Z to 10:06:00Z | enumerated, 187 pages, frontier objectstack-ai#20834 (newest objectstack-ai#20834 before and after) | 748 | **0** | 0 | 0 | 0 | The whole-repo drop is 4, exactly this diff's census sites. The `resolves` tally is 33,134 in both runs, and `resolves-as-pull-request` (1,985) and `cross-repo-unjudged` (1,018) did not move either. Neither run was truncated or discarded: both enumerations read 187 pages at the newest frontier. The seat's census counted 4 here at `6bff748b`, and the base agrees: `6bff748b` is an ancestor of the base, and no commit between them touches this package's `src`. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `namesThisRepository` over every `.ts` file under `service-settings/src` (64 files). It takes its verdicts from the before census's own board reading rather than from a second enumeration: a number is dead when that census reported it `allocated-but-absent`, and alive when the gate's own census-scope extraction judged it and the census did not report it. 10 numbers are covered by neither, because they stand only in test files, or as the second number of an `#A/#B` pair. Each was read on its own through the read-only tools. 1 answers 404 (`objectstack-ai#11318`, on the issue and the pull-request endpoint alike); 6 answer as issues; 3 answer as pull requests (`objectstack-ai#7554`, `objectstack-ai#10251`, `objectstack-ai#5133`). The probe's control: the known issue `objectstack-ai#11352` answers 404 on the pull-request endpoint. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `73155fedc` | 534 | **13** | 4 | 7 | 0 | 2 | | after, `ac05607d6` | 523 | **2** | 0 | 0 | 0 | 2 | Its src-comment column equals the census's 4, which is the control on the second instrument. The 519 live citations and 2 cross-repo citations are the same in both readings, and the drop of 11 citations is exactly the rewritten sites. A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 547 occurrences before and 536 after, the same drop of 11. The 13 tokens beyond the gate's grammar are the same before and after, and none is dead (see Acceptance notes). ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject. | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#13279` | 4/4 | 4/0 | `6a180e42d` (PR objectstack-ai#13475): `resolveAuthzContext` raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) when a permission-store read throws, instead of answering an outage as a caller with zero capabilities, and each production transport's fail-closed `catch` re-raises that brand. The settings plugin's `verifiedContextFromRequest` is one of them. Its message names `objectstack-ai#13279` 4 times and its diff 54 times; `git blame` puts `settings-service-plugin.ts:307` in it, and the other three lines were written by `ac9376a74` (PR objectstack-ai#16580), a descendant, which describes that re-raise. Stage 6's anchor, reused by the storage, datasource and analytics stages | | `objectstack-ai#10159` | 3/2 | 3/0 | `1ec36b730` (PR objectstack-ai#10251): a settings write issued before the engine is bound is refused with `SETTINGS_ENGINE_NOT_BOUND` (503). Its message states that every read in any state is unchanged, which is the "left reads open" all three lines describe. The message does not name `objectstack-ai#10159`, but its own diff does, once, in its changeset ("refused loudly instead of resolving successfully while nothing reaches `sys_setting` (objectstack-ai#10159)"), and `settings-prebind-read-warning.test.ts:17` already paired the two numbers. `git blame` puts the three lines in `a24b7fa4d` (PR objectstack-ai#11044), the later read-half fix, a descendant. New to the sweep | | `objectstack-ai#17062` | 3/2 | 2/1 | `50b6f17d4` (PR objectstack-ai#17071): adds the package-local route-ledger conformance guard beside the dogfood live-mount parity gate, and updates the ledger header that had said such a guard was deliberately omitted. Its message does not name `objectstack-ai#17062`; its diff does, on 3 added lines, which are the three sites here (`git blame` puts all three in it). New to the sweep | | `objectstack-ai#11318` | 3/1 | 2/1 | `99ccbb9c8` (PR objectstack-ai#11467): the Settings, AI "Test connection" fallback keeps its mount instruction on all three real-provider branches and gains the cloud-only boundary read from `PLATFORM_CAPABILITY_PROVIDERS.ai`. Its own changeset states that "the embedder hint at the fourth site is deliberately left alone ... and pinned by a contrast test", which is the fence `:339` describes. Its message's trailer names `objectstack-ai#11318` as the issue it answers, its diff names the number 3 times, and `git blame` puts all three lines in it. New to the sweep | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 4), and all 4 are ancestors of the base (`merge-base --is-ancestor`, exit 0 for each; reverse leg, base against each anchor, exit 1 for each; control legs exit 0: stage 1's landing `422db788a`, and the repository's root commit, which lies deeper than every anchor; the history is complete, `--is-shallow-repository` false, 15,193 commits). Each of the 4 numbers answers 404 on the issues endpoint, which serves pull requests too, read one by one. No ADR, `scripts/adr-anchors/` file or other `docs/` page records the decision of any of the 4: `docs/adr` names none of the numbers, and none of their mechanisms (`AuthzStoreUnavailableError`, `SETTINGS_ENGINE_NOT_BOUND`, `engineBindPending`, the settings route ledger, the AI hint's edition boundary). ## Wordings to check - **Tag swaps in place.** 「[objectstack-ai#13279]」 became 「[commit 6a180e4]」 (`settings-service-plugin.ts:307`); 「(objectstack-ai#13279)」 became 「(commit 6a180e4)」 on 2 lines; 「objectstack-ai#13279's permission-store re-raise」 became 「commit 6a180e4's permission-store re-raise」. These are the forms the storage and datasource stages used for the same sha. - **`objectstack-ai#10159`.** 「is why objectstack-ai#10159's fix deliberately left reads open」 became 「is why commit 1ec36b7's write refusal deliberately left reads open」; 「(objectstack-ai#10159's fix left reads open on purpose)」 became 「(commit 1ec36b7 left reads open on purpose)」; 「(objectstack-ai#10159 / PR objectstack-ai#10251)」 became 「(commit 1ec36b7, PR objectstack-ai#10251)」, with the pull-request number kept as the convenience link beside its own squash commit. - **`objectstack-ai#17062`.** 「Two layers, since objectstack-ai#17062.」 became 「Two layers, since commit 50b6f17.」 The docblock goes on to describe the conformance test that commit added as the second layer. - **Two headers keep the antecedent of the prose below them**, the form stage 14 used: - `settings-route-ledger.conformance.test.ts:4`: 「Settings route-ledger conformance (objectstack-ai#17062)」 became 「Settings route-ledger conformance (the issue behind commit 50b6f17)」, because `:25` of the same docblock says 「(per the issue)」. - `manifests/ai.manifest.test.ts:277`: 「objectstack-ai#11318 —」 became 「The card behind commit 99ccbb9:」, because `:288` 「the very claim this card is about」 and `:329` 「The un-followable form this card retired」 speak of that card. - **`ai.manifest.test.ts:339`.** 「deliberately not edited — objectstack-ai#11318 fences this site out by name」 became 「... — commit 99ccbb9 fences this site out by name」. The commit's own changeset names that site (quoted in the table). ## The 2 sites left - **Test strings, 2 sites on 2 lines**, left as stages 1 to 14 left theirs: - `manifests/ai.manifest.test.ts:292`, a `describe` title (`objectstack-ai#11318`); - `settings-route-ledger.conformance.test.ts:88`, the assertion message a failing run prints (`objectstack-ai#17062`). It is a string, not a comment, and form C does not touch strings. - No operator log string, runtime refusal, quoted maintainer ruling or generated file in this package carries a dead number. - **Outside `src`, listed and left, not edited in this stage:** - the shipping `README.md` names only the live `objectstack-ai#8026`; - `vitest.config.ts` names only live numbers (`objectstack-ai#8020`, `objectstack-ai#8030`, `objectstack-ai#8063`, `objectstack-ai#8104`, `objectstack-ai#10374`); - `tsconfig.json` and `package.json` name none; - the release-owned `CHANGELOG.md` names `objectstack-ai#13279` and `objectstack-ai#10159` on 2 lines, the entries of `6a180e4` and `1ec36b7`, which are this PR's anchors. ## Mechanical guard: no code token moves The guard compares, base `73155fedc` against head, over all 9 touched `.ts` files: - **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild` walk, so comments are trivia and JSDoc nodes are never visited). String and template literals are therefore read in full. - **Reading 2**, the full token stream in parser context (a `getChildren` walk, so punctuation and keywords are included; JSDoc nodes skipped). Results: - Real run at the final head `ac05607d6`: 9,999 base leaf tokens, **0 files with a token change** on either reading (exit 0). The first commit `ff7ef46f4` gave the same, and no `.ts` path changed after it. - Comment control in `settings-service.ts` (「deliberately left reads open.」 to 「deliberately kept reads open.」): 0 files changed, as expected (exit 0). - Positive control, a code token renamed in `settings-service-plugin.ts` (`isAuthzStoreUnavailableError(err)` to `isAuthzStoreUnavailableErrorX(err)`): DIFFER on the identifier (exit 1). - Positive control, one digit changed inside the kept test title `ai.manifest.test.ts:292` (`objectstack-ai#11318` to `objectstack-ai#11319`): DIFFER on the string literal (exit 1). Every mutation went through `scripts/ablation-replace.mjs` (wrap mode) under a shell trap that restores by absolute path, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`1248149a428d`, `a2fac9ad1f0b`, `79c2b40a48a6`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/service-settings` (`.changeset/20596-service-settings-provenance-anchors.md`) is included. Its body is stages 12 and 13's commit-anchor text, word for word, with the package name changed. Measured on the built package (A3), after a full workspace build in which this package was a cache miss (71 of 71 tasks, 0 cached, at `ff7ef46f4`, which holds every source-line change): `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. - The rewritten `settings-service.ts:685` docblock, on the pre-bind read reporter, is in all four entries: `dist/index.js`, `dist/index.cjs`, `dist/index.d.ts` and `dist/index.d.cts` (once each). - The other three rewrites (`settings-route-ledger.ts:17`, `settings-routes.ts:72`, `settings-service-plugin.ts:307`) are stripped by the bundle, and the other seven sit in test files. - Positive controls, the unchanged line beside each rewrite, land exactly where their neighbours do: the line before `settings-service.ts:685` once in each of the four entries, and the neighbours of the three stripped rewrites 0 everywhere. - A never-written negative phrase appears nowhere in `dist`, and none of the four old numbers is left there. The later commit adds only the changeset. ## Gates (final head `ac05607d6`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` exits 0 (self-test, 114 cases, 8 batteries). `node scripts/check-issue-citations.mjs` exits 0: 「no issue citations added against 73155fe (4 file(s) read)」. - **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id baseline holds, no growth). - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `ac05607d6` derived 63 commands, the same 63 as at dispatch. - Each ran with its exit code captured before any pipe, and all 63 exit 0; none exited 3. - `--ran`, fed each command with its exit code, reports 63 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. - The full `turbo run build` above ran first under the shared verify lock, so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock, at `ac05607d6`:** - `pnpm --filter @objectstack/service-settings test`: 33 files pass and 584 tests pass, which is every tracked test file under `src/`, the 5 touched ones included. - `pnpm --filter @objectstack/service-settings typecheck` (`tsc --noEmit`) exits 0, and `tsc --listFiles` puts all 9 touched files in the program. - **Lint, as a proven narrowing:** eslint with inline config disabled, over the 9 touched `.ts` files, gives 9 files, 0 errors and 0 warnings (its `--format json` output). All 9 are in eslint's own population (none reported ignored; `dist/index.js`, the control, reads ignored). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 10 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked** (objectstack-ai#20636, including the `clause #N` position). At the base, `#N-word` is on 0 lines. `#A/#B` is on 11 lines (12 second numbers), and every second number is live: `objectstack-ai#6580`, `objectstack-ai#5094`, `objectstack-ai#11230`, `objectstack-ai#5480`, `objectstack-ai#5932`, `objectstack-ai#6199` and `objectstack-ai#5204` by the census's own judgement, and `objectstack-ai#5133` read on its own as a pull request. `option #N`, `clause #N` and URL-spelled links are on 0 lines. So the claim's 0 / 11 / 0 / 0 hold, and nothing dead hides behind them. The one other raw token beyond the grammar is the colour literal `'#6366f1'` in `manifests/branding.manifest.ts:32`. - **「The card」 phrases.** 38 lines in 18 files under this package's `src` speak of 「the card」 or 「this card」. They carry no number, and neither instrument sees them. The ones whose antecedent this diff would have removed are handled above; the rest are unchanged, as in stages 8 to 14. - **The census instrument did not truncate in this stage.** Both enumerations read 187 pages at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#10159` → `1ec36b730`; `objectstack-ai#17062` → `50b6f17d4`; `objectstack-ai#11318` → `99ccbb9c8`; and the reused `objectstack-ai#13279` → `6a180e42d`. - **Base.** The branch is on `main` at `73155fedc`. `main` has since moved two commits (`4b45afaed`, `15b586dcf`). Neither touches `packages/services/service-settings`, `scripts/check-issue-citations.mjs`, `.changeset/config.json` or a path in this diff. `15b586dcf` moves `packages/spec/liveness/**`, a gate input this comment-only diff cannot interact with. No merge was taken; the merge queue rebuilds on the merged generation. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20805
Clause-②: yes (narrowing)
A caller-supplied value for a
formulafield is now stripped by the engine on every write path, in every context, and reported throughdroppedFields/onFieldsDroppedunder a newreasonarm,computed.ObjectQL.validatenow runs the write's own field doors, so a dry run built on it predicts what the write does. The REST half (the import runner's row report) stays on #20701; #20701 remains open.Reproduction first (base
5bed1f6caf, real ObjectQL +ObjectStackProtocolImplementation+RestServerroutes, SQLite:memory:and the in-memory driver)A throwaway probe (never committed) wrote to an object with
n: number,doubled: formula (record.n * 2),ro: readonly text,task_count: summary, as a non-system caller unless noted. The raw row was read from the driver, below the engine's formula hydration.b21d13b05b)protocol.createDatawithdoubled: 5SqliteErrorSQLITE_ERROR, no status, no fielddroppedFieldsnull, raw row storesdoubled: 5droppedFields: [{ fields: ['doubled'], reason: 'computed' }], raw row has nodoubledPOST /api/v1/data/:objectwithdoubled: 5400 INVALID_FIELD"Unknown field 'doubled'" (REST's driver-string branch, for a declared field)201, raw row storesdoubled: 5201, the samedroppedFields, headerdoubled;reason=computedengine.updateby id withdoubled: 99SqliteErrordoubled: 99computedevent, raw row has nodoubled, read returnsdoubled: 8fromn: 4PATCH /api/v1/data/:object/:idwithdoubled: 7400 INVALID_FIELD200, raw row storesdoubled: 7200, the samedroppedFieldsengine.insert,isSystem,doubled: 5SqliteErrordoubled: 5computedevent, raw row has nodoubledGETthenPUT)SqliteErrorcomputedfordoubledbeside the existingreadonlydropsengine.validatewithdoubled: 5valid: truevalid: truevalid: true, and the listener receives thecomputedeventengine.validatewithnope: 5valid: truewhileinsertrefused itINVALID_FIELD/ 400 /field: 'nope', the envelopeinsertthrowsengine.insert/updatewithtask_count: 42/7(summary, H4)What changed
packages/objectql/src/engine.tsstripComputedWriteFields— a new module function besideundeclaredWriteFieldErrors: takes every own key naming a declaredformulafield out of the payload (copy, never mutating the caller's object) and returns the union of what it took. Keyed ontype === 'formula'literally, the testfieldHasColumnand the read projection use.insert()— runs it right after the declared-field door, over the rows that door did not refuse, with noisSystemgate;opCtx.datacarries the stripped rows, so the snapshot, the defaults and the hooks all see the payload that is stored. The report joins the existing site (insertDrops: onecomputedevent, then the existingreadonlyevent), sostrictReadonlyWritesrefuses it by the derived coverage its contract states.update()— the same door after the pre-hook declared-field door (before the read-only hide pass and the hook recording); reported withreportDroppedFields(..., 'computed')at the confluence after the post-hook door, beside the other strips' reports.ctx.submittedstill carries the submission as sent.validate()— runs, by the same functions and in the write's order: the declared-field door (throws, asinsertdoes), the computed strip, and the caller-write strips under the write'sisSystemgate (insert mode:stripRuntimeOwnedFields, then the static-readonlystrip; update mode:stripReadonlyFields, with a suppliedidread as the address). Drops go to a new optionalonFieldsDroppedoption. The docblock's "second named limit" (a readonly reference field) is closed and rewritten.staticReadonlyCreateStrip— the create path's static-readonlystrip with its re-default, lifted out ofinsert()'s loop unchanged in behaviour sovalidatecalls the same door rather than a copy (one function per door).packages/spec/src/data/data-engine.zod.ts— thecomputedarm onDroppedFieldsEventSchema.reason, its docblock bullet and describe string.packages/spec/src/contracts/data-engine.ts— theWriteObservabilityOptionsdocblock named "all three arms"; it now names four (a comment-only edit, outside the claim's one-arm surface, because the sentence would otherwise be false).reason(H3) — the two exhaustive maps tsc re-checks:REASON_PHRASEinpackages/objectql/src/readonly-strict-errors.ts(with a remedy clause spoken only when acomputeddrop is present, so every existing message is byte-identical;computedis deliberately not inREADONLY_CLASS_REASONS) andDROPPED_REASON_LABELinpackages/services/service-automation/src/builtin/crud-nodes.ts. Reason-agnostic pass-throughs need no change: REST'sX-ObjectStack-Dropped-Fieldsecho, metadata-protocol'smergeDroppedFieldEvents, the client SDK types. objectui is NOT MEASURED (not checked out in this container).content/docs/references/**regenerated bycheck:generated --fix(the enum and describe string only); the two hand-written pages that enumerate the arms (kernel/contracts/data-engine.mdx,protocol/objectql/security.mdx);permissions/system-context.mdxgains row 21b for the preview'sisSystemgate and amends row 22 (strict still refuses a formula value underisSystem), with the census counts regenerated bygen:system-context-census..changeset/20805-formula-write-strip.md:@objectstack/specminor,@objectstack/objectqlminor with the BREAKING banner,@objectstack/service-automationpatch, one ADR-0087 disposition (not-required (no-migration-prescription)).Zone 2 hypotheses, as measured
insert()has two strip passes (stripRuntimeOwnedFields, and the static strip overstaticReadonlyInsertSubject) behind oneisSystemgate, reporting at one site (insertDropped, reasonreadonly);update()has per-branch passes (primary key,readonlyWhen, static) on by-id and multi, reporting through thereportDroppedFieldsclosure. So there are two report sites. The new strip's REPORT joins both; its STRIP sits at the declared-field door instead, because those passes are post-hook andisSystem-gated, while this one must run for system writers, must not hand hooks a value that will not be stored, and must be callable byvalidate, which runs no hooks. The function isstripComputedWriteFields.undeclaredWriteFieldErrorsjudges undeclared keys only andvalidatecalled no door. Served as one function per door (undeclaredWriteFieldErrors,stripComputedWriteFields,stripRuntimeOwnedFields,staticReadonlyCreateStrip,stripReadonlyFields), each called by the write and byvalidate, not one combined judge.Tests
packages/objectql/src/engine-formula-write-strip.test.ts(19 cases, recording driver): insert single / batch /insertMany(a refused row counts toward nothing) /isSystem; update by id / multi /isSystem; a formula also declaredreadonlyreported once ascomputed; controls (staticreadonlystillreadonly, writable fields land); the hooks' view andctx.submitted; strict refusal on both verbs;validatein both modes, underisSystem, the unknown-key envelope equal onvalidateandinsert, the caller-write strips' report, and the update-modeidaddress rule on an object whoseidis declared readonly.packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.tsgains a#20805block, one assertion run on both families (SQLite on a real table, in-memory): insert and update succeed, reportcomputed, store no such key,isSystemincluded;validatereports the same and refuses an unknown key asinsertdoes; controls. The memory arm lives in this file because it is a ruleddriver-memoryconsumer, and the census counts declarations, so no ledger row moves. PostgreSQL is not a cell here: the one live-PostgreSQL job for@objectstack/runtime(Temporal Conformance, step "Run the runtime cascade-delete matrix against live PostgreSQL") runs onlycascade-delete-multivalue-lookup-real-driver, and this file is not in it (sentence corrected by the seat after review 5909189785).VERDICT command-exit 0under the shared lock): objectqllocal344 files / 6769 tests at55c81da438(objectql unchanged tob21d13b05b); speclocal578 files / 17065 tests and service-automation 157 files / 1974 tests at06e16e23f0(both unchanged tob21d13b05b); consumer suites atc8b823cf16— metadata-protocolvalidate-data+ dropped-fields 4 files / 33, REST everyimport-*file +rest-dropped-fields+rest-batch-endpoint22 files / 529, verifyhandle.test.ts24; the runtime file 28 / 28 atb21d13b05b; typecheck (tsc + test layer) green for spec, objectql, service-automation and runtime.scripts/ablation-replace.mjsin wrap mode, restore trap armed):stripComputedWriteFieldsmade to return before stripping (anchor 1 to 0, blob2719ac6bc184to15ace97894c2), objectql rebuilt,ablation-dist-preflightfound the marker in 4 built files. Result: objectql pins 17 of 19 red (the two strip-independent cases — unknown key, nothing to strip — stayed green), runtime 4 of 28 red (both families' write andvalidatecells; the controls and every pre-existing case green). Restore: blob equal to HEAD,git diff HEADempty, rebuilt, marker absent from all 14 built files, whole tree clean, pins 19 / 19 and 28 / 28.computedfromDROPPED_REASON_LABELmadeservice-automationtsc exit 2 withTS2741: Property 'computed' is missing— it reads the rebuilt spec declarations; restored byte-identical.b21d13b05b:node scripts/pm/dispatch-gates.mjs --commandsderived 116; all 116 run, each exit captured before any pipe, all exit 0;--ranreconciliation: 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN. Three that first answeredPREREQUISITE NOT MET(check:skill-examples,check:dual-build-cjs-loads,check:type-check-debt) were re-run green afterturbo run build --filter='./packages/*' --filter='./packages/*/*';check:system-context-censusfirst reddened on the newisSystemread and is green with row 21b.eslint --no-inline-config --format jsonover the 7 TS files this diff touches reports 7 files, 0 errors, 0 warnings;--print-configshows noparserOptions.project/projectServiceon any of them (the config enables no type-aware linting), so the diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.Acceptance notes
summary(H4): a caller-supplied roll-up value is stored as sent and overwritten by the next child write, on both drivers, with no drop. That is the contractRUNTIME_OWNED_FIELD_TYPESand its docblock inrule-validator.tsrecord on purpose; not changed here.opCtx.databefore the engine body, so a permission set that marks aformulafield not editable would 403 a round trip before the strip runs — the same order areadonlyfield already has. Read, not measured.before*hook that writes a formula key itself passes the post-hook declared-field door (the field is declared) and still reaches the driver. Read, not measured; no producer found.droppedFieldsdescribe strings inapi/batch.zod.ts/api/protocol.zod.tsname only the read-only strips (already incomplete sinceprimary_key); left as they are, outside this surface.Generated by Claude Code