fix(objectql): name the withheld read-only key when a hook faults reaching through it - #17327
Conversation
…ching through it Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook reaching THROUGH such a key dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write. The refusal is correct and is not touched here. The diagnostic was the defect. Measured at both doors before this change: direct SandboxError: hook 'guard_task_body' threw: TypeError: cannot set property 'who' of undefined REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} The REST reading is the worse of the two and it is the door an author authors against: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so nothing reached the author -- not the key, not the reason, not the remedy. The engine is the only actor that can tell "the platform took this away" from "nobody sent it": the sandbox face is a plain JSON snapshot, so a withheld key is simply absent there. So the explanation is composed at the hide pass, and all three `beforeUpdate` dispatch sites inside the hide window share one wrapper. Nothing is marshalled onto the sandbox face. The composed error names the withheld key, says the platform withheld it because the field is `readonly: true`, points at `ctx.previous`, carries the original fault text, and declares HTTP 400 -- which is what carries it past `mapDataError`'s sanitiser instead of into a blank 500. No error code is registered and no published payload gains a key. Three declines keep it from repeating the card's own defect in the opposite direction: nothing withheld, an authored refusal, and a non-object throw all rethrow the original error untouched. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 17 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 579766ba6067e1dc2d6218d594bc6203b2549253 && git checkout 579766ba6067e1dc2d6218d594bc6203b2549253
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cca1dc0bfb1e380442670843606d6a0c7762f6ba 94193545fd0d378ccca1ceddc9038f76c12cadbb && git checkout -B drift-repro cca1dc0bfb1e380442670843606d6a0c7762f6ba && git merge --no-ff 94193545fd0d378ccca1ceddc9038f76c12cadbb
node scripts/docs-audit/affected-docs.mjs --json cca1dc0bfb1e380442670843606d6a0c7762f6ba
|
…it through `super()`
`pnpm --filter @objectstack/objectql typecheck` exited 2 on the previous
commit:
src/hook-withheld-readonly-fault.ts(140,20): error TS2554: Expected 0-1 arguments, but got 2.
src/hook-withheld-readonly-fault.ts(140,31): error TS2304: Cannot find name 'ErrorOptions'.
This repo compiles against `lib: ES2020`, where `Error` has neither a `cause`
member nor the `ErrorOptions` constructor overload that carries one. The
precedent is in this same package: `duplicate-record-error.ts` declares
`readonly cause: unknown` on the class and assigns it by hand, and its comment
already records why -- an undeclared assignment would be invisible to every
TypeScript consumer of the field. Followed verbatim rather than reinvented.
No behaviour change: the same original error is attached under the same name,
and both suites plus the reverse verification were re-run against a forced
rebuild after the edit.
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-authored-by: Claude <noreply@anthropic.com>
Contract review — in-seat clause-② review at default tier · VERDICT: PASS
1. ⛔ This seat's dispatch order was WRONG about the lane, and the round caught itMy order named ⭐ That is the correct resolution and it is the opposite of the failure mode: an implementer that had simply followed my order would have put production code in another seat's lane on my say-so. A dispatch order is not a licence to cross a lane boundary triage already drew. 2. The landing site was measured, not assumed — and the measurement moved itThe order required the site be reported before widening. The reported reason it is not the sandbox: that face is a plain JSON snapshot ( Verified in the diff: 3. ⭐ The card UNDERSTATED the defect, and the round measured the worse caseThe card quotes the direct door's ⛔ Not taken on report: 4. Published surface — unchanged, and
|
| reading | result |
|---|---|
src/index.ts re-exports the new module |
NO |
control — export … from './…' lines in index.ts |
38 (the grep fires) |
export * from barrels in index.ts that could sweep it in |
0 |
precedent — hook-write-provenance (#14088, same shape) in index.ts |
0, same treatment |
packages/spec diff |
0 lines ⇒ no ledger code registered |
⇒ The three exports are package-private. Clause-②: no holds, and the patch is not borrowing an export rung. check-clause2-carriers --pair 17327 exit 0, and it reports no widening tell in the diff.
⭐ The module says so itself rather than leaving it to a reviewer: its header states ⛔ it registers no error code, because a ledger entry "is a published member" that would flip clause ② to yes, and re-declaring that is not the implementer's. It also records why ERR_READONLY_FIELD_REJECTED was refused for reuse — that code names the strictReadonlyWrites refusal and carries drops in its contract, so borrowing it would make the error lie about which refusal happened.
5. Grade patch — correct
An envelope on an existing refusal: the write was already refused before this change; what moves is the message and the status. No published export moves, no ledger member is added, packages/spec is untouched. ⇒ ⛔ Not minor.
readonly status = 400 replaces a 500 at the REST door. That is a status change on a published API — but it neither widens the accept set nor enlarges the published surface, and the module argues 400 is the truthful status because the trigger is a value the caller sent. Clause ② is not touched.
6. Rulings 1 and 2 held
⛔ The refusal is untouched and re-pinned in every case; ⛔ nothing was marshalled onto the sandbox face. ⭐ And the sandbox face was refused on the measurement, not merely obeyed: composing there is impossible, which is a stronger reason than the order's.
7. The evidence shape — the ablation is the strongest this lane has seen tonight
- Run from the committed state, in the real direction, with the direction predicted before running.
- On-disk proof before the run: marker occurrences 4 → 0, blob
efa1a3ba88→e54df038b6— a non-empty HEAD blob hash compared, ⛔ not an exit code. - ⭐ DIST proof, which the order did not ask for: the runtime suite resolves
@objectstack/objectqlthroughexportstodist/, notsrc/, so a source-only ablation would have proved nothing about it. The round rebuilt and confirmed absence from all 14 built files. An ablation that does not reach the artefact under test is a no-op wearing an ablation's clothes. - The 3 failures are exactly the 3 dispatch-site cases; the 3 controls stayed green (they assert pass-through, i.e. pre-fix behaviour), and so did the composer's unit suite in isolation.
- Restore proven by whole-tree
git status --porcelainEMPTY and blob equality and marker count back to 4 and a rebuild.
8. ⭐ The round declared its own process failure plainly, and that is worth more than a clean-looking report
It opened the PR before running the package typecheck; objectql#typecheck exited 2 on ErrorOptions / a two-argument super() under lib:ES2020. ⛔ The fix was not a cast and not a suppression — it followed the precedent already written in the same package (duplicate-record-error.ts declares readonly cause: unknown for exactly this reason), pushed as a new commit on top (94193545fd, one parent, ⛔ no rebase/amend/force-push).
⭐ A round that pushes red once and says so is worth more than one that hides it. Recorded as a process reading, ⛔ not as a finding against the delivery.
9. Gates, NOT MEASURED, and docs
dispatch-gates --ran: 64 derived, 62 green, 2 NOT-MEASURED, 0 UNRUN — and the two are exit 3 = PREREQUISITE NOT MET (check:dual-build-cjs-loads,check:type-check-debt), both needing a full workspace build, neither naming a file in this diff. ⭐ Declared to CI, ⛔ not skipped, and ⛔ not counted as a pass.- Lint ran the FULL population —
eslint . --no-inline-configexit 0, 6,483 files, counted from eslint's own JSON output length rather than guessed ⇒ ⛔ no narrowing argument owed. - docs-drift: 22 pages named. 3 release-owned — ⛔ NOT touched (read-only by directive). The 19 hand-written pages were READ and are UNAFFECTED, with the two plausible staleness candidates checked closely:
api/error-catalog.mdxalready carries the governing clause verbatim ("An error carrying its ownstatusis served with it") — which is the exact channel this fix rides — andkernel/contracts/data-engine.mdxdocumentsERR_READONLY_FIELD_REJECTED, deliberately not reused, so it stays true. ⭐ 「Advisory only」 was not accepted as an answer, and it was not given as one.
⚠️ A VOID reading of my own during this review, recorded rather than quietly re-run
Checking whether the new module is reachable from the package entry, my first grep returned zero references anywhere — which contradicted the report. Cause: I grepped the local checkout (packages/objectql/src), which is on origin/main and has no such module, instead of the PR head. ⭐ The pattern was right and the tree was wrong. A new variant of tonight's recurring failure, and the only reason it was caught is that the answer contradicted a claim I could check.
Gates and CI
check-clause2-carriers --pair 17327 ⇒ 0. Governed predicate over the FULL 6-file list ⇒ 0 hits, firing control (adding AGENTS.md) ⇒ exit 3, GOVERNED. CI on 94193545fd: 33 distinct, 0 red, 0 pending; legacy combined status read separately: success.
open_questions: [] — ⛔ no PASS over an unruled question.
VERDICT: PASS. The lane conflict was flagged and resolved conservatively against this seat's own wrong order; the landing site was moved by measurement; the surface is package-private with reachability verified on the PR head; patch is an envelope on an existing refusal; the ablation reaches the built artefact the test actually loads. Landing next.
Generated by Claude Code
Fixes #17219
Clause-②: no— no new error code is registered inpackages/spec'serror-code-ledger.zod.ts, and no key is added to any published payload. The hard stop in the dispatch order was not reached; the route below deliberately avoids it, and the reasoning is recorded under Deliberate limits.Authored by Claude Code, session
session_01XTBcV7zZHmokdyQgXjbyEU.Landing site — reported before widening, as the dispatch ordered
The dispatch named the expected surface as the sandbox error path (
packages/runtime/src/sandbox/**and whatever composes theSandboxErrorwrapper). Measured, the landing site is not there. It ispackages/objectql, which is also the lane triage derived (domain:engine).The measurement that decides it: the sandbox face is a plain JSON snapshot (
body-runner.tstakesunwrapProxyToPlain(engineCtx.input), then it is marshalled into QuickJS), so a withheld key is simply absent there. Absent-because-the-platform-took-it-away is indistinguishable, downstream of the engine, from absent-because-nobody-sent-it — and stating that difference is exactly what ruling 4 requires of the message. Only the hide pass knows it. So the explanation is composed where the hide pass is, andpackages/runtime/src/sandbox/**gains no production code at all — one test file there is tightened, nothing more.Files:
packages/objectql/src/hook-withheld-readonly-fault.tspackages/objectql/src/engine.tsbeforeUpdatedispatch sites inside the hide windowpackages/objectql/src/hook-withheld-readonly-fault.test.tspackages/objectql/src/engine-readonly-hook-input.test.tspackages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.tstoThrowto the envelope.changeset/hook-withheld-readonly-key-diagnostic.md@objectstack/objectqlShared-file fence — re-derived by symbol on my own head
#15719 is claimed in the same round and lands in the CREATE path. Re-derived at
origin/main501959b72a(the card's line numbers were taken at66c580b0cand have moved):Disjoint. My region is the UPDATE path, ~870 lines below #15719's create-path site, and I do not touch
packages/objectql/src/validation/rule-validator.tsat all. My import line was placed beside the #14088 recorder import rather than on therule-validatorimport line, so even the import block does not overlap. No serial conflict; nothing to edit around.What was measured, before anything was written
Re-fetched
origin/mainfirst (ruling 5): the premise commitd2c1d19807is present; measurement base501959b72a.Both doors, on
main, through the real harness — QuickJS, the flat-input proxy, the #14088 recorder and the strip:The card's quoted text is still exact — but the REST reading is the worse of the two outcomes triage allowed for, and it is the door an author actually authors against.
error-response.ts'sisScriptFaultMessagecorrectly classifies a leadingTypeError:as a crash (#7543) and sanitises it, so the author was told nothing at all: not the key, not the reason, not the remedy. A control fired in the same measurement — a body throwing an authoredErrorstill answers400with its own words verbatim — so the blank envelope is a real reading, not a broken probe.Candidate error shapes were then measured through
mapDataErrorbefore choosing one:Error500 INTERNAL_ERRORError+status = 400400+ message verbatim, nocodeError+ ledgeredcodeonly500 INTERNAL_ERROR500 INTERNAL_ERRORstatusis what makes the remedy reachable; acodedoes not. That is the whole reason the route needs no ledger entry.The fix
The engine already records which caller-supplied read-only keys it withheld (
readonlyHiddenFromHooks). All threebeforeUpdatedispatch sites inside that window — by-id, unscoped-multi, per-row — now share one wrapper, so a hook that faults there is answered with an error that:readonly: true— "withheld by the platform, not missing by accident", in those words;ctx.previous.KEY, the remedy PR fix(objectql)!: beforeUpdate receives the persist image; the caller submission moves to ctx.submitted (#16344) #17195's changeset documents under Who is affected;Mechanically this is still a crash, so the fault channel was never the wrong pipe. What was wrong is the causal attribution — one of the platform's own contract enforcements was reported to the author as a bug in their code. That is the line
rest-hook-refusal-classification.test.tsand its siblings already draw, and this puts the case back on the side of it that it belongs on.What this deliberately does NOT do
statusreaches it — so a future repair of the diagnostic cannot quietly restore the old write.ctx.submitted, no newctxmember, no new key on any authoring face.packages/runtime/src/sandbox/**gains no production code.code" channel a body's own authored refusal already uses.ERR_READONLY_FIELD_REJECTEDwas considered and refused: that code names thestrictReadonlyWritesrefusal and carriesdropsas part of its contract, so borrowing it would make the error lie about which refusal happened.Three declines keep the wrapper from repeating the card's defect in the opposite direction, each with its own test: nothing withheld (an ordinary crash keeps its raw words), an authored refusal (never rewritten —
mapDataErrorserves that text to the caller verbatim, so overwriting it would destroy the author's own words), and a non-object throw. All three rethrow the original error object identically.Verification
Measured at
94193545fd. Every exit code captured by redirect-then-$?, never across a pipe.Reverse verification — the real direction, from the committed state. The ablation reverts
packages/objectql/src/engine.tsto the pre-fix commit (git restore --source=BASE, tree only, never the index), which removes the import and all three wraps. Both legs prove the mutation reached the built artifact the runtime suite consumes, because that suite resolves@objectstack/objectqlthroughexportstodist/, notsrc/:The direction was predicted before running and held: the three failures are exactly the three dispatch-site cases. The three control cases stayed green under ablation — they assert pass-through, which is the pre-fix behaviour — and so did the composer's own unit suite, which exercises the module in isolation rather than its wiring. That split is the point: the unit suite proves the composer, the end-to-end cases prove it is reached.
Suites (after the type fix, against a forced rebuild):
pnpm --filter @objectstack/objectql typechecktsc,tsconfig.scripts.json,check:test-typecheckall clean)pnpm --filter @objectstack/runtime typecheckGate families, derived from the real change set rather than from a list —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, then reconciled with--rancarrying every exit code so the NOT-MEASURED count is derived, not claimed:⛔ The two are NOT passes and NOT findings — both exited 3, PREREQUISITE NOT MET, and neither names a file in this diff:
check:dual-build-cjs-loads— "this gate reads built output, and some package has no dist/" (38 packages unbuilt in this worktree).check:type-check-debt— "--re-measurecannot run: 2 workspace dependencies have no built type entry point on disk".Both need a full workspace build (
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'), which is the repo-wide sweep the requiredLint & Repo Gatescontext performs on a fresh checkout after its own build step. Declared to CI rather than skipped.Lint — the full population, not a narrowing.
pnpm lint(eslint . --no-inline-config) finished inside the foreground budget, so no narrowing had to be justified: 6483 files linted — the population read from--format jsonoutput length, i.e. from eslint's own config rather than from any guess of mine — 0 errors, 0 warnings, exit 0, at94193545fd..cache/is absent from this worktree, so the objectui-checkout false positives AGENTS.md warns about cannot be in that count.Docs drift advisory — 22 pages, and what was done about them. 3 are release-owned (
content/docs/releases/v17/17-0,17-1,17-3) and were not touched: read-only by directive. The other 19 hand-written pages were read and are unaffected, and the reason is structural rather than a skim: this change alters no behaviour any of them documents — the #16344 hide, the five strip rules,ctx.submitted,ctx.previousandonError: abortare all exactly as written. Two spots were checked closely because they were the only ones that could have gone stale:content/docs/api/error-catalog.mdxstates the crash-vs-rejection rule and already carries the governing clause verbatim — "An error carrying its ownstatusis served with it." That is precisely the channel this fix uses, so the page is correct as written, not merely unmentioned.content/docs/kernel/contracts/data-engine.mdxdocumentsReadonlyFieldRejectedError/ERR_READONLY_FIELD_REJECTED. That code was deliberately not reused here, so its description stays true.Process note, stated rather than quietly fixed
The first push (
e94f8ae054) was red:@objectstack/objectql#typecheckexited 2 onErrorOptions/ a two-argumentsuper(), because this repo compiles againstlib: ES2020. I opened the PR before running the package typecheck; that was the wrong order and it cost a red head. The fix (94193545fd) follows the precedent already written down in the same package —duplicate-record-error.tsdeclaresreadonly cause: unknownon the class for exactly this reason — rather than reaching for a cast or a suppression, and both suites plus the reverse verification were re-run against a forced rebuild afterwards.Acceptance notes
rejects.toThrow(/locked_meta|cannot set property 'who' of undefined/)— which stayed green through the entire defect, since the rawTypeErrormatches the second arm. It is now an envelope assertion. Noted as the reason atoThrowis not a refusal test.dispatchUnscopedMultiWriteHooksis reachable only for a code hook carryingdispatchUnscopedMultiWrite, which no metadata binding can set today. It is wrapped for parity with the other two sites and covered by its own case, rather than left to drift.Generated by Claude Code