Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): decide the insert-side runtime-owned strip by hook-write provenance - #14472
Conversation
… runtime-owned strips Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…te provenance Withholds the readonlyWhen sibling seam: threading the record into isCallerSuppliedValue was measured to let a caller value survive a TRUE readonlyWhen predicate (#9107 pin LOCK 3b), which #14259's fork clause routes to the decision inbox rather than to a unilateral choice. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ok-provenance-sibling-seams
…le; re-anchor the system-context census Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ok-provenance-sibling-seams
📓 Docs Drift CheckThis PR changes 1 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 1 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 15 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 afdbce2baed0487c867d978b8956d531e297b5cf && git checkout afdbce2baed0487c867d978b8956d531e297b5cf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 18d816a504a5795a13c609304eab5825cf923aaf 25619ebb76b68f80ef5489dddb101255bdc1d802 && git checkout -B drift-repro 18d816a504a5795a13c609304eab5825cf923aaf && git merge --no-ff 25619ebb76b68f80ef5489dddb101255bdc1d802
node scripts/docs-audit/affected-docs.mjs --json 18d816a504a5795a13c609304eab5825cf923aaf
|
os-musk
commented
Sep 2, 2026
Landing provenance — engine execution seat (session ACCEPT on the card: comment 5506811038 (#14259 — seam 2 ships here as Action: Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
…th faces `isCallerSuppliedValue`'s docblock claimed the readonlyWhen seam runs "the identical two-part test `stripReadonlyFields` applies" and is "textually parallel" with it. Neither has held since #14088 moved the static face onto a hook-write record and #14472 did the same to the insert-side twin. The divergence is deliberate and lock-motivated, so it is now stated where the test lives, with the reason each face guards a different thing. Adds the static face's missing measurement pin (a lone self-assigning hook leaves the CALLER value on the key) beside #14088's own suite, and cross-links it with `LOCK 3b`, which pins the opposite verdict for the identical hook spelling. Comment- and test-only; no behaviour moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Part of #14259
Part of, not a closing keyword, and that is the headline.#14259 names TWO sibling seams. One is here and finished; the other is WITHHELD pending a maintainer ruling, because implementing it was measured to violate the card's own binding constraint. Merging this must not close the card.Verified on
git rev-parse --short HEAD=25619ebb7(origin/mainmerged immediately before opening).What ships: seam 2 —
stripRuntimeOwnedFields, the INSERT-side strip#14088 replaced
Object.is(payload[k], supplied[k])insidestripReadonlyFieldswith a recording of the keys the before-phase hook chain actually assigned (recordHookPayloadWrites). Its argument was never aboutnull: value equality cannot separateand the two demand opposite verdicts.
stripRuntimeOwnedFieldswas left on the comparison that argument retired, and #6339's own prose is the finding: it argued a key SET made the contract true "only BY ACCIDENT" and moved to VALUES, which is accidental in the identical way. So #6339's own measured hook — abeforeInsertthat re-issues or normalises a record number — still loses its write to the one caller that submitted the same value.The change.
engine.insertarms onerecordHookPayloadWritesper row at hook-context construction and seals each immediately after that row'sbeforeInsertchain;stripRuntimeOwnedFieldsgains the same optionalhookWrittenKeys#14088 gavestripReadonlyFields, consulted before the value test, with the same no-record fallback.engine.ts regions touched — declared, and nothing else
packages/objectql/src/engine.tsis a hot file, so the touch is region-bounded and the regions are named here as they were in the dev claim:rowHookContextsconstructiondefaultedDatabeforeInsertdispatch loopinput.datastripRuntimeOwnedFieldscall sitehookWrittenKeysimport typebeside the existingrecordHookPayloadWritesimportNo other
engine.tshunk. The two update-pathreadonlyWhencall sites named in the dev claim are not touched (see the withheld seam below). At the time of opening, no other open PR touchespackages/objectql/src/validation/rule-validator.tsor these regions.The forgery boundary the NEW recording owes
The card carries it verbatim — "The record is safe only because it is armed after the caller's payload has arrived and sealed before any engine-owned pass touches it, and because it records that an assignment ran rather than anything about the payload's contents. Any producer of a hook-written key set owes the same proof." The insert-side recording is new, so here is that proof, each property measured rather than asserted:
defaultedData— aftersuppliedPerRowhas taken its explicit shallow copy of the caller's rows, and after the engine's ownapplyFieldDefaults/initializeSummaryFields. So neither a caller key nor an engine default can enter the record. Pinned:FORGERY BOUNDARY: the recording is armed AFTER the caller payload arrives.beforeInsertdispatch and the strip: the post-hook declared-field door (undeclaredWriteFieldErrors, reads only),resolveSystemInsertOrganization(measured read-only over rows — it consultsrow[tenantField]and writes nothing),buildDriverOptions(writesinput.options, neverinput.data), thenrefuseEmptyPasswordFieldsandencryptSecretFields— the last of which does write to the row and is exactly what the recorder's own header names as the escalation risk. The seal is placed ahead of all of them, immediately after the dispatch loop, so the only code inside the window is before-phase hook code. Pinned:FORGERY BOUNDARY: the recording is SEALED before any engine-owned pass.Two further properties the update path's single recording does not have to carry:
input.datafor every row, including one the declared-field door culled (that row ran no hook, so its record is legitimately empty).⛔ Not a relaxation of #5503 — the negative controls
The accept set for callers does not move. A caller-seeded record number that no hook assigned is still stripped, still warns with the same text, and still reports through both observability seams. Asserted through the documented surfaces rather than by
toEqualon the result:Field 'code' on 'prov_ticket',runtime-owned,COMMITTED WITHOUT ITandhook-written keys are not caller-supplied;onFieldsDroppedstill fires withreason: 'readonly'namingcode;strictReadonlyWritesstill REFUSES — asserted as an envelope (nameReadonlyFieldRejectedError,codeERR_READONLY_FIELD_REJECTED,fieldscontainingcode,operationinsert) plus zero rows reaching the driver, never as a baretoThrow().statusis deliberately not asserted: this class carriescodeandnameonly, the HTTP mapping living at the protocol layer, so pinning astatushere would pin a member the class does not have.isSystemandpreserveAuditare untouched. A hook that REPLACES the payload object leaves no attributable record and falls back to the pre-existing value test, which over-strips — pinned, because reading a replacement's keys as hook-owned would launder a caller's forgery, and keeping the old bug is the only safe direction.What is WITHHELD: seam 1 —
isCallerSuppliedValue, behind the tworeadonlyWhenstripsImplemented, measured, then reverted out of this PR. The card's own fork clause fired.
Threading the sealed record into
isCallerSuppliedValueturns the existing #9107 pinLOCK 3bred. Measured, exact:The hook in that pin is
ctx.input.data.closed_note = ctx.input.data.closed_note— a self-assignment that computes nothing. UnderObject.isit "has written nothing". Under provenance it is aseton the recorded object, so the key becomes hook-owned and the caller's forged value survives a TRUEreadonlyWhenpredicate on arecord.status == 'closed'state lock. The pin's own prose says why it exists: "Pinned because 'a hook touched this key' is exactly the weaker rule that WOULD open a laundering path."That is the fork clause verbatim — a case where seam 1 under provenance lets a caller value survive a TRUE predicate — so it goes to the decision inbox and is not resolved here, in either direction. Nothing about the
readonlyWhenseam's behaviour changes in this PR, and no existing pin is relaxed.The tension is real and not a bug in either half: the mechanism the card mandates records that an assignment ran and is deliberately blind to the value, which makes "a hook deliberately wrote the value the caller also sent" (must be KEPT, per the inherited ruling) and "a hook echoed the caller's value onto a locked column" (must be STRIPPED, per
LOCK 3b) mechanically indistinguishable. A "value differs from what was there" filter does not separate them either — it would re-break #14088's own measuredcompleted_at: nullrow, where the hook's assignment is value-identical to what already stands on the key.beforeInserthook does leave the caller's seeded record number on the key. It ships here becausestripRuntimeOwnedFieldsguards a runtime-owned COLUMN (#5503) — the same class of protection #14088 already moved to provenance on the update side, and the class whose hook exemptionruntimeOwnedStripWarningpromises authors in prose — not a STATE lock whose whole purpose is that no caller write survives a TRUE predicate. INSERT is exempt fromreadonlyWhenentirely, so no lock of that class exists on this path to open. A ruling that self-assignment must not count would move that pin and #14088's seam together, deliberately.Seam 3 from the card (arming the recorder before the middleware chain) is out of scope by triage and was not implemented; no middleware stamp needing it was measured.
Verification
All readings on
25619ebb7unless stated. Every exit code captured after a redirect, never through a pipe; each verdict quoted from the gate's own line.Tests.
pnpm --filter @objectstack/objectql test→Test Files 258 passed (258)·Tests 4470 passed (4470).pnpm --filter @objectstack/objectql typecheck→ exit 0,check:test-typecheck: OK(44 files / 242 errors / 69 pinned signatures held — the ledger did not move).tsc --noEmit -p tsconfig.test.json --listFilesnames it (1 hit, not 0).New pin file
packages/objectql/src/engine-hook-provenance-sibling-seams.test.ts— 13 cases, all green, alongside every neighbouring pin (engine-readonly-strip-caller-values,engine-readonly-when-derived-writes,engine-insert-runtime-owned-strip,hook-write-provenance,validation/rule-validator,validation/skip-provenance): 7 files / 268 tests green together.Ablation, direction predicted before the run and matched: reverting the
hookWrittenKeys?.has(name)branch instripRuntimeOwnedFieldsturns 5 red / 8 green — every DEFECT and consequence case red, every negative control green. No rebuild leg applies: the pin imports./engine.js, a relative specifier inside the same package'ssrc/, so vitest resolves the subject from source and never fromdist/— the ablation going red with no build in between is itself the evidence.The mutation was proven on disk before the run (exact occurrence counts⚠️ Worth recording: the first attempt's marker matched 2 occurrences — the bare branch line is byte-identical to #14088's inside
1 -> 0deleted,0 -> 1injected, counted in Python because the anchor is multi-line andgrep -cwould count either half of it).stripReadonlyFieldstwo functions up — and the on-disk guard refused the run rather than let a reading contaminated by ablating #14088's seam through. The anchor was narrowed to the preceding comment. Restore was proven by observed state, not exit code:git hash-objectequal to the HEAD blob,git diff HEADempty,git status --porcelainempty, with the restore path pinned to an absolute repo root inside the trap.Lint. The whole population, not a narrowing:
eslint . --no-inline-config --format json(the exact rule setpnpm lintruns) → 5702 files, 0 errors, 0 warnings, exit 0.Gates. Re-derived after the last commit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no path list — the script takes its own change set), then run as a union on this HEAD: 60 green, 0 red, 4 NOT MEASURED. The four areexit 3prerequisite refusals, recorded in the gates' own words and never as passes:check-test-completeness— needs a savedturbo run testlog this gate cannot produce; CI tees one.check-half-states— needs repo-scoped GitHub reads this container's egress refuses.check:dual-build-cjs-loads—PREREQUISITE NOT MET, reads built output for 56 unbuilt packages.check:type-check-debt—PREREQUISITE NOT MET,--re-measureneeds 41 dependency closures built.check:skill-examplesinitially refused for the same reason (packages/client-react/distunbuilt, and its own text says a verdict then would be a FALSE GREEN); the closure was built and it now reports 263 prose examples type-check across 3 surfaces, exit 0.Gate scaffolding, regenerated and never hand-edited.
check-system-context-censuswent red on pure line rot — the insert-path arm/seal shifts every laterengine.tsline — and was repaired withnode scripts/check-system-context-census.mjs --fix(15 anchors rewritten incontent/docs/permissions/system-context.mdx; the gate then reportsOK — 109 elevation read sites ... all anchored).check:objectql-double-limitflagged the new pin's driver double for ignoring the caller's bound; the double now applieslimitafter the filter, by presence, and the gate is green.check:engine-double-contractwas green throughout and needed no ledger row.origin/main,engine-filter-array-lowering.test.tsfailed 6 cases. It was staledist/, not code — the merge broughtpackages/specchanges my worktree's build predated. Cleanorigin/mainin a comparison worktree ran the same file 60/60 green, and rebuilding this worktree's dependency closure made the failure vanish. Nothing was "fixed"; the instrument was.Scope
Changeset:
@objectstack/objectqlpatch.packages/objectql-internal throughout — none of the four functions is re-exported fromindex.tsorcore.ts, and every mention outsiderule-validator.ts/engine.tsis prose in a comment (packages/spec,packages/metadata-protocol, threepackages/lintfiles, one showcase object) or a test in this package. The new option is optional and every positional signature is unchanged, so no existing call site moves.🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Generated by Claude Code