fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent - #14258

Merged
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance
Sep 2, 2026
Merged

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent#14258
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14088

stripReadonlyFields asked Object.is(payload[name], supplied[name]) to answer who wrote this key. Value equality cannot carry that question, and this is it failing: when a hook writes the value the caller also sent, the comparison reads it as the hook never touched it and deletes the hook's write.

Reproduced first, on origin/main's predicate, then fixed. The reproduction is the ablation below.

The defect, and why it is the same defect twice

#5591 / #6339 retired the key-SET judgement because it made the strip's own written contract — runtimeOwnedStripWarning's promise that "hook-written keys are NOT caller-supplied" — true only by accident. Value equality is accidental in precisely the same way, and null == null is just its most common collision. So this is #6339's residual item, not a new defect, and that framing is load-bearing: the repair is not a better comparison, it is the end of comparing.

Measured downstream (published 17.2.0, duly_task): a readonlycompleted_at stamped by a beforeUpdate hook on the transition into done and cleared on the transition out. Reopening works — until the caller also sends completed_at: null, which is what a form round-trip of the whole record does. Object.is(null, null) is true, the hook's clear is stripped, and the row commits status = in_progress still carrying its old completion timestamp, with no error. That row is the one a validation rule structurally cannot catch ("a completed task must carry a completion timestamp" has no purchase on its inverse), nothing downstream can tell it from a genuinely completed one, and every on-time metric reading completed_at counts it.

The repair: provenance, recorded — never inferred, never a sentinel

New packages/objectql/src/hook-write-provenance.ts. recordHookPayloadWrites returns a write-through view of the update payload that records the fact that an assignment executed — never the payload's contents. engine.update() arms it at hookContext construction and seals it at the post-hook confluence #13657 already uses, where hookContext.input.data is final on both update branches. stripReadonlyFields gains an optional hookWrittenKeys and keeps a key a hook demonstrably assigned.

Both branches consume the one sealed record. Not a second derivation: two notions of "a hook wrote this key" that disagree in one edge case would be worse than the defect they each closed, and a bulk write reaching a different verdict about authorship than a by-id write is the #3106 / #4441 divergence verbatim.

Not a null special case.0, '', false and a shared object reference collide identically, and the same whole-record write-back idiom echoes all of them. The 0 twin is pinned as its own test and it fails under ablation alongside the null one.

The forgery boundary — the one place a mistake is worse than the bug

A hook-owned key is a key the strip stops defending, so this is the part that had to be structural rather than careful. The record cannot be reached by caller data, because:

  • it records nothing about the object's contents — only that an assignment ran;
  • the window is armed after the caller's entry snapshot and sealed before the engine's own passes (encryptSecretFields, normalizeMultiValueFields, the strips). Between those two points the only code that runs is before-phase hook code. Sealing is not tidiness: a recorder still armed for encryptSecretFields would attribute an engine write to a hook, which on a caller-forged secret column is exactly the escalation this is built to prevent;
  • a caller cannot execute an assignment. Echoing a key, echoing a value, sending null, sending a Proxy, sending a getter — none of them is a set.

The record therefore only ever turns a strip into a keep, and only for a key a hook assigned. Every other key is decided by the two-part test that was already there, unchanged.

The forgery pin at engine-readonly-strip-signal.test.ts:290-292 is untouched and passes. It calls stripReadonlyFields with no provenance argument, and absent provenance the function is byte-identical to before.

The discriminator, pinned in both directions. The same caller payload — completed_at: null over a stored timestamp — now clears when a hook wrote the null and is still stripped (with the same warn) when no hook did. Two opposite verdicts on byte-identical caller input is what value equality cannot deliver and a record can; it is also what separates this from "stopped stripping read-only fields".

Known limit, deliberately fail-safe, pinned as a test

A hook that replaces the payload (ctx.input.data = { ...ctx.input.data, x: 1 }) rather than mutating it leaves no attributable record — the replacement's keys are indistinguishable from the caller's because most of them are the caller's — so seal returns undefined (not an empty set) and that call falls back to the previous value comparison, i.e. it keeps the old over-strip. Reading a replacement's keys as hook-owned would launder a caller's forged created_by into a platform write, so the fallback direction is the only safe one. Two tests pin it, including the one proving the fallback is the whole pre-repair behaviour and not a new hole. The pre-existing shallow-snapshot limit (a hook mutating a caller object in place) is unchanged, for the same argument.

Measured, including what the card did not measure

Verification tree: 7a486e32f (every reading below is from that tree, taken after the final commit — re-run there after the merge round, not carried over from the pre-merge head).

Merge round.main landed PR #14249 after this branch's merge base and re-anchored other rows of content/docs/permissions/system-context.mdx, the generated census page, leaving the PR dirty. Resolved by merging origin/main (a merge commit — never a rebase, never a force-push). The repo's merge driver deliberately does not text-merge that page and says so, which left this branch's pre-merge copy in place — that would have silently dropped #14249's rows, so main's copy was taken wholesale and the anchors re-derived from the merged tree with the repo's own pnpm gen:system-context-census. It did not refuse, and the census totals are unchanged (109 read sites / 145 anchors / 27 declared non-read), so this was line rot and not a population change. Verified against main's copy: 12 rows differ and every difference is an engine.ts line number inside backticks — all prose byte-identical.

All six content files of this PR are byte-identical across the merge (git rev-parse on each blob, efda8da21 vs 7a486e32f): only the generated page moved. That is what lets the ablation reading below stand as measured — the file it mutates, validation/rule-validator.ts, is the same blob at both heads.

Whole package, unablated, re-run on the merged tree:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 253 passed (253) · Tests 4379 passed (4379), wrapper VERDICT command-exit 0. Identical to the pre-merge run, so the merge changed nothing behavioural. Dependency closure rebuilt first (pnpm --filter '@objectstack/objectql^...' build, VERDICT command-exit 0), since main moved packages/spec and packages/types underneath it.

Ablation — the reproduction. Provenance consult deleted from rule-validator.ts (marker count 1 to 0 confirmed on disk; blob 1f205514 to 0e478d69; restored blob compared back to the HEAD blob and git diff HEAD empty). Every import in these suites is relative and in-package, so vitest runs the TS source and no dist/ is in the resolution path — the mutation changing behaviour with no rebuild is itself the proof.

7 failed | 45 passed (52) — and which 7 is the finding:

ablated caseassertion
THE REPORT: reopen also sending completed_at: nullexpected '2026-08-01T09:00:00.000Z' not to be '2026-08-01T09:00:00.000Z' — the stale timestamp, exactly as reported
the same collision on 0expected 42 to be +0
the STAMP directionexpected null to be '2026-08-05T10:00:00.000Z'
the PREDICATE branch, clearexpected '2026-08-01T09:00:00.000Z' to be null
the PREDICATE branch, stampexpected null to be '2026-08-05T10:00:00.000Z'
hook clear not reported to onFieldsDroppedone event, expected none
strictReadonlyWrites refusing a hook's own clearwrite refused

Two things that answers. The stamp direction is broken symmetrically — the card measures only the clear, and the framing is exactly as wide as the tree. And both update branches were broken, so both had to be fixed; a by-id-only repair would have left the predicate path corrupt.

Green under ablation, i.e. genuine negative controls: the bare { status } reopen, the partial patch, isSystem, every forgery face, the replacement fallback, all recorder unit tests, and the whole of engine-readonly-strip-signal.test.ts including the forgery pin. The fix moves the collision cases and nothing else.

Gates. Re-derived on the merged tree by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack with no paths passed — it read the change set itself (7 paths vs merge base a39b02a6b). The family grew from 33 to 58, because the change set now carries a content/docs/** path and that pulls in the whole docs family; nothing dropped out. All 58 run, every exit code captured by redirect-then-read and never through a pipe. 54 green, plus check:nul-bytes and check-engine-double-contract run outside the derived list. Four readings that are not greens, and none is a finding:

  • check-system-context-census — the reason for this round; green on the merged tree, and its --self-test passes alongside.
  • check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone). Report-only by default, and its exit 2 is documented as distinct from a finding. NOT MEASURED locally; the scheduled workflow checks out at full depth.
  • check-test-completeness exit 3 = PREREQUISITE NOT MET (it grades a saved turbo log; none exists locally, and its own text says to record this as NOT MEASURED). NOT MEASURED locally.
  • check:dual-build-cjs-loads exit 3 and spec check:skill-examples exit 1 — both PREREQUISITE NOT MET for the same reason: they read built dist/ output, and this container built only the @objectstack/objectql dependency closure, not all 56 packages. Both refuse rather than conclude ("⛔ This is NOT a pass: nothing was measured" / "a verdict now would be… a FALSE GREEN"), and both name packages this diff does not touch (client-react, hono, apps/*). NOT MEASURED locally; CI builds everything. They are new to this round only because the docs path widened the family — neither was a green that turned red.

Typecheckpnpm --filter @objectstack/objectql typecheck green on the merged tree too (VERDICT command-exit 0), and measured to cover the new tests rather than assumed: tsc -p tsconfig.test.json --listFiles finds all three new/edited files, and the 242 error lines are exactly the ledgered debt with none attributed to them.

Lint, as a declared narrowing. Repo-wide pnpm lint was not run; the five changed files were, --format json, 5/5 linted (none ignored), 0 errors / 0 warnings. The narrowing is measurable rather than merely narrow: this repo runs one eslint.config.mjs which never enables type-aware linting for any file — stated at eslint.config.mjs:326-335 with a measured positive control — so no untouched file's verdict can move as a consequence of this diff.

Scope

engine.ts's multi: truebatch-hook path is untouched. dispatchPerRowBeforeHooks is not modified; the predicate branch participates only by reading the same sealed record at the same shared seam. #14099 remains open and is not addressed here.

⭐ One thing worth the next dispatch's attention: this record does answer #14099's detection question. That card's own suggested direction asks for an engine that can tell "a hook mutated the payload on a multi update" — and after the seal, on the predicate branch, hookWrittenKeys is exactly that set, non-empty precisely when a batch hook wrote the shared SET clause. Deliberately not acted on here.


Session, kept in prose so it survives body edits: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5


Generated by Claude Code

…14088)
`stripReadonlyFields` asked `Object.is(payload[k], supplied[k])` to answer
"who wrote this key". Value equality cannot: when a hook writes the value the
caller also sent, the comparison reads it as "the hook never touched it" and
deletes the hook's write. Measured downstream on a `readonly` `completed_at`
cleared by a reopen hook against a caller that round-tripped the record — the
row committed `in_progress` carrying its old completion timestamp, silently.
Record the keys the before-phase hook chain actually ASSIGNS
(`recordHookPayloadWrites`), armed after the caller's entry snapshot and
sealed at the post-hook confluence both update branches share, and let the
strip keep a key a hook demonstrably wrote. Not a `null` sentinel: `0`, `''`,
`false` and shared references collide identically. Not a relaxation: a
caller-supplied read-only value no hook wrote is still stripped, and a caller
cannot enter the record because echoing a value is not an assignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…insertion (#14088)
Pure line rot: the #14088 import block shifts every subsequent engine.ts line
by five, so all 15 anchors on content/docs/permissions/system-context.mdx
moved. Repaired with the gate's own `--fix`; no prose changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/security.mdx(via stripReadonlyFields (symbol, a top-level function))
  • content/docs/protocol/objectql/state-machine.mdx(via stripReadonlyFields (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e32c142f7baffc21a5eafda24c5c8162b458f34 — the merge of head 7a486e32f78821e3decda1c49dddb767c8ad77b7 into base d63c8a25216c59b9bf2652cf5197aa526a2c00b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e32c142f7baffc21a5eafda24c5c8162b458f34 && git checkout 1e32c142f7baffc21a5eafda24c5c8162b458f34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d63c8a25216c59b9bf2652cf5197aa526a2c00b2 7a486e32f78821e3decda1c49dddb767c8ad77b7 && git checkout -B drift-repro d63c8a25216c59b9bf2652cf5197aa526a2c00b2 && git merge --no-ff 7a486e32f78821e3decda1c49dddb767c8ad77b7
node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d63c8a25216c59b9bf2652cf5197aa526a2c00b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The merge driver treats content/docs/permissions/system-context.mdx as
generated and does not text-merge it, so the merge left this branch's
pre-merge copy in place — which would have dropped the rows PR #14249 added
on main. Took main's copy wholesale and re-derived the anchors from the
MERGED tree with the repo's own tooling (pnpm gen:system-context-census).
Result verified against main's copy: 12 rows differ and every difference is
an engine.ts line number inside backticks; all prose is byte-identical, so
#14249's rows survive intact. The 15 rewritten anchors are the same 15 this
branch's engine.ts insertion shifts, and the census totals are unchanged
(109 read sites / 145 anchors / 27 declared non-read), so this is line rot
and not a population change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent - #14258

Merged
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance
Sep 2, 2026
Merged

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent#14258
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14088

stripReadonlyFields asked Object.is(payload[name], supplied[name]) to answer who wrote this key. Value equality cannot carry that question, and this is it failing: when a hook writes the value the caller also sent, the comparison reads it as the hook never touched it and deletes the hook's write.

Reproduced first, on origin/main's predicate, then fixed. The reproduction is the ablation below.

The defect, and why it is the same defect twice

#5591 / #6339 retired the key-SET judgement because it made the strip's own written contract — runtimeOwnedStripWarning's promise that "hook-written keys are NOT caller-supplied" — true only by accident. Value equality is accidental in precisely the same way, and null == null is just its most common collision. So this is #6339's residual item, not a new defect, and that framing is load-bearing: the repair is not a better comparison, it is the end of comparing.

Measured downstream (published 17.2.0, duly_task): a readonlycompleted_at stamped by a beforeUpdate hook on the transition into done and cleared on the transition out. Reopening works — until the caller also sends completed_at: null, which is what a form round-trip of the whole record does. Object.is(null, null) is true, the hook's clear is stripped, and the row commits status = in_progress still carrying its old completion timestamp, with no error. That row is the one a validation rule structurally cannot catch ("a completed task must carry a completion timestamp" has no purchase on its inverse), nothing downstream can tell it from a genuinely completed one, and every on-time metric reading completed_at counts it.

The repair: provenance, recorded — never inferred, never a sentinel

New packages/objectql/src/hook-write-provenance.ts. recordHookPayloadWrites returns a write-through view of the update payload that records the fact that an assignment executed — never the payload's contents. engine.update() arms it at hookContext construction and seals it at the post-hook confluence #13657 already uses, where hookContext.input.data is final on both update branches. stripReadonlyFields gains an optional hookWrittenKeys and keeps a key a hook demonstrably assigned.

Both branches consume the one sealed record. Not a second derivation: two notions of "a hook wrote this key" that disagree in one edge case would be worse than the defect they each closed, and a bulk write reaching a different verdict about authorship than a by-id write is the #3106 / #4441 divergence verbatim.

Not a null special case.0, '', false and a shared object reference collide identically, and the same whole-record write-back idiom echoes all of them. The 0 twin is pinned as its own test and it fails under ablation alongside the null one.

The forgery boundary — the one place a mistake is worse than the bug

A hook-owned key is a key the strip stops defending, so this is the part that had to be structural rather than careful. The record cannot be reached by caller data, because:

  • it records nothing about the object's contents — only that an assignment ran;
  • the window is armed after the caller's entry snapshot and sealed before the engine's own passes (encryptSecretFields, normalizeMultiValueFields, the strips). Between those two points the only code that runs is before-phase hook code. Sealing is not tidiness: a recorder still armed for encryptSecretFields would attribute an engine write to a hook, which on a caller-forged secret column is exactly the escalation this is built to prevent;
  • a caller cannot execute an assignment. Echoing a key, echoing a value, sending null, sending a Proxy, sending a getter — none of them is a set.

The record therefore only ever turns a strip into a keep, and only for a key a hook assigned. Every other key is decided by the two-part test that was already there, unchanged.

The forgery pin at engine-readonly-strip-signal.test.ts:290-292 is untouched and passes. It calls stripReadonlyFields with no provenance argument, and absent provenance the function is byte-identical to before.

The discriminator, pinned in both directions. The same caller payload — completed_at: null over a stored timestamp — now clears when a hook wrote the null and is still stripped (with the same warn) when no hook did. Two opposite verdicts on byte-identical caller input is what value equality cannot deliver and a record can; it is also what separates this from "stopped stripping read-only fields".

Known limit, deliberately fail-safe, pinned as a test

A hook that replaces the payload (ctx.input.data = { ...ctx.input.data, x: 1 }) rather than mutating it leaves no attributable record — the replacement's keys are indistinguishable from the caller's because most of them are the caller's — so seal returns undefined (not an empty set) and that call falls back to the previous value comparison, i.e. it keeps the old over-strip. Reading a replacement's keys as hook-owned would launder a caller's forged created_by into a platform write, so the fallback direction is the only safe one. Two tests pin it, including the one proving the fallback is the whole pre-repair behaviour and not a new hole. The pre-existing shallow-snapshot limit (a hook mutating a caller object in place) is unchanged, for the same argument.

Measured, including what the card did not measure

Verification tree: 7a486e32f (every reading below is from that tree, taken after the final commit — re-run there after the merge round, not carried over from the pre-merge head).

Merge round.main landed PR #14249 after this branch's merge base and re-anchored other rows of content/docs/permissions/system-context.mdx, the generated census page, leaving the PR dirty. Resolved by merging origin/main (a merge commit — never a rebase, never a force-push). The repo's merge driver deliberately does not text-merge that page and says so, which left this branch's pre-merge copy in place — that would have silently dropped #14249's rows, so main's copy was taken wholesale and the anchors re-derived from the merged tree with the repo's own pnpm gen:system-context-census. It did not refuse, and the census totals are unchanged (109 read sites / 145 anchors / 27 declared non-read), so this was line rot and not a population change. Verified against main's copy: 12 rows differ and every difference is an engine.ts line number inside backticks — all prose byte-identical.

All six content files of this PR are byte-identical across the merge (git rev-parse on each blob, efda8da21 vs 7a486e32f): only the generated page moved. That is what lets the ablation reading below stand as measured — the file it mutates, validation/rule-validator.ts, is the same blob at both heads.

Whole package, unablated, re-run on the merged tree:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 253 passed (253) · Tests 4379 passed (4379), wrapper VERDICT command-exit 0. Identical to the pre-merge run, so the merge changed nothing behavioural. Dependency closure rebuilt first (pnpm --filter '@objectstack/objectql^...' build, VERDICT command-exit 0), since main moved packages/spec and packages/types underneath it.

Ablation — the reproduction. Provenance consult deleted from rule-validator.ts (marker count 1 to 0 confirmed on disk; blob 1f205514 to 0e478d69; restored blob compared back to the HEAD blob and git diff HEAD empty). Every import in these suites is relative and in-package, so vitest runs the TS source and no dist/ is in the resolution path — the mutation changing behaviour with no rebuild is itself the proof.

7 failed | 45 passed (52) — and which 7 is the finding:

ablated caseassertion
THE REPORT: reopen also sending completed_at: nullexpected '2026-08-01T09:00:00.000Z' not to be '2026-08-01T09:00:00.000Z' — the stale timestamp, exactly as reported
the same collision on 0expected 42 to be +0
the STAMP directionexpected null to be '2026-08-05T10:00:00.000Z'
the PREDICATE branch, clearexpected '2026-08-01T09:00:00.000Z' to be null
the PREDICATE branch, stampexpected null to be '2026-08-05T10:00:00.000Z'
hook clear not reported to onFieldsDroppedone event, expected none
strictReadonlyWrites refusing a hook's own clearwrite refused

Two things that answers. The stamp direction is broken symmetrically — the card measures only the clear, and the framing is exactly as wide as the tree. And both update branches were broken, so both had to be fixed; a by-id-only repair would have left the predicate path corrupt.

Green under ablation, i.e. genuine negative controls: the bare { status } reopen, the partial patch, isSystem, every forgery face, the replacement fallback, all recorder unit tests, and the whole of engine-readonly-strip-signal.test.ts including the forgery pin. The fix moves the collision cases and nothing else.

Gates. Re-derived on the merged tree by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack with no paths passed — it read the change set itself (7 paths vs merge base a39b02a6b). The family grew from 33 to 58, because the change set now carries a content/docs/** path and that pulls in the whole docs family; nothing dropped out. All 58 run, every exit code captured by redirect-then-read and never through a pipe. 54 green, plus check:nul-bytes and check-engine-double-contract run outside the derived list. Four readings that are not greens, and none is a finding:

  • check-system-context-census — the reason for this round; green on the merged tree, and its --self-test passes alongside.
  • check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone). Report-only by default, and its exit 2 is documented as distinct from a finding. NOT MEASURED locally; the scheduled workflow checks out at full depth.
  • check-test-completeness exit 3 = PREREQUISITE NOT MET (it grades a saved turbo log; none exists locally, and its own text says to record this as NOT MEASURED). NOT MEASURED locally.
  • check:dual-build-cjs-loads exit 3 and spec check:skill-examples exit 1 — both PREREQUISITE NOT MET for the same reason: they read built dist/ output, and this container built only the @objectstack/objectql dependency closure, not all 56 packages. Both refuse rather than conclude ("⛔ This is NOT a pass: nothing was measured" / "a verdict now would be… a FALSE GREEN"), and both name packages this diff does not touch (client-react, hono, apps/*). NOT MEASURED locally; CI builds everything. They are new to this round only because the docs path widened the family — neither was a green that turned red.

Typecheckpnpm --filter @objectstack/objectql typecheck green on the merged tree too (VERDICT command-exit 0), and measured to cover the new tests rather than assumed: tsc -p tsconfig.test.json --listFiles finds all three new/edited files, and the 242 error lines are exactly the ledgered debt with none attributed to them.

Lint, as a declared narrowing. Repo-wide pnpm lint was not run; the five changed files were, --format json, 5/5 linted (none ignored), 0 errors / 0 warnings. The narrowing is measurable rather than merely narrow: this repo runs one eslint.config.mjs which never enables type-aware linting for any file — stated at eslint.config.mjs:326-335 with a measured positive control — so no untouched file's verdict can move as a consequence of this diff.

Scope

engine.ts's multi: truebatch-hook path is untouched. dispatchPerRowBeforeHooks is not modified; the predicate branch participates only by reading the same sealed record at the same shared seam. #14099 remains open and is not addressed here.

⭐ One thing worth the next dispatch's attention: this record does answer #14099's detection question. That card's own suggested direction asks for an engine that can tell "a hook mutated the payload on a multi update" — and after the seal, on the predicate branch, hookWrittenKeys is exactly that set, non-empty precisely when a batch hook wrote the shared SET clause. Deliberately not acted on here.


Session, kept in prose so it survives body edits: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5


Generated by Claude Code

…14088)
`stripReadonlyFields` asked `Object.is(payload[k], supplied[k])` to answer
"who wrote this key". Value equality cannot: when a hook writes the value the
caller also sent, the comparison reads it as "the hook never touched it" and
deletes the hook's write. Measured downstream on a `readonly` `completed_at`
cleared by a reopen hook against a caller that round-tripped the record — the
row committed `in_progress` carrying its old completion timestamp, silently.
Record the keys the before-phase hook chain actually ASSIGNS
(`recordHookPayloadWrites`), armed after the caller's entry snapshot and
sealed at the post-hook confluence both update branches share, and let the
strip keep a key a hook demonstrably wrote. Not a `null` sentinel: `0`, `''`,
`false` and shared references collide identically. Not a relaxation: a
caller-supplied read-only value no hook wrote is still stripped, and a caller
cannot enter the record because echoing a value is not an assignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…insertion (#14088)
Pure line rot: the #14088 import block shifts every subsequent engine.ts line
by five, so all 15 anchors on content/docs/permissions/system-context.mdx
moved. Repaired with the gate's own `--fix`; no prose changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/security.mdx(via stripReadonlyFields (symbol, a top-level function))
  • content/docs/protocol/objectql/state-machine.mdx(via stripReadonlyFields (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e32c142f7baffc21a5eafda24c5c8162b458f34 — the merge of head 7a486e32f78821e3decda1c49dddb767c8ad77b7 into base d63c8a25216c59b9bf2652cf5197aa526a2c00b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e32c142f7baffc21a5eafda24c5c8162b458f34 && git checkout 1e32c142f7baffc21a5eafda24c5c8162b458f34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d63c8a25216c59b9bf2652cf5197aa526a2c00b2 7a486e32f78821e3decda1c49dddb767c8ad77b7 && git checkout -B drift-repro d63c8a25216c59b9bf2652cf5197aa526a2c00b2 && git merge --no-ff 7a486e32f78821e3decda1c49dddb767c8ad77b7
node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d63c8a25216c59b9bf2652cf5197aa526a2c00b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The merge driver treats content/docs/permissions/system-context.mdx as
generated and does not text-merge it, so the merge left this branch's
pre-merge copy in place — which would have dropped the rows PR #14249 added
on main. Took main's copy wholesale and re-derived the anchors from the
MERGED tree with the repo's own tooling (pnpm gen:system-context-census).
Result verified against main's copy: 12 rows differ and every difference is
an engine.ts line number inside backticks; all prose is byte-identical, so
#14249's rows survive intact. The 15 rewritten anchors are the same 15 this
branch's engine.ts insertion shifts, and the census totals are unchanged
(109 read sites / 145 anchors / 27 declared non-read), so this is line rot
and not a population change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent - #14258

Merged
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance
Sep 2, 2026
Merged

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent#14258
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14088

stripReadonlyFields asked Object.is(payload[name], supplied[name]) to answer who wrote this key. Value equality cannot carry that question, and this is it failing: when a hook writes the value the caller also sent, the comparison reads it as the hook never touched it and deletes the hook's write.

Reproduced first, on origin/main's predicate, then fixed. The reproduction is the ablation below.

The defect, and why it is the same defect twice

#5591 / #6339 retired the key-SET judgement because it made the strip's own written contract — runtimeOwnedStripWarning's promise that "hook-written keys are NOT caller-supplied" — true only by accident. Value equality is accidental in precisely the same way, and null == null is just its most common collision. So this is #6339's residual item, not a new defect, and that framing is load-bearing: the repair is not a better comparison, it is the end of comparing.

Measured downstream (published 17.2.0, duly_task): a readonlycompleted_at stamped by a beforeUpdate hook on the transition into done and cleared on the transition out. Reopening works — until the caller also sends completed_at: null, which is what a form round-trip of the whole record does. Object.is(null, null) is true, the hook's clear is stripped, and the row commits status = in_progress still carrying its old completion timestamp, with no error. That row is the one a validation rule structurally cannot catch ("a completed task must carry a completion timestamp" has no purchase on its inverse), nothing downstream can tell it from a genuinely completed one, and every on-time metric reading completed_at counts it.

The repair: provenance, recorded — never inferred, never a sentinel

New packages/objectql/src/hook-write-provenance.ts. recordHookPayloadWrites returns a write-through view of the update payload that records the fact that an assignment executed — never the payload's contents. engine.update() arms it at hookContext construction and seals it at the post-hook confluence #13657 already uses, where hookContext.input.data is final on both update branches. stripReadonlyFields gains an optional hookWrittenKeys and keeps a key a hook demonstrably assigned.

Both branches consume the one sealed record. Not a second derivation: two notions of "a hook wrote this key" that disagree in one edge case would be worse than the defect they each closed, and a bulk write reaching a different verdict about authorship than a by-id write is the #3106 / #4441 divergence verbatim.

Not a null special case.0, '', false and a shared object reference collide identically, and the same whole-record write-back idiom echoes all of them. The 0 twin is pinned as its own test and it fails under ablation alongside the null one.

The forgery boundary — the one place a mistake is worse than the bug

A hook-owned key is a key the strip stops defending, so this is the part that had to be structural rather than careful. The record cannot be reached by caller data, because:

  • it records nothing about the object's contents — only that an assignment ran;
  • the window is armed after the caller's entry snapshot and sealed before the engine's own passes (encryptSecretFields, normalizeMultiValueFields, the strips). Between those two points the only code that runs is before-phase hook code. Sealing is not tidiness: a recorder still armed for encryptSecretFields would attribute an engine write to a hook, which on a caller-forged secret column is exactly the escalation this is built to prevent;
  • a caller cannot execute an assignment. Echoing a key, echoing a value, sending null, sending a Proxy, sending a getter — none of them is a set.

The record therefore only ever turns a strip into a keep, and only for a key a hook assigned. Every other key is decided by the two-part test that was already there, unchanged.

The forgery pin at engine-readonly-strip-signal.test.ts:290-292 is untouched and passes. It calls stripReadonlyFields with no provenance argument, and absent provenance the function is byte-identical to before.

The discriminator, pinned in both directions. The same caller payload — completed_at: null over a stored timestamp — now clears when a hook wrote the null and is still stripped (with the same warn) when no hook did. Two opposite verdicts on byte-identical caller input is what value equality cannot deliver and a record can; it is also what separates this from "stopped stripping read-only fields".

Known limit, deliberately fail-safe, pinned as a test

A hook that replaces the payload (ctx.input.data = { ...ctx.input.data, x: 1 }) rather than mutating it leaves no attributable record — the replacement's keys are indistinguishable from the caller's because most of them are the caller's — so seal returns undefined (not an empty set) and that call falls back to the previous value comparison, i.e. it keeps the old over-strip. Reading a replacement's keys as hook-owned would launder a caller's forged created_by into a platform write, so the fallback direction is the only safe one. Two tests pin it, including the one proving the fallback is the whole pre-repair behaviour and not a new hole. The pre-existing shallow-snapshot limit (a hook mutating a caller object in place) is unchanged, for the same argument.

Measured, including what the card did not measure

Verification tree: 7a486e32f (every reading below is from that tree, taken after the final commit — re-run there after the merge round, not carried over from the pre-merge head).

Merge round.main landed PR #14249 after this branch's merge base and re-anchored other rows of content/docs/permissions/system-context.mdx, the generated census page, leaving the PR dirty. Resolved by merging origin/main (a merge commit — never a rebase, never a force-push). The repo's merge driver deliberately does not text-merge that page and says so, which left this branch's pre-merge copy in place — that would have silently dropped #14249's rows, so main's copy was taken wholesale and the anchors re-derived from the merged tree with the repo's own pnpm gen:system-context-census. It did not refuse, and the census totals are unchanged (109 read sites / 145 anchors / 27 declared non-read), so this was line rot and not a population change. Verified against main's copy: 12 rows differ and every difference is an engine.ts line number inside backticks — all prose byte-identical.

All six content files of this PR are byte-identical across the merge (git rev-parse on each blob, efda8da21 vs 7a486e32f): only the generated page moved. That is what lets the ablation reading below stand as measured — the file it mutates, validation/rule-validator.ts, is the same blob at both heads.

Whole package, unablated, re-run on the merged tree:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 253 passed (253) · Tests 4379 passed (4379), wrapper VERDICT command-exit 0. Identical to the pre-merge run, so the merge changed nothing behavioural. Dependency closure rebuilt first (pnpm --filter '@objectstack/objectql^...' build, VERDICT command-exit 0), since main moved packages/spec and packages/types underneath it.

Ablation — the reproduction. Provenance consult deleted from rule-validator.ts (marker count 1 to 0 confirmed on disk; blob 1f205514 to 0e478d69; restored blob compared back to the HEAD blob and git diff HEAD empty). Every import in these suites is relative and in-package, so vitest runs the TS source and no dist/ is in the resolution path — the mutation changing behaviour with no rebuild is itself the proof.

7 failed | 45 passed (52) — and which 7 is the finding:

ablated caseassertion
THE REPORT: reopen also sending completed_at: nullexpected '2026-08-01T09:00:00.000Z' not to be '2026-08-01T09:00:00.000Z' — the stale timestamp, exactly as reported
the same collision on 0expected 42 to be +0
the STAMP directionexpected null to be '2026-08-05T10:00:00.000Z'
the PREDICATE branch, clearexpected '2026-08-01T09:00:00.000Z' to be null
the PREDICATE branch, stampexpected null to be '2026-08-05T10:00:00.000Z'
hook clear not reported to onFieldsDroppedone event, expected none
strictReadonlyWrites refusing a hook's own clearwrite refused

Two things that answers. The stamp direction is broken symmetrically — the card measures only the clear, and the framing is exactly as wide as the tree. And both update branches were broken, so both had to be fixed; a by-id-only repair would have left the predicate path corrupt.

Green under ablation, i.e. genuine negative controls: the bare { status } reopen, the partial patch, isSystem, every forgery face, the replacement fallback, all recorder unit tests, and the whole of engine-readonly-strip-signal.test.ts including the forgery pin. The fix moves the collision cases and nothing else.

Gates. Re-derived on the merged tree by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack with no paths passed — it read the change set itself (7 paths vs merge base a39b02a6b). The family grew from 33 to 58, because the change set now carries a content/docs/** path and that pulls in the whole docs family; nothing dropped out. All 58 run, every exit code captured by redirect-then-read and never through a pipe. 54 green, plus check:nul-bytes and check-engine-double-contract run outside the derived list. Four readings that are not greens, and none is a finding:

  • check-system-context-census — the reason for this round; green on the merged tree, and its --self-test passes alongside.
  • check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone). Report-only by default, and its exit 2 is documented as distinct from a finding. NOT MEASURED locally; the scheduled workflow checks out at full depth.
  • check-test-completeness exit 3 = PREREQUISITE NOT MET (it grades a saved turbo log; none exists locally, and its own text says to record this as NOT MEASURED). NOT MEASURED locally.
  • check:dual-build-cjs-loads exit 3 and spec check:skill-examples exit 1 — both PREREQUISITE NOT MET for the same reason: they read built dist/ output, and this container built only the @objectstack/objectql dependency closure, not all 56 packages. Both refuse rather than conclude ("⛔ This is NOT a pass: nothing was measured" / "a verdict now would be… a FALSE GREEN"), and both name packages this diff does not touch (client-react, hono, apps/*). NOT MEASURED locally; CI builds everything. They are new to this round only because the docs path widened the family — neither was a green that turned red.

Typecheckpnpm --filter @objectstack/objectql typecheck green on the merged tree too (VERDICT command-exit 0), and measured to cover the new tests rather than assumed: tsc -p tsconfig.test.json --listFiles finds all three new/edited files, and the 242 error lines are exactly the ledgered debt with none attributed to them.

Lint, as a declared narrowing. Repo-wide pnpm lint was not run; the five changed files were, --format json, 5/5 linted (none ignored), 0 errors / 0 warnings. The narrowing is measurable rather than merely narrow: this repo runs one eslint.config.mjs which never enables type-aware linting for any file — stated at eslint.config.mjs:326-335 with a measured positive control — so no untouched file's verdict can move as a consequence of this diff.

Scope

engine.ts's multi: truebatch-hook path is untouched. dispatchPerRowBeforeHooks is not modified; the predicate branch participates only by reading the same sealed record at the same shared seam. #14099 remains open and is not addressed here.

⭐ One thing worth the next dispatch's attention: this record does answer #14099's detection question. That card's own suggested direction asks for an engine that can tell "a hook mutated the payload on a multi update" — and after the seal, on the predicate branch, hookWrittenKeys is exactly that set, non-empty precisely when a batch hook wrote the shared SET clause. Deliberately not acted on here.


Session, kept in prose so it survives body edits: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5


Generated by Claude Code

…14088)
`stripReadonlyFields` asked `Object.is(payload[k], supplied[k])` to answer
"who wrote this key". Value equality cannot: when a hook writes the value the
caller also sent, the comparison reads it as "the hook never touched it" and
deletes the hook's write. Measured downstream on a `readonly` `completed_at`
cleared by a reopen hook against a caller that round-tripped the record — the
row committed `in_progress` carrying its old completion timestamp, silently.
Record the keys the before-phase hook chain actually ASSIGNS
(`recordHookPayloadWrites`), armed after the caller's entry snapshot and
sealed at the post-hook confluence both update branches share, and let the
strip keep a key a hook demonstrably wrote. Not a `null` sentinel: `0`, `''`,
`false` and shared references collide identically. Not a relaxation: a
caller-supplied read-only value no hook wrote is still stripped, and a caller
cannot enter the record because echoing a value is not an assignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…insertion (#14088)
Pure line rot: the #14088 import block shifts every subsequent engine.ts line
by five, so all 15 anchors on content/docs/permissions/system-context.mdx
moved. Repaired with the gate's own `--fix`; no prose changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/security.mdx(via stripReadonlyFields (symbol, a top-level function))
  • content/docs/protocol/objectql/state-machine.mdx(via stripReadonlyFields (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e32c142f7baffc21a5eafda24c5c8162b458f34 — the merge of head 7a486e32f78821e3decda1c49dddb767c8ad77b7 into base d63c8a25216c59b9bf2652cf5197aa526a2c00b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e32c142f7baffc21a5eafda24c5c8162b458f34 && git checkout 1e32c142f7baffc21a5eafda24c5c8162b458f34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d63c8a25216c59b9bf2652cf5197aa526a2c00b2 7a486e32f78821e3decda1c49dddb767c8ad77b7 && git checkout -B drift-repro d63c8a25216c59b9bf2652cf5197aa526a2c00b2 && git merge --no-ff 7a486e32f78821e3decda1c49dddb767c8ad77b7
node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d63c8a25216c59b9bf2652cf5197aa526a2c00b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The merge driver treats content/docs/permissions/system-context.mdx as
generated and does not text-merge it, so the merge left this branch's
pre-merge copy in place — which would have dropped the rows PR #14249 added
on main. Took main's copy wholesale and re-derived the anchors from the
MERGED tree with the repo's own tooling (pnpm gen:system-context-census).
Result verified against main's copy: 12 rows differ and every difference is
an engine.ts line number inside backticks; all prose is byte-identical, so
#14249's rows survive intact. The 15 rewritten anchors are the same 15 this
branch's engine.ts insertion shifts, and the census totals are unchanged
(109 read sites / 145 anchors / 27 declared non-read), so this is line rot
and not a population change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent - #14258

Merged
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance
Sep 2, 2026
Merged

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent#14258
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14088

stripReadonlyFields asked Object.is(payload[name], supplied[name]) to answer who wrote this key. Value equality cannot carry that question, and this is it failing: when a hook writes the value the caller also sent, the comparison reads it as the hook never touched it and deletes the hook's write.

Reproduced first, on origin/main's predicate, then fixed. The reproduction is the ablation below.

The defect, and why it is the same defect twice

#5591 / #6339 retired the key-SET judgement because it made the strip's own written contract — runtimeOwnedStripWarning's promise that "hook-written keys are NOT caller-supplied" — true only by accident. Value equality is accidental in precisely the same way, and null == null is just its most common collision. So this is #6339's residual item, not a new defect, and that framing is load-bearing: the repair is not a better comparison, it is the end of comparing.

Measured downstream (published 17.2.0, duly_task): a readonlycompleted_at stamped by a beforeUpdate hook on the transition into done and cleared on the transition out. Reopening works — until the caller also sends completed_at: null, which is what a form round-trip of the whole record does. Object.is(null, null) is true, the hook's clear is stripped, and the row commits status = in_progress still carrying its old completion timestamp, with no error. That row is the one a validation rule structurally cannot catch ("a completed task must carry a completion timestamp" has no purchase on its inverse), nothing downstream can tell it from a genuinely completed one, and every on-time metric reading completed_at counts it.

The repair: provenance, recorded — never inferred, never a sentinel

New packages/objectql/src/hook-write-provenance.ts. recordHookPayloadWrites returns a write-through view of the update payload that records the fact that an assignment executed — never the payload's contents. engine.update() arms it at hookContext construction and seals it at the post-hook confluence #13657 already uses, where hookContext.input.data is final on both update branches. stripReadonlyFields gains an optional hookWrittenKeys and keeps a key a hook demonstrably assigned.

Both branches consume the one sealed record. Not a second derivation: two notions of "a hook wrote this key" that disagree in one edge case would be worse than the defect they each closed, and a bulk write reaching a different verdict about authorship than a by-id write is the #3106 / #4441 divergence verbatim.

Not a null special case.0, '', false and a shared object reference collide identically, and the same whole-record write-back idiom echoes all of them. The 0 twin is pinned as its own test and it fails under ablation alongside the null one.

The forgery boundary — the one place a mistake is worse than the bug

A hook-owned key is a key the strip stops defending, so this is the part that had to be structural rather than careful. The record cannot be reached by caller data, because:

  • it records nothing about the object's contents — only that an assignment ran;
  • the window is armed after the caller's entry snapshot and sealed before the engine's own passes (encryptSecretFields, normalizeMultiValueFields, the strips). Between those two points the only code that runs is before-phase hook code. Sealing is not tidiness: a recorder still armed for encryptSecretFields would attribute an engine write to a hook, which on a caller-forged secret column is exactly the escalation this is built to prevent;
  • a caller cannot execute an assignment. Echoing a key, echoing a value, sending null, sending a Proxy, sending a getter — none of them is a set.

The record therefore only ever turns a strip into a keep, and only for a key a hook assigned. Every other key is decided by the two-part test that was already there, unchanged.

The forgery pin at engine-readonly-strip-signal.test.ts:290-292 is untouched and passes. It calls stripReadonlyFields with no provenance argument, and absent provenance the function is byte-identical to before.

The discriminator, pinned in both directions. The same caller payload — completed_at: null over a stored timestamp — now clears when a hook wrote the null and is still stripped (with the same warn) when no hook did. Two opposite verdicts on byte-identical caller input is what value equality cannot deliver and a record can; it is also what separates this from "stopped stripping read-only fields".

Known limit, deliberately fail-safe, pinned as a test

A hook that replaces the payload (ctx.input.data = { ...ctx.input.data, x: 1 }) rather than mutating it leaves no attributable record — the replacement's keys are indistinguishable from the caller's because most of them are the caller's — so seal returns undefined (not an empty set) and that call falls back to the previous value comparison, i.e. it keeps the old over-strip. Reading a replacement's keys as hook-owned would launder a caller's forged created_by into a platform write, so the fallback direction is the only safe one. Two tests pin it, including the one proving the fallback is the whole pre-repair behaviour and not a new hole. The pre-existing shallow-snapshot limit (a hook mutating a caller object in place) is unchanged, for the same argument.

Measured, including what the card did not measure

Verification tree: 7a486e32f (every reading below is from that tree, taken after the final commit — re-run there after the merge round, not carried over from the pre-merge head).

Merge round.main landed PR #14249 after this branch's merge base and re-anchored other rows of content/docs/permissions/system-context.mdx, the generated census page, leaving the PR dirty. Resolved by merging origin/main (a merge commit — never a rebase, never a force-push). The repo's merge driver deliberately does not text-merge that page and says so, which left this branch's pre-merge copy in place — that would have silently dropped #14249's rows, so main's copy was taken wholesale and the anchors re-derived from the merged tree with the repo's own pnpm gen:system-context-census. It did not refuse, and the census totals are unchanged (109 read sites / 145 anchors / 27 declared non-read), so this was line rot and not a population change. Verified against main's copy: 12 rows differ and every difference is an engine.ts line number inside backticks — all prose byte-identical.

All six content files of this PR are byte-identical across the merge (git rev-parse on each blob, efda8da21 vs 7a486e32f): only the generated page moved. That is what lets the ablation reading below stand as measured — the file it mutates, validation/rule-validator.ts, is the same blob at both heads.

Whole package, unablated, re-run on the merged tree:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 253 passed (253) · Tests 4379 passed (4379), wrapper VERDICT command-exit 0. Identical to the pre-merge run, so the merge changed nothing behavioural. Dependency closure rebuilt first (pnpm --filter '@objectstack/objectql^...' build, VERDICT command-exit 0), since main moved packages/spec and packages/types underneath it.

Ablation — the reproduction. Provenance consult deleted from rule-validator.ts (marker count 1 to 0 confirmed on disk; blob 1f205514 to 0e478d69; restored blob compared back to the HEAD blob and git diff HEAD empty). Every import in these suites is relative and in-package, so vitest runs the TS source and no dist/ is in the resolution path — the mutation changing behaviour with no rebuild is itself the proof.

7 failed | 45 passed (52) — and which 7 is the finding:

ablated caseassertion
THE REPORT: reopen also sending completed_at: nullexpected '2026-08-01T09:00:00.000Z' not to be '2026-08-01T09:00:00.000Z' — the stale timestamp, exactly as reported
the same collision on 0expected 42 to be +0
the STAMP directionexpected null to be '2026-08-05T10:00:00.000Z'
the PREDICATE branch, clearexpected '2026-08-01T09:00:00.000Z' to be null
the PREDICATE branch, stampexpected null to be '2026-08-05T10:00:00.000Z'
hook clear not reported to onFieldsDroppedone event, expected none
strictReadonlyWrites refusing a hook's own clearwrite refused

Two things that answers. The stamp direction is broken symmetrically — the card measures only the clear, and the framing is exactly as wide as the tree. And both update branches were broken, so both had to be fixed; a by-id-only repair would have left the predicate path corrupt.

Green under ablation, i.e. genuine negative controls: the bare { status } reopen, the partial patch, isSystem, every forgery face, the replacement fallback, all recorder unit tests, and the whole of engine-readonly-strip-signal.test.ts including the forgery pin. The fix moves the collision cases and nothing else.

Gates. Re-derived on the merged tree by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack with no paths passed — it read the change set itself (7 paths vs merge base a39b02a6b). The family grew from 33 to 58, because the change set now carries a content/docs/** path and that pulls in the whole docs family; nothing dropped out. All 58 run, every exit code captured by redirect-then-read and never through a pipe. 54 green, plus check:nul-bytes and check-engine-double-contract run outside the derived list. Four readings that are not greens, and none is a finding:

  • check-system-context-census — the reason for this round; green on the merged tree, and its --self-test passes alongside.
  • check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone). Report-only by default, and its exit 2 is documented as distinct from a finding. NOT MEASURED locally; the scheduled workflow checks out at full depth.
  • check-test-completeness exit 3 = PREREQUISITE NOT MET (it grades a saved turbo log; none exists locally, and its own text says to record this as NOT MEASURED). NOT MEASURED locally.
  • check:dual-build-cjs-loads exit 3 and spec check:skill-examples exit 1 — both PREREQUISITE NOT MET for the same reason: they read built dist/ output, and this container built only the @objectstack/objectql dependency closure, not all 56 packages. Both refuse rather than conclude ("⛔ This is NOT a pass: nothing was measured" / "a verdict now would be… a FALSE GREEN"), and both name packages this diff does not touch (client-react, hono, apps/*). NOT MEASURED locally; CI builds everything. They are new to this round only because the docs path widened the family — neither was a green that turned red.

Typecheckpnpm --filter @objectstack/objectql typecheck green on the merged tree too (VERDICT command-exit 0), and measured to cover the new tests rather than assumed: tsc -p tsconfig.test.json --listFiles finds all three new/edited files, and the 242 error lines are exactly the ledgered debt with none attributed to them.

Lint, as a declared narrowing. Repo-wide pnpm lint was not run; the five changed files were, --format json, 5/5 linted (none ignored), 0 errors / 0 warnings. The narrowing is measurable rather than merely narrow: this repo runs one eslint.config.mjs which never enables type-aware linting for any file — stated at eslint.config.mjs:326-335 with a measured positive control — so no untouched file's verdict can move as a consequence of this diff.

Scope

engine.ts's multi: truebatch-hook path is untouched. dispatchPerRowBeforeHooks is not modified; the predicate branch participates only by reading the same sealed record at the same shared seam. #14099 remains open and is not addressed here.

⭐ One thing worth the next dispatch's attention: this record does answer #14099's detection question. That card's own suggested direction asks for an engine that can tell "a hook mutated the payload on a multi update" — and after the seal, on the predicate branch, hookWrittenKeys is exactly that set, non-empty precisely when a batch hook wrote the shared SET clause. Deliberately not acted on here.


Session, kept in prose so it survives body edits: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5


Generated by Claude Code

…14088)
`stripReadonlyFields` asked `Object.is(payload[k], supplied[k])` to answer
"who wrote this key". Value equality cannot: when a hook writes the value the
caller also sent, the comparison reads it as "the hook never touched it" and
deletes the hook's write. Measured downstream on a `readonly` `completed_at`
cleared by a reopen hook against a caller that round-tripped the record — the
row committed `in_progress` carrying its old completion timestamp, silently.
Record the keys the before-phase hook chain actually ASSIGNS
(`recordHookPayloadWrites`), armed after the caller's entry snapshot and
sealed at the post-hook confluence both update branches share, and let the
strip keep a key a hook demonstrably wrote. Not a `null` sentinel: `0`, `''`,
`false` and shared references collide identically. Not a relaxation: a
caller-supplied read-only value no hook wrote is still stripped, and a caller
cannot enter the record because echoing a value is not an assignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…insertion (#14088)
Pure line rot: the #14088 import block shifts every subsequent engine.ts line
by five, so all 15 anchors on content/docs/permissions/system-context.mdx
moved. Repaired with the gate's own `--fix`; no prose changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/security.mdx(via stripReadonlyFields (symbol, a top-level function))
  • content/docs/protocol/objectql/state-machine.mdx(via stripReadonlyFields (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e32c142f7baffc21a5eafda24c5c8162b458f34 — the merge of head 7a486e32f78821e3decda1c49dddb767c8ad77b7 into base d63c8a25216c59b9bf2652cf5197aa526a2c00b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e32c142f7baffc21a5eafda24c5c8162b458f34 && git checkout 1e32c142f7baffc21a5eafda24c5c8162b458f34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d63c8a25216c59b9bf2652cf5197aa526a2c00b2 7a486e32f78821e3decda1c49dddb767c8ad77b7 && git checkout -B drift-repro d63c8a25216c59b9bf2652cf5197aa526a2c00b2 && git merge --no-ff 7a486e32f78821e3decda1c49dddb767c8ad77b7
node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d63c8a25216c59b9bf2652cf5197aa526a2c00b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The merge driver treats content/docs/permissions/system-context.mdx as
generated and does not text-merge it, so the merge left this branch's
pre-merge copy in place — which would have dropped the rows PR #14249 added
on main. Took main's copy wholesale and re-derived the anchors from the
MERGED tree with the repo's own tooling (pnpm gen:system-context-census).
Result verified against main's copy: 12 rows differ and every difference is
an engine.ts line number inside backticks; all prose is byte-identical, so
#14249's rows survive intact. The 15 rewritten anchors are the same 15 this
branch's engine.ts insertion shifts, and the census totals are unchanged
(109 read sites / 145 anchors / 27 declared non-read), so this is line rot
and not a population change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent - #14258

Merged
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance
Sep 2, 2026
Merged

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent#14258
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14088

stripReadonlyFields asked Object.is(payload[name], supplied[name]) to answer who wrote this key. Value equality cannot carry that question, and this is it failing: when a hook writes the value the caller also sent, the comparison reads it as the hook never touched it and deletes the hook's write.

Reproduced first, on origin/main's predicate, then fixed. The reproduction is the ablation below.

The defect, and why it is the same defect twice

#5591 / #6339 retired the key-SET judgement because it made the strip's own written contract — runtimeOwnedStripWarning's promise that "hook-written keys are NOT caller-supplied" — true only by accident. Value equality is accidental in precisely the same way, and null == null is just its most common collision. So this is #6339's residual item, not a new defect, and that framing is load-bearing: the repair is not a better comparison, it is the end of comparing.

Measured downstream (published 17.2.0, duly_task): a readonlycompleted_at stamped by a beforeUpdate hook on the transition into done and cleared on the transition out. Reopening works — until the caller also sends completed_at: null, which is what a form round-trip of the whole record does. Object.is(null, null) is true, the hook's clear is stripped, and the row commits status = in_progress still carrying its old completion timestamp, with no error. That row is the one a validation rule structurally cannot catch ("a completed task must carry a completion timestamp" has no purchase on its inverse), nothing downstream can tell it from a genuinely completed one, and every on-time metric reading completed_at counts it.

The repair: provenance, recorded — never inferred, never a sentinel

New packages/objectql/src/hook-write-provenance.ts. recordHookPayloadWrites returns a write-through view of the update payload that records the fact that an assignment executed — never the payload's contents. engine.update() arms it at hookContext construction and seals it at the post-hook confluence #13657 already uses, where hookContext.input.data is final on both update branches. stripReadonlyFields gains an optional hookWrittenKeys and keeps a key a hook demonstrably assigned.

Both branches consume the one sealed record. Not a second derivation: two notions of "a hook wrote this key" that disagree in one edge case would be worse than the defect they each closed, and a bulk write reaching a different verdict about authorship than a by-id write is the #3106 / #4441 divergence verbatim.

Not a null special case.0, '', false and a shared object reference collide identically, and the same whole-record write-back idiom echoes all of them. The 0 twin is pinned as its own test and it fails under ablation alongside the null one.

The forgery boundary — the one place a mistake is worse than the bug

A hook-owned key is a key the strip stops defending, so this is the part that had to be structural rather than careful. The record cannot be reached by caller data, because:

  • it records nothing about the object's contents — only that an assignment ran;
  • the window is armed after the caller's entry snapshot and sealed before the engine's own passes (encryptSecretFields, normalizeMultiValueFields, the strips). Between those two points the only code that runs is before-phase hook code. Sealing is not tidiness: a recorder still armed for encryptSecretFields would attribute an engine write to a hook, which on a caller-forged secret column is exactly the escalation this is built to prevent;
  • a caller cannot execute an assignment. Echoing a key, echoing a value, sending null, sending a Proxy, sending a getter — none of them is a set.

The record therefore only ever turns a strip into a keep, and only for a key a hook assigned. Every other key is decided by the two-part test that was already there, unchanged.

The forgery pin at engine-readonly-strip-signal.test.ts:290-292 is untouched and passes. It calls stripReadonlyFields with no provenance argument, and absent provenance the function is byte-identical to before.

The discriminator, pinned in both directions. The same caller payload — completed_at: null over a stored timestamp — now clears when a hook wrote the null and is still stripped (with the same warn) when no hook did. Two opposite verdicts on byte-identical caller input is what value equality cannot deliver and a record can; it is also what separates this from "stopped stripping read-only fields".

Known limit, deliberately fail-safe, pinned as a test

A hook that replaces the payload (ctx.input.data = { ...ctx.input.data, x: 1 }) rather than mutating it leaves no attributable record — the replacement's keys are indistinguishable from the caller's because most of them are the caller's — so seal returns undefined (not an empty set) and that call falls back to the previous value comparison, i.e. it keeps the old over-strip. Reading a replacement's keys as hook-owned would launder a caller's forged created_by into a platform write, so the fallback direction is the only safe one. Two tests pin it, including the one proving the fallback is the whole pre-repair behaviour and not a new hole. The pre-existing shallow-snapshot limit (a hook mutating a caller object in place) is unchanged, for the same argument.

Measured, including what the card did not measure

Verification tree: 7a486e32f (every reading below is from that tree, taken after the final commit — re-run there after the merge round, not carried over from the pre-merge head).

Merge round.main landed PR #14249 after this branch's merge base and re-anchored other rows of content/docs/permissions/system-context.mdx, the generated census page, leaving the PR dirty. Resolved by merging origin/main (a merge commit — never a rebase, never a force-push). The repo's merge driver deliberately does not text-merge that page and says so, which left this branch's pre-merge copy in place — that would have silently dropped #14249's rows, so main's copy was taken wholesale and the anchors re-derived from the merged tree with the repo's own pnpm gen:system-context-census. It did not refuse, and the census totals are unchanged (109 read sites / 145 anchors / 27 declared non-read), so this was line rot and not a population change. Verified against main's copy: 12 rows differ and every difference is an engine.ts line number inside backticks — all prose byte-identical.

All six content files of this PR are byte-identical across the merge (git rev-parse on each blob, efda8da21 vs 7a486e32f): only the generated page moved. That is what lets the ablation reading below stand as measured — the file it mutates, validation/rule-validator.ts, is the same blob at both heads.

Whole package, unablated, re-run on the merged tree:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 253 passed (253) · Tests 4379 passed (4379), wrapper VERDICT command-exit 0. Identical to the pre-merge run, so the merge changed nothing behavioural. Dependency closure rebuilt first (pnpm --filter '@objectstack/objectql^...' build, VERDICT command-exit 0), since main moved packages/spec and packages/types underneath it.

Ablation — the reproduction. Provenance consult deleted from rule-validator.ts (marker count 1 to 0 confirmed on disk; blob 1f205514 to 0e478d69; restored blob compared back to the HEAD blob and git diff HEAD empty). Every import in these suites is relative and in-package, so vitest runs the TS source and no dist/ is in the resolution path — the mutation changing behaviour with no rebuild is itself the proof.

7 failed | 45 passed (52) — and which 7 is the finding:

ablated caseassertion
THE REPORT: reopen also sending completed_at: nullexpected '2026-08-01T09:00:00.000Z' not to be '2026-08-01T09:00:00.000Z' — the stale timestamp, exactly as reported
the same collision on 0expected 42 to be +0
the STAMP directionexpected null to be '2026-08-05T10:00:00.000Z'
the PREDICATE branch, clearexpected '2026-08-01T09:00:00.000Z' to be null
the PREDICATE branch, stampexpected null to be '2026-08-05T10:00:00.000Z'
hook clear not reported to onFieldsDroppedone event, expected none
strictReadonlyWrites refusing a hook's own clearwrite refused

Two things that answers. The stamp direction is broken symmetrically — the card measures only the clear, and the framing is exactly as wide as the tree. And both update branches were broken, so both had to be fixed; a by-id-only repair would have left the predicate path corrupt.

Green under ablation, i.e. genuine negative controls: the bare { status } reopen, the partial patch, isSystem, every forgery face, the replacement fallback, all recorder unit tests, and the whole of engine-readonly-strip-signal.test.ts including the forgery pin. The fix moves the collision cases and nothing else.

Gates. Re-derived on the merged tree by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack with no paths passed — it read the change set itself (7 paths vs merge base a39b02a6b). The family grew from 33 to 58, because the change set now carries a content/docs/** path and that pulls in the whole docs family; nothing dropped out. All 58 run, every exit code captured by redirect-then-read and never through a pipe. 54 green, plus check:nul-bytes and check-engine-double-contract run outside the derived list. Four readings that are not greens, and none is a finding:

  • check-system-context-census — the reason for this round; green on the merged tree, and its --self-test passes alongside.
  • check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone). Report-only by default, and its exit 2 is documented as distinct from a finding. NOT MEASURED locally; the scheduled workflow checks out at full depth.
  • check-test-completeness exit 3 = PREREQUISITE NOT MET (it grades a saved turbo log; none exists locally, and its own text says to record this as NOT MEASURED). NOT MEASURED locally.
  • check:dual-build-cjs-loads exit 3 and spec check:skill-examples exit 1 — both PREREQUISITE NOT MET for the same reason: they read built dist/ output, and this container built only the @objectstack/objectql dependency closure, not all 56 packages. Both refuse rather than conclude ("⛔ This is NOT a pass: nothing was measured" / "a verdict now would be… a FALSE GREEN"), and both name packages this diff does not touch (client-react, hono, apps/*). NOT MEASURED locally; CI builds everything. They are new to this round only because the docs path widened the family — neither was a green that turned red.

Typecheckpnpm --filter @objectstack/objectql typecheck green on the merged tree too (VERDICT command-exit 0), and measured to cover the new tests rather than assumed: tsc -p tsconfig.test.json --listFiles finds all three new/edited files, and the 242 error lines are exactly the ledgered debt with none attributed to them.

Lint, as a declared narrowing. Repo-wide pnpm lint was not run; the five changed files were, --format json, 5/5 linted (none ignored), 0 errors / 0 warnings. The narrowing is measurable rather than merely narrow: this repo runs one eslint.config.mjs which never enables type-aware linting for any file — stated at eslint.config.mjs:326-335 with a measured positive control — so no untouched file's verdict can move as a consequence of this diff.

Scope

engine.ts's multi: truebatch-hook path is untouched. dispatchPerRowBeforeHooks is not modified; the predicate branch participates only by reading the same sealed record at the same shared seam. #14099 remains open and is not addressed here.

⭐ One thing worth the next dispatch's attention: this record does answer #14099's detection question. That card's own suggested direction asks for an engine that can tell "a hook mutated the payload on a multi update" — and after the seal, on the predicate branch, hookWrittenKeys is exactly that set, non-empty precisely when a batch hook wrote the shared SET clause. Deliberately not acted on here.


Session, kept in prose so it survives body edits: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5


Generated by Claude Code

…14088)
`stripReadonlyFields` asked `Object.is(payload[k], supplied[k])` to answer
"who wrote this key". Value equality cannot: when a hook writes the value the
caller also sent, the comparison reads it as "the hook never touched it" and
deletes the hook's write. Measured downstream on a `readonly` `completed_at`
cleared by a reopen hook against a caller that round-tripped the record — the
row committed `in_progress` carrying its old completion timestamp, silently.
Record the keys the before-phase hook chain actually ASSIGNS
(`recordHookPayloadWrites`), armed after the caller's entry snapshot and
sealed at the post-hook confluence both update branches share, and let the
strip keep a key a hook demonstrably wrote. Not a `null` sentinel: `0`, `''`,
`false` and shared references collide identically. Not a relaxation: a
caller-supplied read-only value no hook wrote is still stripped, and a caller
cannot enter the record because echoing a value is not an assignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…insertion (#14088)
Pure line rot: the #14088 import block shifts every subsequent engine.ts line
by five, so all 15 anchors on content/docs/permissions/system-context.mdx
moved. Repaired with the gate's own `--fix`; no prose changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/security.mdx(via stripReadonlyFields (symbol, a top-level function))
  • content/docs/protocol/objectql/state-machine.mdx(via stripReadonlyFields (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e32c142f7baffc21a5eafda24c5c8162b458f34 — the merge of head 7a486e32f78821e3decda1c49dddb767c8ad77b7 into base d63c8a25216c59b9bf2652cf5197aa526a2c00b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e32c142f7baffc21a5eafda24c5c8162b458f34 && git checkout 1e32c142f7baffc21a5eafda24c5c8162b458f34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d63c8a25216c59b9bf2652cf5197aa526a2c00b2 7a486e32f78821e3decda1c49dddb767c8ad77b7 && git checkout -B drift-repro d63c8a25216c59b9bf2652cf5197aa526a2c00b2 && git merge --no-ff 7a486e32f78821e3decda1c49dddb767c8ad77b7
node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d63c8a25216c59b9bf2652cf5197aa526a2c00b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The merge driver treats content/docs/permissions/system-context.mdx as
generated and does not text-merge it, so the merge left this branch's
pre-merge copy in place — which would have dropped the rows PR #14249 added
on main. Took main's copy wholesale and re-derived the anchors from the
MERGED tree with the repo's own tooling (pnpm gen:system-context-census).
Result verified against main's copy: 12 rows differ and every difference is
an engine.ts line number inside backticks; all prose is byte-identical, so
#14249's rows survive intact. The 15 rewritten anchors are the same 15 this
branch's engine.ts insertion shifts, and the census totals are unchanged
(109 read sites / 145 anchors / 27 declared non-read), so this is line rot
and not a population change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent - #14258

Merged
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance
Sep 2, 2026
Merged

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent#14258
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14088

stripReadonlyFields asked Object.is(payload[name], supplied[name]) to answer who wrote this key. Value equality cannot carry that question, and this is it failing: when a hook writes the value the caller also sent, the comparison reads it as the hook never touched it and deletes the hook's write.

Reproduced first, on origin/main's predicate, then fixed. The reproduction is the ablation below.

The defect, and why it is the same defect twice

#5591 / #6339 retired the key-SET judgement because it made the strip's own written contract — runtimeOwnedStripWarning's promise that "hook-written keys are NOT caller-supplied" — true only by accident. Value equality is accidental in precisely the same way, and null == null is just its most common collision. So this is #6339's residual item, not a new defect, and that framing is load-bearing: the repair is not a better comparison, it is the end of comparing.

Measured downstream (published 17.2.0, duly_task): a readonlycompleted_at stamped by a beforeUpdate hook on the transition into done and cleared on the transition out. Reopening works — until the caller also sends completed_at: null, which is what a form round-trip of the whole record does. Object.is(null, null) is true, the hook's clear is stripped, and the row commits status = in_progress still carrying its old completion timestamp, with no error. That row is the one a validation rule structurally cannot catch ("a completed task must carry a completion timestamp" has no purchase on its inverse), nothing downstream can tell it from a genuinely completed one, and every on-time metric reading completed_at counts it.

The repair: provenance, recorded — never inferred, never a sentinel

New packages/objectql/src/hook-write-provenance.ts. recordHookPayloadWrites returns a write-through view of the update payload that records the fact that an assignment executed — never the payload's contents. engine.update() arms it at hookContext construction and seals it at the post-hook confluence #13657 already uses, where hookContext.input.data is final on both update branches. stripReadonlyFields gains an optional hookWrittenKeys and keeps a key a hook demonstrably assigned.

Both branches consume the one sealed record. Not a second derivation: two notions of "a hook wrote this key" that disagree in one edge case would be worse than the defect they each closed, and a bulk write reaching a different verdict about authorship than a by-id write is the #3106 / #4441 divergence verbatim.

Not a null special case.0, '', false and a shared object reference collide identically, and the same whole-record write-back idiom echoes all of them. The 0 twin is pinned as its own test and it fails under ablation alongside the null one.

The forgery boundary — the one place a mistake is worse than the bug

A hook-owned key is a key the strip stops defending, so this is the part that had to be structural rather than careful. The record cannot be reached by caller data, because:

  • it records nothing about the object's contents — only that an assignment ran;
  • the window is armed after the caller's entry snapshot and sealed before the engine's own passes (encryptSecretFields, normalizeMultiValueFields, the strips). Between those two points the only code that runs is before-phase hook code. Sealing is not tidiness: a recorder still armed for encryptSecretFields would attribute an engine write to a hook, which on a caller-forged secret column is exactly the escalation this is built to prevent;
  • a caller cannot execute an assignment. Echoing a key, echoing a value, sending null, sending a Proxy, sending a getter — none of them is a set.

The record therefore only ever turns a strip into a keep, and only for a key a hook assigned. Every other key is decided by the two-part test that was already there, unchanged.

The forgery pin at engine-readonly-strip-signal.test.ts:290-292 is untouched and passes. It calls stripReadonlyFields with no provenance argument, and absent provenance the function is byte-identical to before.

The discriminator, pinned in both directions. The same caller payload — completed_at: null over a stored timestamp — now clears when a hook wrote the null and is still stripped (with the same warn) when no hook did. Two opposite verdicts on byte-identical caller input is what value equality cannot deliver and a record can; it is also what separates this from "stopped stripping read-only fields".

Known limit, deliberately fail-safe, pinned as a test

A hook that replaces the payload (ctx.input.data = { ...ctx.input.data, x: 1 }) rather than mutating it leaves no attributable record — the replacement's keys are indistinguishable from the caller's because most of them are the caller's — so seal returns undefined (not an empty set) and that call falls back to the previous value comparison, i.e. it keeps the old over-strip. Reading a replacement's keys as hook-owned would launder a caller's forged created_by into a platform write, so the fallback direction is the only safe one. Two tests pin it, including the one proving the fallback is the whole pre-repair behaviour and not a new hole. The pre-existing shallow-snapshot limit (a hook mutating a caller object in place) is unchanged, for the same argument.

Measured, including what the card did not measure

Verification tree: 7a486e32f (every reading below is from that tree, taken after the final commit — re-run there after the merge round, not carried over from the pre-merge head).

Merge round.main landed PR #14249 after this branch's merge base and re-anchored other rows of content/docs/permissions/system-context.mdx, the generated census page, leaving the PR dirty. Resolved by merging origin/main (a merge commit — never a rebase, never a force-push). The repo's merge driver deliberately does not text-merge that page and says so, which left this branch's pre-merge copy in place — that would have silently dropped #14249's rows, so main's copy was taken wholesale and the anchors re-derived from the merged tree with the repo's own pnpm gen:system-context-census. It did not refuse, and the census totals are unchanged (109 read sites / 145 anchors / 27 declared non-read), so this was line rot and not a population change. Verified against main's copy: 12 rows differ and every difference is an engine.ts line number inside backticks — all prose byte-identical.

All six content files of this PR are byte-identical across the merge (git rev-parse on each blob, efda8da21 vs 7a486e32f): only the generated page moved. That is what lets the ablation reading below stand as measured — the file it mutates, validation/rule-validator.ts, is the same blob at both heads.

Whole package, unablated, re-run on the merged tree:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 253 passed (253) · Tests 4379 passed (4379), wrapper VERDICT command-exit 0. Identical to the pre-merge run, so the merge changed nothing behavioural. Dependency closure rebuilt first (pnpm --filter '@objectstack/objectql^...' build, VERDICT command-exit 0), since main moved packages/spec and packages/types underneath it.

Ablation — the reproduction. Provenance consult deleted from rule-validator.ts (marker count 1 to 0 confirmed on disk; blob 1f205514 to 0e478d69; restored blob compared back to the HEAD blob and git diff HEAD empty). Every import in these suites is relative and in-package, so vitest runs the TS source and no dist/ is in the resolution path — the mutation changing behaviour with no rebuild is itself the proof.

7 failed | 45 passed (52) — and which 7 is the finding:

ablated caseassertion
THE REPORT: reopen also sending completed_at: nullexpected '2026-08-01T09:00:00.000Z' not to be '2026-08-01T09:00:00.000Z' — the stale timestamp, exactly as reported
the same collision on 0expected 42 to be +0
the STAMP directionexpected null to be '2026-08-05T10:00:00.000Z'
the PREDICATE branch, clearexpected '2026-08-01T09:00:00.000Z' to be null
the PREDICATE branch, stampexpected null to be '2026-08-05T10:00:00.000Z'
hook clear not reported to onFieldsDroppedone event, expected none
strictReadonlyWrites refusing a hook's own clearwrite refused

Two things that answers. The stamp direction is broken symmetrically — the card measures only the clear, and the framing is exactly as wide as the tree. And both update branches were broken, so both had to be fixed; a by-id-only repair would have left the predicate path corrupt.

Green under ablation, i.e. genuine negative controls: the bare { status } reopen, the partial patch, isSystem, every forgery face, the replacement fallback, all recorder unit tests, and the whole of engine-readonly-strip-signal.test.ts including the forgery pin. The fix moves the collision cases and nothing else.

Gates. Re-derived on the merged tree by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack with no paths passed — it read the change set itself (7 paths vs merge base a39b02a6b). The family grew from 33 to 58, because the change set now carries a content/docs/** path and that pulls in the whole docs family; nothing dropped out. All 58 run, every exit code captured by redirect-then-read and never through a pipe. 54 green, plus check:nul-bytes and check-engine-double-contract run outside the derived list. Four readings that are not greens, and none is a finding:

  • check-system-context-census — the reason for this round; green on the merged tree, and its --self-test passes alongside.
  • check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone). Report-only by default, and its exit 2 is documented as distinct from a finding. NOT MEASURED locally; the scheduled workflow checks out at full depth.
  • check-test-completeness exit 3 = PREREQUISITE NOT MET (it grades a saved turbo log; none exists locally, and its own text says to record this as NOT MEASURED). NOT MEASURED locally.
  • check:dual-build-cjs-loads exit 3 and spec check:skill-examples exit 1 — both PREREQUISITE NOT MET for the same reason: they read built dist/ output, and this container built only the @objectstack/objectql dependency closure, not all 56 packages. Both refuse rather than conclude ("⛔ This is NOT a pass: nothing was measured" / "a verdict now would be… a FALSE GREEN"), and both name packages this diff does not touch (client-react, hono, apps/*). NOT MEASURED locally; CI builds everything. They are new to this round only because the docs path widened the family — neither was a green that turned red.

Typecheckpnpm --filter @objectstack/objectql typecheck green on the merged tree too (VERDICT command-exit 0), and measured to cover the new tests rather than assumed: tsc -p tsconfig.test.json --listFiles finds all three new/edited files, and the 242 error lines are exactly the ledgered debt with none attributed to them.

Lint, as a declared narrowing. Repo-wide pnpm lint was not run; the five changed files were, --format json, 5/5 linted (none ignored), 0 errors / 0 warnings. The narrowing is measurable rather than merely narrow: this repo runs one eslint.config.mjs which never enables type-aware linting for any file — stated at eslint.config.mjs:326-335 with a measured positive control — so no untouched file's verdict can move as a consequence of this diff.

Scope

engine.ts's multi: truebatch-hook path is untouched. dispatchPerRowBeforeHooks is not modified; the predicate branch participates only by reading the same sealed record at the same shared seam. #14099 remains open and is not addressed here.

⭐ One thing worth the next dispatch's attention: this record does answer #14099's detection question. That card's own suggested direction asks for an engine that can tell "a hook mutated the payload on a multi update" — and after the seal, on the predicate branch, hookWrittenKeys is exactly that set, non-empty precisely when a batch hook wrote the shared SET clause. Deliberately not acted on here.


Session, kept in prose so it survives body edits: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5


Generated by Claude Code

…14088)
`stripReadonlyFields` asked `Object.is(payload[k], supplied[k])` to answer
"who wrote this key". Value equality cannot: when a hook writes the value the
caller also sent, the comparison reads it as "the hook never touched it" and
deletes the hook's write. Measured downstream on a `readonly` `completed_at`
cleared by a reopen hook against a caller that round-tripped the record — the
row committed `in_progress` carrying its old completion timestamp, silently.
Record the keys the before-phase hook chain actually ASSIGNS
(`recordHookPayloadWrites`), armed after the caller's entry snapshot and
sealed at the post-hook confluence both update branches share, and let the
strip keep a key a hook demonstrably wrote. Not a `null` sentinel: `0`, `''`,
`false` and shared references collide identically. Not a relaxation: a
caller-supplied read-only value no hook wrote is still stripped, and a caller
cannot enter the record because echoing a value is not an assignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…insertion (#14088)
Pure line rot: the #14088 import block shifts every subsequent engine.ts line
by five, so all 15 anchors on content/docs/permissions/system-context.mdx
moved. Repaired with the gate's own `--fix`; no prose changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/security.mdx(via stripReadonlyFields (symbol, a top-level function))
  • content/docs/protocol/objectql/state-machine.mdx(via stripReadonlyFields (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e32c142f7baffc21a5eafda24c5c8162b458f34 — the merge of head 7a486e32f78821e3decda1c49dddb767c8ad77b7 into base d63c8a25216c59b9bf2652cf5197aa526a2c00b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e32c142f7baffc21a5eafda24c5c8162b458f34 && git checkout 1e32c142f7baffc21a5eafda24c5c8162b458f34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d63c8a25216c59b9bf2652cf5197aa526a2c00b2 7a486e32f78821e3decda1c49dddb767c8ad77b7 && git checkout -B drift-repro d63c8a25216c59b9bf2652cf5197aa526a2c00b2 && git merge --no-ff 7a486e32f78821e3decda1c49dddb767c8ad77b7
node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d63c8a25216c59b9bf2652cf5197aa526a2c00b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The merge driver treats content/docs/permissions/system-context.mdx as
generated and does not text-merge it, so the merge left this branch's
pre-merge copy in place — which would have dropped the rows PR #14249 added
on main. Took main's copy wholesale and re-derived the anchors from the
MERGED tree with the repo's own tooling (pnpm gen:system-context-census).
Result verified against main's copy: 12 rows differ and every difference is
an engine.ts line number inside backticks; all prose is byte-identical, so
#14249's rows survive intact. The 15 rewritten anchors are the same 15 this
branch's engine.ts insertion shifts, and the census totals are unchanged
(109 read sites / 145 anchors / 27 declared non-read), so this is line rot
and not a population change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent - #14258

Merged
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance
Sep 2, 2026
Merged

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent#14258
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14088

stripReadonlyFields asked Object.is(payload[name], supplied[name]) to answer who wrote this key. Value equality cannot carry that question, and this is it failing: when a hook writes the value the caller also sent, the comparison reads it as the hook never touched it and deletes the hook's write.

Reproduced first, on origin/main's predicate, then fixed. The reproduction is the ablation below.

The defect, and why it is the same defect twice

#5591 / #6339 retired the key-SET judgement because it made the strip's own written contract — runtimeOwnedStripWarning's promise that "hook-written keys are NOT caller-supplied" — true only by accident. Value equality is accidental in precisely the same way, and null == null is just its most common collision. So this is #6339's residual item, not a new defect, and that framing is load-bearing: the repair is not a better comparison, it is the end of comparing.

Measured downstream (published 17.2.0, duly_task): a readonlycompleted_at stamped by a beforeUpdate hook on the transition into done and cleared on the transition out. Reopening works — until the caller also sends completed_at: null, which is what a form round-trip of the whole record does. Object.is(null, null) is true, the hook's clear is stripped, and the row commits status = in_progress still carrying its old completion timestamp, with no error. That row is the one a validation rule structurally cannot catch ("a completed task must carry a completion timestamp" has no purchase on its inverse), nothing downstream can tell it from a genuinely completed one, and every on-time metric reading completed_at counts it.

The repair: provenance, recorded — never inferred, never a sentinel

New packages/objectql/src/hook-write-provenance.ts. recordHookPayloadWrites returns a write-through view of the update payload that records the fact that an assignment executed — never the payload's contents. engine.update() arms it at hookContext construction and seals it at the post-hook confluence #13657 already uses, where hookContext.input.data is final on both update branches. stripReadonlyFields gains an optional hookWrittenKeys and keeps a key a hook demonstrably assigned.

Both branches consume the one sealed record. Not a second derivation: two notions of "a hook wrote this key" that disagree in one edge case would be worse than the defect they each closed, and a bulk write reaching a different verdict about authorship than a by-id write is the #3106 / #4441 divergence verbatim.

Not a null special case.0, '', false and a shared object reference collide identically, and the same whole-record write-back idiom echoes all of them. The 0 twin is pinned as its own test and it fails under ablation alongside the null one.

The forgery boundary — the one place a mistake is worse than the bug

A hook-owned key is a key the strip stops defending, so this is the part that had to be structural rather than careful. The record cannot be reached by caller data, because:

  • it records nothing about the object's contents — only that an assignment ran;
  • the window is armed after the caller's entry snapshot and sealed before the engine's own passes (encryptSecretFields, normalizeMultiValueFields, the strips). Between those two points the only code that runs is before-phase hook code. Sealing is not tidiness: a recorder still armed for encryptSecretFields would attribute an engine write to a hook, which on a caller-forged secret column is exactly the escalation this is built to prevent;
  • a caller cannot execute an assignment. Echoing a key, echoing a value, sending null, sending a Proxy, sending a getter — none of them is a set.

The record therefore only ever turns a strip into a keep, and only for a key a hook assigned. Every other key is decided by the two-part test that was already there, unchanged.

The forgery pin at engine-readonly-strip-signal.test.ts:290-292 is untouched and passes. It calls stripReadonlyFields with no provenance argument, and absent provenance the function is byte-identical to before.

The discriminator, pinned in both directions. The same caller payload — completed_at: null over a stored timestamp — now clears when a hook wrote the null and is still stripped (with the same warn) when no hook did. Two opposite verdicts on byte-identical caller input is what value equality cannot deliver and a record can; it is also what separates this from "stopped stripping read-only fields".

Known limit, deliberately fail-safe, pinned as a test

A hook that replaces the payload (ctx.input.data = { ...ctx.input.data, x: 1 }) rather than mutating it leaves no attributable record — the replacement's keys are indistinguishable from the caller's because most of them are the caller's — so seal returns undefined (not an empty set) and that call falls back to the previous value comparison, i.e. it keeps the old over-strip. Reading a replacement's keys as hook-owned would launder a caller's forged created_by into a platform write, so the fallback direction is the only safe one. Two tests pin it, including the one proving the fallback is the whole pre-repair behaviour and not a new hole. The pre-existing shallow-snapshot limit (a hook mutating a caller object in place) is unchanged, for the same argument.

Measured, including what the card did not measure

Verification tree: 7a486e32f (every reading below is from that tree, taken after the final commit — re-run there after the merge round, not carried over from the pre-merge head).

Merge round.main landed PR #14249 after this branch's merge base and re-anchored other rows of content/docs/permissions/system-context.mdx, the generated census page, leaving the PR dirty. Resolved by merging origin/main (a merge commit — never a rebase, never a force-push). The repo's merge driver deliberately does not text-merge that page and says so, which left this branch's pre-merge copy in place — that would have silently dropped #14249's rows, so main's copy was taken wholesale and the anchors re-derived from the merged tree with the repo's own pnpm gen:system-context-census. It did not refuse, and the census totals are unchanged (109 read sites / 145 anchors / 27 declared non-read), so this was line rot and not a population change. Verified against main's copy: 12 rows differ and every difference is an engine.ts line number inside backticks — all prose byte-identical.

All six content files of this PR are byte-identical across the merge (git rev-parse on each blob, efda8da21 vs 7a486e32f): only the generated page moved. That is what lets the ablation reading below stand as measured — the file it mutates, validation/rule-validator.ts, is the same blob at both heads.

Whole package, unablated, re-run on the merged tree:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 253 passed (253) · Tests 4379 passed (4379), wrapper VERDICT command-exit 0. Identical to the pre-merge run, so the merge changed nothing behavioural. Dependency closure rebuilt first (pnpm --filter '@objectstack/objectql^...' build, VERDICT command-exit 0), since main moved packages/spec and packages/types underneath it.

Ablation — the reproduction. Provenance consult deleted from rule-validator.ts (marker count 1 to 0 confirmed on disk; blob 1f205514 to 0e478d69; restored blob compared back to the HEAD blob and git diff HEAD empty). Every import in these suites is relative and in-package, so vitest runs the TS source and no dist/ is in the resolution path — the mutation changing behaviour with no rebuild is itself the proof.

7 failed | 45 passed (52) — and which 7 is the finding:

ablated caseassertion
THE REPORT: reopen also sending completed_at: nullexpected '2026-08-01T09:00:00.000Z' not to be '2026-08-01T09:00:00.000Z' — the stale timestamp, exactly as reported
the same collision on 0expected 42 to be +0
the STAMP directionexpected null to be '2026-08-05T10:00:00.000Z'
the PREDICATE branch, clearexpected '2026-08-01T09:00:00.000Z' to be null
the PREDICATE branch, stampexpected null to be '2026-08-05T10:00:00.000Z'
hook clear not reported to onFieldsDroppedone event, expected none
strictReadonlyWrites refusing a hook's own clearwrite refused

Two things that answers. The stamp direction is broken symmetrically — the card measures only the clear, and the framing is exactly as wide as the tree. And both update branches were broken, so both had to be fixed; a by-id-only repair would have left the predicate path corrupt.

Green under ablation, i.e. genuine negative controls: the bare { status } reopen, the partial patch, isSystem, every forgery face, the replacement fallback, all recorder unit tests, and the whole of engine-readonly-strip-signal.test.ts including the forgery pin. The fix moves the collision cases and nothing else.

Gates. Re-derived on the merged tree by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack with no paths passed — it read the change set itself (7 paths vs merge base a39b02a6b). The family grew from 33 to 58, because the change set now carries a content/docs/** path and that pulls in the whole docs family; nothing dropped out. All 58 run, every exit code captured by redirect-then-read and never through a pipe. 54 green, plus check:nul-bytes and check-engine-double-contract run outside the derived list. Four readings that are not greens, and none is a finding:

  • check-system-context-census — the reason for this round; green on the merged tree, and its --self-test passes alongside.
  • check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone). Report-only by default, and its exit 2 is documented as distinct from a finding. NOT MEASURED locally; the scheduled workflow checks out at full depth.
  • check-test-completeness exit 3 = PREREQUISITE NOT MET (it grades a saved turbo log; none exists locally, and its own text says to record this as NOT MEASURED). NOT MEASURED locally.
  • check:dual-build-cjs-loads exit 3 and spec check:skill-examples exit 1 — both PREREQUISITE NOT MET for the same reason: they read built dist/ output, and this container built only the @objectstack/objectql dependency closure, not all 56 packages. Both refuse rather than conclude ("⛔ This is NOT a pass: nothing was measured" / "a verdict now would be… a FALSE GREEN"), and both name packages this diff does not touch (client-react, hono, apps/*). NOT MEASURED locally; CI builds everything. They are new to this round only because the docs path widened the family — neither was a green that turned red.

Typecheckpnpm --filter @objectstack/objectql typecheck green on the merged tree too (VERDICT command-exit 0), and measured to cover the new tests rather than assumed: tsc -p tsconfig.test.json --listFiles finds all three new/edited files, and the 242 error lines are exactly the ledgered debt with none attributed to them.

Lint, as a declared narrowing. Repo-wide pnpm lint was not run; the five changed files were, --format json, 5/5 linted (none ignored), 0 errors / 0 warnings. The narrowing is measurable rather than merely narrow: this repo runs one eslint.config.mjs which never enables type-aware linting for any file — stated at eslint.config.mjs:326-335 with a measured positive control — so no untouched file's verdict can move as a consequence of this diff.

Scope

engine.ts's multi: truebatch-hook path is untouched. dispatchPerRowBeforeHooks is not modified; the predicate branch participates only by reading the same sealed record at the same shared seam. #14099 remains open and is not addressed here.

⭐ One thing worth the next dispatch's attention: this record does answer #14099's detection question. That card's own suggested direction asks for an engine that can tell "a hook mutated the payload on a multi update" — and after the seal, on the predicate branch, hookWrittenKeys is exactly that set, non-empty precisely when a batch hook wrote the shared SET clause. Deliberately not acted on here.


Session, kept in prose so it survives body edits: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5


Generated by Claude Code

…14088)
`stripReadonlyFields` asked `Object.is(payload[k], supplied[k])` to answer
"who wrote this key". Value equality cannot: when a hook writes the value the
caller also sent, the comparison reads it as "the hook never touched it" and
deletes the hook's write. Measured downstream on a `readonly` `completed_at`
cleared by a reopen hook against a caller that round-tripped the record — the
row committed `in_progress` carrying its old completion timestamp, silently.
Record the keys the before-phase hook chain actually ASSIGNS
(`recordHookPayloadWrites`), armed after the caller's entry snapshot and
sealed at the post-hook confluence both update branches share, and let the
strip keep a key a hook demonstrably wrote. Not a `null` sentinel: `0`, `''`,
`false` and shared references collide identically. Not a relaxation: a
caller-supplied read-only value no hook wrote is still stripped, and a caller
cannot enter the record because echoing a value is not an assignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…insertion (#14088)
Pure line rot: the #14088 import block shifts every subsequent engine.ts line
by five, so all 15 anchors on content/docs/permissions/system-context.mdx
moved. Repaired with the gate's own `--fix`; no prose changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/security.mdx(via stripReadonlyFields (symbol, a top-level function))
  • content/docs/protocol/objectql/state-machine.mdx(via stripReadonlyFields (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e32c142f7baffc21a5eafda24c5c8162b458f34 — the merge of head 7a486e32f78821e3decda1c49dddb767c8ad77b7 into base d63c8a25216c59b9bf2652cf5197aa526a2c00b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e32c142f7baffc21a5eafda24c5c8162b458f34 && git checkout 1e32c142f7baffc21a5eafda24c5c8162b458f34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d63c8a25216c59b9bf2652cf5197aa526a2c00b2 7a486e32f78821e3decda1c49dddb767c8ad77b7 && git checkout -B drift-repro d63c8a25216c59b9bf2652cf5197aa526a2c00b2 && git merge --no-ff 7a486e32f78821e3decda1c49dddb767c8ad77b7
node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d63c8a25216c59b9bf2652cf5197aa526a2c00b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The merge driver treats content/docs/permissions/system-context.mdx as
generated and does not text-merge it, so the merge left this branch's
pre-merge copy in place — which would have dropped the rows PR #14249 added
on main. Took main's copy wholesale and re-derived the anchors from the
MERGED tree with the repo's own tooling (pnpm gen:system-context-census).
Result verified against main's copy: 12 rows differ and every difference is
an engine.ts line number inside backticks; all prose is byte-identical, so
#14249's rows survive intact. The 15 rewritten anchors are the same 15 this
branch's engine.ts insertion shifts, and the census totals are unchanged
(109 read sites / 145 anchors / 27 declared non-read), so this is line rot
and not a population change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent - #14258

Merged
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance
Sep 2, 2026
Merged

fix(objectql): decide the read-only strip on hook-write provenance, so a hook can clear a field the caller also sent#14258
os-support-ai merged 4 commits into
mainfrom
claude/issue-14088-readonly-strip-provenance

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14088

stripReadonlyFields asked Object.is(payload[name], supplied[name]) to answer who wrote this key. Value equality cannot carry that question, and this is it failing: when a hook writes the value the caller also sent, the comparison reads it as the hook never touched it and deletes the hook's write.

Reproduced first, on origin/main's predicate, then fixed. The reproduction is the ablation below.

The defect, and why it is the same defect twice

#5591 / #6339 retired the key-SET judgement because it made the strip's own written contract — runtimeOwnedStripWarning's promise that "hook-written keys are NOT caller-supplied" — true only by accident. Value equality is accidental in precisely the same way, and null == null is just its most common collision. So this is #6339's residual item, not a new defect, and that framing is load-bearing: the repair is not a better comparison, it is the end of comparing.

Measured downstream (published 17.2.0, duly_task): a readonlycompleted_at stamped by a beforeUpdate hook on the transition into done and cleared on the transition out. Reopening works — until the caller also sends completed_at: null, which is what a form round-trip of the whole record does. Object.is(null, null) is true, the hook's clear is stripped, and the row commits status = in_progress still carrying its old completion timestamp, with no error. That row is the one a validation rule structurally cannot catch ("a completed task must carry a completion timestamp" has no purchase on its inverse), nothing downstream can tell it from a genuinely completed one, and every on-time metric reading completed_at counts it.

The repair: provenance, recorded — never inferred, never a sentinel

New packages/objectql/src/hook-write-provenance.ts. recordHookPayloadWrites returns a write-through view of the update payload that records the fact that an assignment executed — never the payload's contents. engine.update() arms it at hookContext construction and seals it at the post-hook confluence #13657 already uses, where hookContext.input.data is final on both update branches. stripReadonlyFields gains an optional hookWrittenKeys and keeps a key a hook demonstrably assigned.

Both branches consume the one sealed record. Not a second derivation: two notions of "a hook wrote this key" that disagree in one edge case would be worse than the defect they each closed, and a bulk write reaching a different verdict about authorship than a by-id write is the #3106 / #4441 divergence verbatim.

Not a null special case.0, '', false and a shared object reference collide identically, and the same whole-record write-back idiom echoes all of them. The 0 twin is pinned as its own test and it fails under ablation alongside the null one.

The forgery boundary — the one place a mistake is worse than the bug

A hook-owned key is a key the strip stops defending, so this is the part that had to be structural rather than careful. The record cannot be reached by caller data, because:

  • it records nothing about the object's contents — only that an assignment ran;
  • the window is armed after the caller's entry snapshot and sealed before the engine's own passes (encryptSecretFields, normalizeMultiValueFields, the strips). Between those two points the only code that runs is before-phase hook code. Sealing is not tidiness: a recorder still armed for encryptSecretFields would attribute an engine write to a hook, which on a caller-forged secret column is exactly the escalation this is built to prevent;
  • a caller cannot execute an assignment. Echoing a key, echoing a value, sending null, sending a Proxy, sending a getter — none of them is a set.

The record therefore only ever turns a strip into a keep, and only for a key a hook assigned. Every other key is decided by the two-part test that was already there, unchanged.

The forgery pin at engine-readonly-strip-signal.test.ts:290-292 is untouched and passes. It calls stripReadonlyFields with no provenance argument, and absent provenance the function is byte-identical to before.

The discriminator, pinned in both directions. The same caller payload — completed_at: null over a stored timestamp — now clears when a hook wrote the null and is still stripped (with the same warn) when no hook did. Two opposite verdicts on byte-identical caller input is what value equality cannot deliver and a record can; it is also what separates this from "stopped stripping read-only fields".

Known limit, deliberately fail-safe, pinned as a test

A hook that replaces the payload (ctx.input.data = { ...ctx.input.data, x: 1 }) rather than mutating it leaves no attributable record — the replacement's keys are indistinguishable from the caller's because most of them are the caller's — so seal returns undefined (not an empty set) and that call falls back to the previous value comparison, i.e. it keeps the old over-strip. Reading a replacement's keys as hook-owned would launder a caller's forged created_by into a platform write, so the fallback direction is the only safe one. Two tests pin it, including the one proving the fallback is the whole pre-repair behaviour and not a new hole. The pre-existing shallow-snapshot limit (a hook mutating a caller object in place) is unchanged, for the same argument.

Measured, including what the card did not measure

Verification tree: 7a486e32f (every reading below is from that tree, taken after the final commit — re-run there after the merge round, not carried over from the pre-merge head).

Merge round.main landed PR #14249 after this branch's merge base and re-anchored other rows of content/docs/permissions/system-context.mdx, the generated census page, leaving the PR dirty. Resolved by merging origin/main (a merge commit — never a rebase, never a force-push). The repo's merge driver deliberately does not text-merge that page and says so, which left this branch's pre-merge copy in place — that would have silently dropped #14249's rows, so main's copy was taken wholesale and the anchors re-derived from the merged tree with the repo's own pnpm gen:system-context-census. It did not refuse, and the census totals are unchanged (109 read sites / 145 anchors / 27 declared non-read), so this was line rot and not a population change. Verified against main's copy: 12 rows differ and every difference is an engine.ts line number inside backticks — all prose byte-identical.

All six content files of this PR are byte-identical across the merge (git rev-parse on each blob, efda8da21 vs 7a486e32f): only the generated page moved. That is what lets the ablation reading below stand as measured — the file it mutates, validation/rule-validator.ts, is the same blob at both heads.

Whole package, unablated, re-run on the merged tree:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 253 passed (253) · Tests 4379 passed (4379), wrapper VERDICT command-exit 0. Identical to the pre-merge run, so the merge changed nothing behavioural. Dependency closure rebuilt first (pnpm --filter '@objectstack/objectql^...' build, VERDICT command-exit 0), since main moved packages/spec and packages/types underneath it.

Ablation — the reproduction. Provenance consult deleted from rule-validator.ts (marker count 1 to 0 confirmed on disk; blob 1f205514 to 0e478d69; restored blob compared back to the HEAD blob and git diff HEAD empty). Every import in these suites is relative and in-package, so vitest runs the TS source and no dist/ is in the resolution path — the mutation changing behaviour with no rebuild is itself the proof.

7 failed | 45 passed (52) — and which 7 is the finding:

ablated caseassertion
THE REPORT: reopen also sending completed_at: nullexpected '2026-08-01T09:00:00.000Z' not to be '2026-08-01T09:00:00.000Z' — the stale timestamp, exactly as reported
the same collision on 0expected 42 to be +0
the STAMP directionexpected null to be '2026-08-05T10:00:00.000Z'
the PREDICATE branch, clearexpected '2026-08-01T09:00:00.000Z' to be null
the PREDICATE branch, stampexpected null to be '2026-08-05T10:00:00.000Z'
hook clear not reported to onFieldsDroppedone event, expected none
strictReadonlyWrites refusing a hook's own clearwrite refused

Two things that answers. The stamp direction is broken symmetrically — the card measures only the clear, and the framing is exactly as wide as the tree. And both update branches were broken, so both had to be fixed; a by-id-only repair would have left the predicate path corrupt.

Green under ablation, i.e. genuine negative controls: the bare { status } reopen, the partial patch, isSystem, every forgery face, the replacement fallback, all recorder unit tests, and the whole of engine-readonly-strip-signal.test.ts including the forgery pin. The fix moves the collision cases and nothing else.

Gates. Re-derived on the merged tree by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack with no paths passed — it read the change set itself (7 paths vs merge base a39b02a6b). The family grew from 33 to 58, because the change set now carries a content/docs/** path and that pulls in the whole docs family; nothing dropped out. All 58 run, every exit code captured by redirect-then-read and never through a pipe. 54 green, plus check:nul-bytes and check-engine-double-contract run outside the derived list. Four readings that are not greens, and none is a finding:

  • check-system-context-census — the reason for this round; green on the merged tree, and its --self-test passes alongside.
  • check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone). Report-only by default, and its exit 2 is documented as distinct from a finding. NOT MEASURED locally; the scheduled workflow checks out at full depth.
  • check-test-completeness exit 3 = PREREQUISITE NOT MET (it grades a saved turbo log; none exists locally, and its own text says to record this as NOT MEASURED). NOT MEASURED locally.
  • check:dual-build-cjs-loads exit 3 and spec check:skill-examples exit 1 — both PREREQUISITE NOT MET for the same reason: they read built dist/ output, and this container built only the @objectstack/objectql dependency closure, not all 56 packages. Both refuse rather than conclude ("⛔ This is NOT a pass: nothing was measured" / "a verdict now would be… a FALSE GREEN"), and both name packages this diff does not touch (client-react, hono, apps/*). NOT MEASURED locally; CI builds everything. They are new to this round only because the docs path widened the family — neither was a green that turned red.

Typecheckpnpm --filter @objectstack/objectql typecheck green on the merged tree too (VERDICT command-exit 0), and measured to cover the new tests rather than assumed: tsc -p tsconfig.test.json --listFiles finds all three new/edited files, and the 242 error lines are exactly the ledgered debt with none attributed to them.

Lint, as a declared narrowing. Repo-wide pnpm lint was not run; the five changed files were, --format json, 5/5 linted (none ignored), 0 errors / 0 warnings. The narrowing is measurable rather than merely narrow: this repo runs one eslint.config.mjs which never enables type-aware linting for any file — stated at eslint.config.mjs:326-335 with a measured positive control — so no untouched file's verdict can move as a consequence of this diff.

Scope

engine.ts's multi: truebatch-hook path is untouched. dispatchPerRowBeforeHooks is not modified; the predicate branch participates only by reading the same sealed record at the same shared seam. #14099 remains open and is not addressed here.

⭐ One thing worth the next dispatch's attention: this record does answer #14099's detection question. That card's own suggested direction asks for an engine that can tell "a hook mutated the payload on a multi update" — and after the seal, on the predicate branch, hookWrittenKeys is exactly that set, non-empty precisely when a batch hook wrote the shared SET clause. Deliberately not acted on here.


Session, kept in prose so it survives body edits: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5


Generated by Claude Code

…14088)
`stripReadonlyFields` asked `Object.is(payload[k], supplied[k])` to answer
"who wrote this key". Value equality cannot: when a hook writes the value the
caller also sent, the comparison reads it as "the hook never touched it" and
deletes the hook's write. Measured downstream on a `readonly` `completed_at`
cleared by a reopen hook against a caller that round-tripped the record — the
row committed `in_progress` carrying its old completion timestamp, silently.
Record the keys the before-phase hook chain actually ASSIGNS
(`recordHookPayloadWrites`), armed after the caller's entry snapshot and
sealed at the post-hook confluence both update branches share, and let the
strip keep a key a hook demonstrably wrote. Not a `null` sentinel: `0`, `''`,
`false` and shared references collide identically. Not a relaxation: a
caller-supplied read-only value no hook wrote is still stripped, and a caller
cannot enter the record because echoing a value is not an assignment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…insertion (#14088)
Pure line rot: the #14088 import block shifts every subsequent engine.ts line
by five, so all 15 anchors on content/docs/permissions/system-context.mdx
moved. Repaired with the gate's own `--fix`; no prose changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/security.mdx(via stripReadonlyFields (symbol, a top-level function))
  • content/docs/protocol/objectql/state-machine.mdx(via stripReadonlyFields (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e32c142f7baffc21a5eafda24c5c8162b458f34 — the merge of head 7a486e32f78821e3decda1c49dddb767c8ad77b7 into base d63c8a25216c59b9bf2652cf5197aa526a2c00b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e32c142f7baffc21a5eafda24c5c8162b458f34 && git checkout 1e32c142f7baffc21a5eafda24c5c8162b458f34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d63c8a25216c59b9bf2652cf5197aa526a2c00b2 7a486e32f78821e3decda1c49dddb767c8ad77b7 && git checkout -B drift-repro d63c8a25216c59b9bf2652cf5197aa526a2c00b2 && git merge --no-ff 7a486e32f78821e3decda1c49dddb767c8ad77b7
node scripts/docs-audit/affected-docs.mjs --json d63c8a25216c59b9bf2652cf5197aa526a2c00b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d63c8a25216c59b9bf2652cf5197aa526a2c00b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The merge driver treats content/docs/permissions/system-context.mdx as
generated and does not text-merge it, so the merge left this branch's
pre-merge copy in place — which would have dropped the rows PR #14249 added
on main. Took main's copy wholesale and re-derived the anchors from the
MERGED tree with the repo's own tooling (pnpm gen:system-context-census).
Result verified against main's copy: 12 rows differ and every difference is
an engine.ts line number inside backticks; all prose is byte-identical, so
#14249's rows survive intact. The 15 rewritten anchors are the same 15 this
branch's engine.ts insertion shifts, and the census totals are unchanged
(109 read sites / 145 anchors / 27 declared non-read), so this is line rot
and not a population change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null

2 participants

@os-support-ai@claude