feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary - #14199

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear
Sep 1, 2026
Merged

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary#14199
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#13644

Executes the 2026-08-31 adoption ruling (decision record: issue comment 5478756633): declare a first-class ctx.referentialFieldClear on HookContextSchema, populated by the engine on every reference-cleanup write (deleteBehavior: 'set_null' cascade), and carried across the sandbox boundary by contract — with BOTH mandated conditions landed as executable pins in this same PR.

Clause-②: yes — published contract-surface widening (one new optional, engine-produced key on HookContextSchema). Parked for the contract-review tier per the ruling; no enqueue from this seat.

Freshness gate honoured

Designed from the filer's correction comment (5474384377), not from the body's "Measured on 17.1.0" table (a rig artifact, per the ruling's own record). The engine builds the cleanup write's context by spreading the caller's envelope and adding the transaction handle and the operation-private marker — it inherits whatever identity the caller supplied — so on the path a real request takes (a REST DELETE carrying a userId), ctx.user, ctx.session and ctx.input are identical between the engine's cascade and a user's hand-clear of the same lookup. Consequences taken in this PR:

  • every pin runs under a full REST-shaped caller envelope (userId plus isSystem), so the discriminator-erasing condition is in force inside the tests rather than dodged by an identity-less rig context;
  • the identity-less shape (what the original card's rig happened to measure) is additionally pinned, proving the marker keys on the operation and not on any identity member.

What landed

  • specpackages/spec/src/data/hook.zod.ts: referentialFieldClear (boolean, optional) declared on HookContextSchema, placed with the other server-stamped write metadata (beside provenance). The doc block carries the one-fact-two-faces relationship to the operation-private key, the sandbox-carry contract, and the trust model (server-derived, never client-supplied). Parse pins added in packages/spec/src/data/hook.test.ts — load-bearing because this schema STRIPS undeclared keys on parse, so without the declaration a parsed context would silently lose the key.
  • objectqlpackages/objectql/src/engine.ts: new buildReferentialFieldClear (sibling of buildSession / buildProvenance) projects the operation envelope's private marker onto the declared key at update()'s hook-context assembly — a single site that covers both phases and the per-row fan-out, because the after phase reuses the assembled context and the per-row contexts spread it. Conditional spread keeps the key truly absent (never present-but-undefined) on every other write. The 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 cascade-site comment now names the projection.
  • runtimepackages/runtime/src/sandbox/script-runner.ts (ScriptContext.referentialFieldClear, documented), body-runner.ts (buildSandboxContext copies it, hook face only, only in its declared shape true), quickjs-runner.ts (installCtx installs it on the VM ctx).
  • core — comment-only: packages/core/src/security/operation-private-keys.ts header now names the declared projection, so the operation-private registration cannot be misread as the app-facing channel. No behavior change, no changeset.
  • changeset .changeset/hook-ctx-referential-field-clear.md (spec minor / objectql minor / runtime minor), citing the ruling.
  • docs: content/docs/references/data/hook.mdx regenerated (gen:docs); content/docs/permissions/system-context.mdx re-anchored by the census gate's own --fix (pure line rot from the engine.ts insertions, 14 + 5 anchors, verified OK after).

Mandated condition 1 — sandbox reachability, pinned from inside the VM

packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts — real ObjectQL + real SqlDriver (better-sqlite3) + real QuickJSScriptRunner behind hookBodyRunnerFactory, the same wiring AppPlugin performs. Every assertion lands on what the body OBSERVED (reported out through the log capability):

  • on the cleanup write, ctx.referentialFieldClear === true inside the VM, in both phases, with the inherited caller identity present alongside;
  • on the user's hand-clear of the SAME lookup under the SAME identity, the key is ABSENT (typeof reads 'undefined'), so the guard idiom reads false.

This is deliberately NOT a kernel-rig read — the ruling names the #11552 declared-vs-observable family as the reason sandbox reachability is a condition of adoption, and the operation-private spelling is exactly what a kernel rig could read and a shipped body could not (buildSandboxApi may hand a body an object shim with no executionContext at all).

Mandated condition 2 — populate surface, pinned write site by write site

New describe in packages/objectql/src/engine-cascade-delete.test.ts:

  • scalar set_null clear: true in both phases, caller identity inherited, and the hand-clear control has NO key at all (absence asserted with an in check, not just an undefined read);
  • multiple: true member removal — the second cleanup write site in cascadeDeleteRelations — same assertions against its own hand-edit control;
  • an identity-less delete still marks its cleanup writes;
  • the one-fact-two-faces consistency leg: middleware captures the operation-private marker on the same writes, and the declared face is true exactly where the envelope carries it, write for write (three ops asserted in order: hand-clear, restore, cleanup).

Derived judgments — each stated, none silently decided

  1. Accept-set change: pure widening by one optional, engine-produced key on the deliberately non-strict runtime context shape. No previously valid context changes meaning; a context without the key parses exactly as before; the engine writes true or omits the key, never false.
  2. Old operation-private key disposition: KEPT, unchanged.__referentialFieldClear remains the engine/middleware authorization channel — plugin-security's ownership-anchor exemption reads it off the operation context before any hook runs — and the operation-private-keys stripping rule is untouched. The declared key is a read-only projection, not a replacement; no dual-write transition is needed because the two faces have one producer site each and the projection is derived at assembly. The governance ledger needed no mechanical change (comment updated only).
  3. Envelope-scoped semantics, flagged: the declared key faithfully projects the envelope fact. A write a hook itself issues through ctx.api DURING a cleanup dispatch runs on the same inherited envelope and therefore carries the marker too — this is the semantics the operation-private key has always had (and what plugin-security keys on), projected rather than quietly re-scoped. If a strictly per-write-site signal is ever wanted instead, that is a contract decision to take explicitly, not a default this PR assumes.
  4. Populate site singular (update assembly only), flagged: every reference-cleanup write the engine issues is an update, so update()'s assembly is the complete populate surface. The find/insert/delete assemblies deliberately do NOT project the envelope — a delete or insert is never itself a reference-cleanup write, and advertising the envelope there would widen the key's meaning without a pull.
  5. Semver: spec minor (declared contract-surface widening), objectql minor (the engine now populates a declared contract face), runtime minor (a new marshalled member of the sandbox contract). core is comment-only: no changeset.
  6. ADR-0087: non-breaking addition — no disposition marker owed on a non-breaking changeset; check:adr-0087-registration green.

Tests and gates — real readings, final tree

All exit codes captured redirect-first (never through a pipe); heavy runs serialized through scripts/pm/os-verify-lock.sh. Final union re-run at HEAD 1108016dd (post-merge of origin/main, census deferral discharged):

  • spec src/data/hook.test.ts: 79 passed (79) — includes the two new parse pins
  • core src/security/operation-private-keys.test.ts: 8 passed (8)
  • objectql src/engine-cascade-delete.test.ts + src/hook-input-shape-contract.test.ts: 35 passed (35)
  • runtime src/sandbox/referential-field-clear-signal.integration.test.ts: 1 passed (real QuickJS; earlier full sandbox sweep also green: perrow-dispatch-signal.integration + body-runner + quickjs-runner = 101 passed (101))
  • typecheck: spec, objectql, runtime all green on the merged tree (objectql's check:test-typecheck ratchet held at its ledgered count — one new TS2345 was introduced by the suite and fixed rather than ledgered). Note runtime's tsc --noEmit excludes test files by its tsconfig (pre-existing), so the new integration test is proven by execution, not by tsc.
  • full workspace build: turbo 71/71 tasks successful (9m50s); check:dev-prereqs green after the post-merge spec rebuild ("built from the sources on disk"); pnpm check:dual-build-cjs-loads green (93 self-test cases + ledger)
  • pnpm --filter @objectstack/spec check:generated: one stale artifact (check:docs) regenerated via --fix; full re-run green. check:authorable-surface green (data shard gained the one declared row)
  • pnpm check:type-check-debt on the merged tree: "27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number; surplus: none" — nothing raised
  • gate families derived from the actual change set (node scripts/pm/dispatch-gates.mjs, no hand-fed paths, re-derived after fetching origin/main): all derived local gates green, including check:system-context-census (line-rot re-anchored via its own --fix, verified "109 sites, all anchored"), check:engine-split-ratio (after the gate's own deepen remedy), check:corpus-claim-drift, check:doc-anchors, check:kernel-hook-pairs, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, and the spec-scoped family (docs/liveness/strictness-ledger/llms-txt/skill-refs/variant-docs/yaml-examples/empty-state/skill-examples)
  • node scripts/check-test-completeness.mjs: exit 3 PREREQUISITE NOT MET — by its own text the local reading is NOT MEASURED (it grades a saved turbo test log CI produces); left to CI
  • check:nul-bytes green (7751 files scanned)

Out of scope: #13331 remains open and untouched by this PR (its branch carries no commits; its lane escalated to a maintainer decision independently of this card).

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 8 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via HookContextSchema (symbol, a top-level const), cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 1 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 — 139 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea363f033874f43918c13e0f1d6e8714fe3782ee — the merge of head 1108016ddda0e77ef797776bd9f8a2d146e4309f into base 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33, 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 ea363f033874f43918c13e0f1d6e8714fe3782ee && git checkout ea363f033874f43918c13e0f1d6e8714fe3782ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 1108016ddda0e77ef797776bd9f8a2d146e4309f && git checkout -B drift-repro 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 && git merge --no-ff 1108016ddda0e77ef797776bd9f8a2d146e4309f
node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33

⚠️ 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 1, 2026 11:57
@os-support-ai
os-support-ai added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 34ce8e7Sep 1, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-13644-referential-field-clear branch September 1, 2026 12:41
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
Same mechanism as the previous merge: main's #14199 re-anchored 11 rows
of this machine-maintained table while this branch re-anchors row 21,
and a textually clean merge proves nothing about the line numbers.
Resolution per the tool: took main's page wholesale, re-derived with
check-system-context-census --fix on the merged tree — exactly ONE
anchor rewritten (row 21, protocol.ts 1737 to 1741; re-derived, not
carried — main has zero commits on protocol.ts, so the insertion shift
is unchanged). Delta vs origin/main is that single line; bare gate
green (109 sites, 145 anchors). engine-double-contract ledger: zero
main-side commits, gate OK on the merged tree — no regeneration owed.
Co-Authored-By: Claude Opus 5 <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 documentationprotocol:datasize/lteststooling

Projects

None yet

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

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary - #14199

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear
Sep 1, 2026
Merged

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary#14199
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#13644

Executes the 2026-08-31 adoption ruling (decision record: issue comment 5478756633): declare a first-class ctx.referentialFieldClear on HookContextSchema, populated by the engine on every reference-cleanup write (deleteBehavior: 'set_null' cascade), and carried across the sandbox boundary by contract — with BOTH mandated conditions landed as executable pins in this same PR.

Clause-②: yes — published contract-surface widening (one new optional, engine-produced key on HookContextSchema). Parked for the contract-review tier per the ruling; no enqueue from this seat.

Freshness gate honoured

Designed from the filer's correction comment (5474384377), not from the body's "Measured on 17.1.0" table (a rig artifact, per the ruling's own record). The engine builds the cleanup write's context by spreading the caller's envelope and adding the transaction handle and the operation-private marker — it inherits whatever identity the caller supplied — so on the path a real request takes (a REST DELETE carrying a userId), ctx.user, ctx.session and ctx.input are identical between the engine's cascade and a user's hand-clear of the same lookup. Consequences taken in this PR:

  • every pin runs under a full REST-shaped caller envelope (userId plus isSystem), so the discriminator-erasing condition is in force inside the tests rather than dodged by an identity-less rig context;
  • the identity-less shape (what the original card's rig happened to measure) is additionally pinned, proving the marker keys on the operation and not on any identity member.

What landed

  • specpackages/spec/src/data/hook.zod.ts: referentialFieldClear (boolean, optional) declared on HookContextSchema, placed with the other server-stamped write metadata (beside provenance). The doc block carries the one-fact-two-faces relationship to the operation-private key, the sandbox-carry contract, and the trust model (server-derived, never client-supplied). Parse pins added in packages/spec/src/data/hook.test.ts — load-bearing because this schema STRIPS undeclared keys on parse, so without the declaration a parsed context would silently lose the key.
  • objectqlpackages/objectql/src/engine.ts: new buildReferentialFieldClear (sibling of buildSession / buildProvenance) projects the operation envelope's private marker onto the declared key at update()'s hook-context assembly — a single site that covers both phases and the per-row fan-out, because the after phase reuses the assembled context and the per-row contexts spread it. Conditional spread keeps the key truly absent (never present-but-undefined) on every other write. The 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 cascade-site comment now names the projection.
  • runtimepackages/runtime/src/sandbox/script-runner.ts (ScriptContext.referentialFieldClear, documented), body-runner.ts (buildSandboxContext copies it, hook face only, only in its declared shape true), quickjs-runner.ts (installCtx installs it on the VM ctx).
  • core — comment-only: packages/core/src/security/operation-private-keys.ts header now names the declared projection, so the operation-private registration cannot be misread as the app-facing channel. No behavior change, no changeset.
  • changeset .changeset/hook-ctx-referential-field-clear.md (spec minor / objectql minor / runtime minor), citing the ruling.
  • docs: content/docs/references/data/hook.mdx regenerated (gen:docs); content/docs/permissions/system-context.mdx re-anchored by the census gate's own --fix (pure line rot from the engine.ts insertions, 14 + 5 anchors, verified OK after).

Mandated condition 1 — sandbox reachability, pinned from inside the VM

packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts — real ObjectQL + real SqlDriver (better-sqlite3) + real QuickJSScriptRunner behind hookBodyRunnerFactory, the same wiring AppPlugin performs. Every assertion lands on what the body OBSERVED (reported out through the log capability):

  • on the cleanup write, ctx.referentialFieldClear === true inside the VM, in both phases, with the inherited caller identity present alongside;
  • on the user's hand-clear of the SAME lookup under the SAME identity, the key is ABSENT (typeof reads 'undefined'), so the guard idiom reads false.

This is deliberately NOT a kernel-rig read — the ruling names the #11552 declared-vs-observable family as the reason sandbox reachability is a condition of adoption, and the operation-private spelling is exactly what a kernel rig could read and a shipped body could not (buildSandboxApi may hand a body an object shim with no executionContext at all).

Mandated condition 2 — populate surface, pinned write site by write site

New describe in packages/objectql/src/engine-cascade-delete.test.ts:

  • scalar set_null clear: true in both phases, caller identity inherited, and the hand-clear control has NO key at all (absence asserted with an in check, not just an undefined read);
  • multiple: true member removal — the second cleanup write site in cascadeDeleteRelations — same assertions against its own hand-edit control;
  • an identity-less delete still marks its cleanup writes;
  • the one-fact-two-faces consistency leg: middleware captures the operation-private marker on the same writes, and the declared face is true exactly where the envelope carries it, write for write (three ops asserted in order: hand-clear, restore, cleanup).

Derived judgments — each stated, none silently decided

  1. Accept-set change: pure widening by one optional, engine-produced key on the deliberately non-strict runtime context shape. No previously valid context changes meaning; a context without the key parses exactly as before; the engine writes true or omits the key, never false.
  2. Old operation-private key disposition: KEPT, unchanged.__referentialFieldClear remains the engine/middleware authorization channel — plugin-security's ownership-anchor exemption reads it off the operation context before any hook runs — and the operation-private-keys stripping rule is untouched. The declared key is a read-only projection, not a replacement; no dual-write transition is needed because the two faces have one producer site each and the projection is derived at assembly. The governance ledger needed no mechanical change (comment updated only).
  3. Envelope-scoped semantics, flagged: the declared key faithfully projects the envelope fact. A write a hook itself issues through ctx.api DURING a cleanup dispatch runs on the same inherited envelope and therefore carries the marker too — this is the semantics the operation-private key has always had (and what plugin-security keys on), projected rather than quietly re-scoped. If a strictly per-write-site signal is ever wanted instead, that is a contract decision to take explicitly, not a default this PR assumes.
  4. Populate site singular (update assembly only), flagged: every reference-cleanup write the engine issues is an update, so update()'s assembly is the complete populate surface. The find/insert/delete assemblies deliberately do NOT project the envelope — a delete or insert is never itself a reference-cleanup write, and advertising the envelope there would widen the key's meaning without a pull.
  5. Semver: spec minor (declared contract-surface widening), objectql minor (the engine now populates a declared contract face), runtime minor (a new marshalled member of the sandbox contract). core is comment-only: no changeset.
  6. ADR-0087: non-breaking addition — no disposition marker owed on a non-breaking changeset; check:adr-0087-registration green.

Tests and gates — real readings, final tree

All exit codes captured redirect-first (never through a pipe); heavy runs serialized through scripts/pm/os-verify-lock.sh. Final union re-run at HEAD 1108016dd (post-merge of origin/main, census deferral discharged):

  • spec src/data/hook.test.ts: 79 passed (79) — includes the two new parse pins
  • core src/security/operation-private-keys.test.ts: 8 passed (8)
  • objectql src/engine-cascade-delete.test.ts + src/hook-input-shape-contract.test.ts: 35 passed (35)
  • runtime src/sandbox/referential-field-clear-signal.integration.test.ts: 1 passed (real QuickJS; earlier full sandbox sweep also green: perrow-dispatch-signal.integration + body-runner + quickjs-runner = 101 passed (101))
  • typecheck: spec, objectql, runtime all green on the merged tree (objectql's check:test-typecheck ratchet held at its ledgered count — one new TS2345 was introduced by the suite and fixed rather than ledgered). Note runtime's tsc --noEmit excludes test files by its tsconfig (pre-existing), so the new integration test is proven by execution, not by tsc.
  • full workspace build: turbo 71/71 tasks successful (9m50s); check:dev-prereqs green after the post-merge spec rebuild ("built from the sources on disk"); pnpm check:dual-build-cjs-loads green (93 self-test cases + ledger)
  • pnpm --filter @objectstack/spec check:generated: one stale artifact (check:docs) regenerated via --fix; full re-run green. check:authorable-surface green (data shard gained the one declared row)
  • pnpm check:type-check-debt on the merged tree: "27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number; surplus: none" — nothing raised
  • gate families derived from the actual change set (node scripts/pm/dispatch-gates.mjs, no hand-fed paths, re-derived after fetching origin/main): all derived local gates green, including check:system-context-census (line-rot re-anchored via its own --fix, verified "109 sites, all anchored"), check:engine-split-ratio (after the gate's own deepen remedy), check:corpus-claim-drift, check:doc-anchors, check:kernel-hook-pairs, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, and the spec-scoped family (docs/liveness/strictness-ledger/llms-txt/skill-refs/variant-docs/yaml-examples/empty-state/skill-examples)
  • node scripts/check-test-completeness.mjs: exit 3 PREREQUISITE NOT MET — by its own text the local reading is NOT MEASURED (it grades a saved turbo test log CI produces); left to CI
  • check:nul-bytes green (7751 files scanned)

Out of scope: #13331 remains open and untouched by this PR (its branch carries no commits; its lane escalated to a maintainer decision independently of this card).

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 8 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via HookContextSchema (symbol, a top-level const), cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 1 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 — 139 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea363f033874f43918c13e0f1d6e8714fe3782ee — the merge of head 1108016ddda0e77ef797776bd9f8a2d146e4309f into base 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33, 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 ea363f033874f43918c13e0f1d6e8714fe3782ee && git checkout ea363f033874f43918c13e0f1d6e8714fe3782ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 1108016ddda0e77ef797776bd9f8a2d146e4309f && git checkout -B drift-repro 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 && git merge --no-ff 1108016ddda0e77ef797776bd9f8a2d146e4309f
node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33

⚠️ 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 1, 2026 11:57
@os-support-ai
os-support-ai added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 34ce8e7Sep 1, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-13644-referential-field-clear branch September 1, 2026 12:41
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
Same mechanism as the previous merge: main's #14199 re-anchored 11 rows
of this machine-maintained table while this branch re-anchors row 21,
and a textually clean merge proves nothing about the line numbers.
Resolution per the tool: took main's page wholesale, re-derived with
check-system-context-census --fix on the merged tree — exactly ONE
anchor rewritten (row 21, protocol.ts 1737 to 1741; re-derived, not
carried — main has zero commits on protocol.ts, so the insertion shift
is unchanged). Delta vs origin/main is that single line; bare gate
green (109 sites, 145 anchors). engine-double-contract ledger: zero
main-side commits, gate OK on the merged tree — no regeneration owed.
Co-Authored-By: Claude Opus 5 <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 documentationprotocol:datasize/lteststooling

Projects

None yet

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

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary - #14199

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear
Sep 1, 2026
Merged

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary#14199
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#13644

Executes the 2026-08-31 adoption ruling (decision record: issue comment 5478756633): declare a first-class ctx.referentialFieldClear on HookContextSchema, populated by the engine on every reference-cleanup write (deleteBehavior: 'set_null' cascade), and carried across the sandbox boundary by contract — with BOTH mandated conditions landed as executable pins in this same PR.

Clause-②: yes — published contract-surface widening (one new optional, engine-produced key on HookContextSchema). Parked for the contract-review tier per the ruling; no enqueue from this seat.

Freshness gate honoured

Designed from the filer's correction comment (5474384377), not from the body's "Measured on 17.1.0" table (a rig artifact, per the ruling's own record). The engine builds the cleanup write's context by spreading the caller's envelope and adding the transaction handle and the operation-private marker — it inherits whatever identity the caller supplied — so on the path a real request takes (a REST DELETE carrying a userId), ctx.user, ctx.session and ctx.input are identical between the engine's cascade and a user's hand-clear of the same lookup. Consequences taken in this PR:

  • every pin runs under a full REST-shaped caller envelope (userId plus isSystem), so the discriminator-erasing condition is in force inside the tests rather than dodged by an identity-less rig context;
  • the identity-less shape (what the original card's rig happened to measure) is additionally pinned, proving the marker keys on the operation and not on any identity member.

What landed

  • specpackages/spec/src/data/hook.zod.ts: referentialFieldClear (boolean, optional) declared on HookContextSchema, placed with the other server-stamped write metadata (beside provenance). The doc block carries the one-fact-two-faces relationship to the operation-private key, the sandbox-carry contract, and the trust model (server-derived, never client-supplied). Parse pins added in packages/spec/src/data/hook.test.ts — load-bearing because this schema STRIPS undeclared keys on parse, so without the declaration a parsed context would silently lose the key.
  • objectqlpackages/objectql/src/engine.ts: new buildReferentialFieldClear (sibling of buildSession / buildProvenance) projects the operation envelope's private marker onto the declared key at update()'s hook-context assembly — a single site that covers both phases and the per-row fan-out, because the after phase reuses the assembled context and the per-row contexts spread it. Conditional spread keeps the key truly absent (never present-but-undefined) on every other write. The 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 cascade-site comment now names the projection.
  • runtimepackages/runtime/src/sandbox/script-runner.ts (ScriptContext.referentialFieldClear, documented), body-runner.ts (buildSandboxContext copies it, hook face only, only in its declared shape true), quickjs-runner.ts (installCtx installs it on the VM ctx).
  • core — comment-only: packages/core/src/security/operation-private-keys.ts header now names the declared projection, so the operation-private registration cannot be misread as the app-facing channel. No behavior change, no changeset.
  • changeset .changeset/hook-ctx-referential-field-clear.md (spec minor / objectql minor / runtime minor), citing the ruling.
  • docs: content/docs/references/data/hook.mdx regenerated (gen:docs); content/docs/permissions/system-context.mdx re-anchored by the census gate's own --fix (pure line rot from the engine.ts insertions, 14 + 5 anchors, verified OK after).

Mandated condition 1 — sandbox reachability, pinned from inside the VM

packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts — real ObjectQL + real SqlDriver (better-sqlite3) + real QuickJSScriptRunner behind hookBodyRunnerFactory, the same wiring AppPlugin performs. Every assertion lands on what the body OBSERVED (reported out through the log capability):

  • on the cleanup write, ctx.referentialFieldClear === true inside the VM, in both phases, with the inherited caller identity present alongside;
  • on the user's hand-clear of the SAME lookup under the SAME identity, the key is ABSENT (typeof reads 'undefined'), so the guard idiom reads false.

This is deliberately NOT a kernel-rig read — the ruling names the #11552 declared-vs-observable family as the reason sandbox reachability is a condition of adoption, and the operation-private spelling is exactly what a kernel rig could read and a shipped body could not (buildSandboxApi may hand a body an object shim with no executionContext at all).

Mandated condition 2 — populate surface, pinned write site by write site

New describe in packages/objectql/src/engine-cascade-delete.test.ts:

  • scalar set_null clear: true in both phases, caller identity inherited, and the hand-clear control has NO key at all (absence asserted with an in check, not just an undefined read);
  • multiple: true member removal — the second cleanup write site in cascadeDeleteRelations — same assertions against its own hand-edit control;
  • an identity-less delete still marks its cleanup writes;
  • the one-fact-two-faces consistency leg: middleware captures the operation-private marker on the same writes, and the declared face is true exactly where the envelope carries it, write for write (three ops asserted in order: hand-clear, restore, cleanup).

Derived judgments — each stated, none silently decided

  1. Accept-set change: pure widening by one optional, engine-produced key on the deliberately non-strict runtime context shape. No previously valid context changes meaning; a context without the key parses exactly as before; the engine writes true or omits the key, never false.
  2. Old operation-private key disposition: KEPT, unchanged.__referentialFieldClear remains the engine/middleware authorization channel — plugin-security's ownership-anchor exemption reads it off the operation context before any hook runs — and the operation-private-keys stripping rule is untouched. The declared key is a read-only projection, not a replacement; no dual-write transition is needed because the two faces have one producer site each and the projection is derived at assembly. The governance ledger needed no mechanical change (comment updated only).
  3. Envelope-scoped semantics, flagged: the declared key faithfully projects the envelope fact. A write a hook itself issues through ctx.api DURING a cleanup dispatch runs on the same inherited envelope and therefore carries the marker too — this is the semantics the operation-private key has always had (and what plugin-security keys on), projected rather than quietly re-scoped. If a strictly per-write-site signal is ever wanted instead, that is a contract decision to take explicitly, not a default this PR assumes.
  4. Populate site singular (update assembly only), flagged: every reference-cleanup write the engine issues is an update, so update()'s assembly is the complete populate surface. The find/insert/delete assemblies deliberately do NOT project the envelope — a delete or insert is never itself a reference-cleanup write, and advertising the envelope there would widen the key's meaning without a pull.
  5. Semver: spec minor (declared contract-surface widening), objectql minor (the engine now populates a declared contract face), runtime minor (a new marshalled member of the sandbox contract). core is comment-only: no changeset.
  6. ADR-0087: non-breaking addition — no disposition marker owed on a non-breaking changeset; check:adr-0087-registration green.

Tests and gates — real readings, final tree

All exit codes captured redirect-first (never through a pipe); heavy runs serialized through scripts/pm/os-verify-lock.sh. Final union re-run at HEAD 1108016dd (post-merge of origin/main, census deferral discharged):

  • spec src/data/hook.test.ts: 79 passed (79) — includes the two new parse pins
  • core src/security/operation-private-keys.test.ts: 8 passed (8)
  • objectql src/engine-cascade-delete.test.ts + src/hook-input-shape-contract.test.ts: 35 passed (35)
  • runtime src/sandbox/referential-field-clear-signal.integration.test.ts: 1 passed (real QuickJS; earlier full sandbox sweep also green: perrow-dispatch-signal.integration + body-runner + quickjs-runner = 101 passed (101))
  • typecheck: spec, objectql, runtime all green on the merged tree (objectql's check:test-typecheck ratchet held at its ledgered count — one new TS2345 was introduced by the suite and fixed rather than ledgered). Note runtime's tsc --noEmit excludes test files by its tsconfig (pre-existing), so the new integration test is proven by execution, not by tsc.
  • full workspace build: turbo 71/71 tasks successful (9m50s); check:dev-prereqs green after the post-merge spec rebuild ("built from the sources on disk"); pnpm check:dual-build-cjs-loads green (93 self-test cases + ledger)
  • pnpm --filter @objectstack/spec check:generated: one stale artifact (check:docs) regenerated via --fix; full re-run green. check:authorable-surface green (data shard gained the one declared row)
  • pnpm check:type-check-debt on the merged tree: "27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number; surplus: none" — nothing raised
  • gate families derived from the actual change set (node scripts/pm/dispatch-gates.mjs, no hand-fed paths, re-derived after fetching origin/main): all derived local gates green, including check:system-context-census (line-rot re-anchored via its own --fix, verified "109 sites, all anchored"), check:engine-split-ratio (after the gate's own deepen remedy), check:corpus-claim-drift, check:doc-anchors, check:kernel-hook-pairs, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, and the spec-scoped family (docs/liveness/strictness-ledger/llms-txt/skill-refs/variant-docs/yaml-examples/empty-state/skill-examples)
  • node scripts/check-test-completeness.mjs: exit 3 PREREQUISITE NOT MET — by its own text the local reading is NOT MEASURED (it grades a saved turbo test log CI produces); left to CI
  • check:nul-bytes green (7751 files scanned)

Out of scope: #13331 remains open and untouched by this PR (its branch carries no commits; its lane escalated to a maintainer decision independently of this card).

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 8 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via HookContextSchema (symbol, a top-level const), cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 1 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 — 139 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea363f033874f43918c13e0f1d6e8714fe3782ee — the merge of head 1108016ddda0e77ef797776bd9f8a2d146e4309f into base 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33, 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 ea363f033874f43918c13e0f1d6e8714fe3782ee && git checkout ea363f033874f43918c13e0f1d6e8714fe3782ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 1108016ddda0e77ef797776bd9f8a2d146e4309f && git checkout -B drift-repro 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 && git merge --no-ff 1108016ddda0e77ef797776bd9f8a2d146e4309f
node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33

⚠️ 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 1, 2026 11:57
@os-support-ai
os-support-ai added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 34ce8e7Sep 1, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-13644-referential-field-clear branch September 1, 2026 12:41
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
Same mechanism as the previous merge: main's #14199 re-anchored 11 rows
of this machine-maintained table while this branch re-anchors row 21,
and a textually clean merge proves nothing about the line numbers.
Resolution per the tool: took main's page wholesale, re-derived with
check-system-context-census --fix on the merged tree — exactly ONE
anchor rewritten (row 21, protocol.ts 1737 to 1741; re-derived, not
carried — main has zero commits on protocol.ts, so the insertion shift
is unchanged). Delta vs origin/main is that single line; bare gate
green (109 sites, 145 anchors). engine-double-contract ledger: zero
main-side commits, gate OK on the merged tree — no regeneration owed.
Co-Authored-By: Claude Opus 5 <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 documentationprotocol:datasize/lteststooling

Projects

None yet

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

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary - #14199

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear
Sep 1, 2026
Merged

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary#14199
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#13644

Executes the 2026-08-31 adoption ruling (decision record: issue comment 5478756633): declare a first-class ctx.referentialFieldClear on HookContextSchema, populated by the engine on every reference-cleanup write (deleteBehavior: 'set_null' cascade), and carried across the sandbox boundary by contract — with BOTH mandated conditions landed as executable pins in this same PR.

Clause-②: yes — published contract-surface widening (one new optional, engine-produced key on HookContextSchema). Parked for the contract-review tier per the ruling; no enqueue from this seat.

Freshness gate honoured

Designed from the filer's correction comment (5474384377), not from the body's "Measured on 17.1.0" table (a rig artifact, per the ruling's own record). The engine builds the cleanup write's context by spreading the caller's envelope and adding the transaction handle and the operation-private marker — it inherits whatever identity the caller supplied — so on the path a real request takes (a REST DELETE carrying a userId), ctx.user, ctx.session and ctx.input are identical between the engine's cascade and a user's hand-clear of the same lookup. Consequences taken in this PR:

  • every pin runs under a full REST-shaped caller envelope (userId plus isSystem), so the discriminator-erasing condition is in force inside the tests rather than dodged by an identity-less rig context;
  • the identity-less shape (what the original card's rig happened to measure) is additionally pinned, proving the marker keys on the operation and not on any identity member.

What landed

  • specpackages/spec/src/data/hook.zod.ts: referentialFieldClear (boolean, optional) declared on HookContextSchema, placed with the other server-stamped write metadata (beside provenance). The doc block carries the one-fact-two-faces relationship to the operation-private key, the sandbox-carry contract, and the trust model (server-derived, never client-supplied). Parse pins added in packages/spec/src/data/hook.test.ts — load-bearing because this schema STRIPS undeclared keys on parse, so without the declaration a parsed context would silently lose the key.
  • objectqlpackages/objectql/src/engine.ts: new buildReferentialFieldClear (sibling of buildSession / buildProvenance) projects the operation envelope's private marker onto the declared key at update()'s hook-context assembly — a single site that covers both phases and the per-row fan-out, because the after phase reuses the assembled context and the per-row contexts spread it. Conditional spread keeps the key truly absent (never present-but-undefined) on every other write. The 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 cascade-site comment now names the projection.
  • runtimepackages/runtime/src/sandbox/script-runner.ts (ScriptContext.referentialFieldClear, documented), body-runner.ts (buildSandboxContext copies it, hook face only, only in its declared shape true), quickjs-runner.ts (installCtx installs it on the VM ctx).
  • core — comment-only: packages/core/src/security/operation-private-keys.ts header now names the declared projection, so the operation-private registration cannot be misread as the app-facing channel. No behavior change, no changeset.
  • changeset .changeset/hook-ctx-referential-field-clear.md (spec minor / objectql minor / runtime minor), citing the ruling.
  • docs: content/docs/references/data/hook.mdx regenerated (gen:docs); content/docs/permissions/system-context.mdx re-anchored by the census gate's own --fix (pure line rot from the engine.ts insertions, 14 + 5 anchors, verified OK after).

Mandated condition 1 — sandbox reachability, pinned from inside the VM

packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts — real ObjectQL + real SqlDriver (better-sqlite3) + real QuickJSScriptRunner behind hookBodyRunnerFactory, the same wiring AppPlugin performs. Every assertion lands on what the body OBSERVED (reported out through the log capability):

  • on the cleanup write, ctx.referentialFieldClear === true inside the VM, in both phases, with the inherited caller identity present alongside;
  • on the user's hand-clear of the SAME lookup under the SAME identity, the key is ABSENT (typeof reads 'undefined'), so the guard idiom reads false.

This is deliberately NOT a kernel-rig read — the ruling names the #11552 declared-vs-observable family as the reason sandbox reachability is a condition of adoption, and the operation-private spelling is exactly what a kernel rig could read and a shipped body could not (buildSandboxApi may hand a body an object shim with no executionContext at all).

Mandated condition 2 — populate surface, pinned write site by write site

New describe in packages/objectql/src/engine-cascade-delete.test.ts:

  • scalar set_null clear: true in both phases, caller identity inherited, and the hand-clear control has NO key at all (absence asserted with an in check, not just an undefined read);
  • multiple: true member removal — the second cleanup write site in cascadeDeleteRelations — same assertions against its own hand-edit control;
  • an identity-less delete still marks its cleanup writes;
  • the one-fact-two-faces consistency leg: middleware captures the operation-private marker on the same writes, and the declared face is true exactly where the envelope carries it, write for write (three ops asserted in order: hand-clear, restore, cleanup).

Derived judgments — each stated, none silently decided

  1. Accept-set change: pure widening by one optional, engine-produced key on the deliberately non-strict runtime context shape. No previously valid context changes meaning; a context without the key parses exactly as before; the engine writes true or omits the key, never false.
  2. Old operation-private key disposition: KEPT, unchanged.__referentialFieldClear remains the engine/middleware authorization channel — plugin-security's ownership-anchor exemption reads it off the operation context before any hook runs — and the operation-private-keys stripping rule is untouched. The declared key is a read-only projection, not a replacement; no dual-write transition is needed because the two faces have one producer site each and the projection is derived at assembly. The governance ledger needed no mechanical change (comment updated only).
  3. Envelope-scoped semantics, flagged: the declared key faithfully projects the envelope fact. A write a hook itself issues through ctx.api DURING a cleanup dispatch runs on the same inherited envelope and therefore carries the marker too — this is the semantics the operation-private key has always had (and what plugin-security keys on), projected rather than quietly re-scoped. If a strictly per-write-site signal is ever wanted instead, that is a contract decision to take explicitly, not a default this PR assumes.
  4. Populate site singular (update assembly only), flagged: every reference-cleanup write the engine issues is an update, so update()'s assembly is the complete populate surface. The find/insert/delete assemblies deliberately do NOT project the envelope — a delete or insert is never itself a reference-cleanup write, and advertising the envelope there would widen the key's meaning without a pull.
  5. Semver: spec minor (declared contract-surface widening), objectql minor (the engine now populates a declared contract face), runtime minor (a new marshalled member of the sandbox contract). core is comment-only: no changeset.
  6. ADR-0087: non-breaking addition — no disposition marker owed on a non-breaking changeset; check:adr-0087-registration green.

Tests and gates — real readings, final tree

All exit codes captured redirect-first (never through a pipe); heavy runs serialized through scripts/pm/os-verify-lock.sh. Final union re-run at HEAD 1108016dd (post-merge of origin/main, census deferral discharged):

  • spec src/data/hook.test.ts: 79 passed (79) — includes the two new parse pins
  • core src/security/operation-private-keys.test.ts: 8 passed (8)
  • objectql src/engine-cascade-delete.test.ts + src/hook-input-shape-contract.test.ts: 35 passed (35)
  • runtime src/sandbox/referential-field-clear-signal.integration.test.ts: 1 passed (real QuickJS; earlier full sandbox sweep also green: perrow-dispatch-signal.integration + body-runner + quickjs-runner = 101 passed (101))
  • typecheck: spec, objectql, runtime all green on the merged tree (objectql's check:test-typecheck ratchet held at its ledgered count — one new TS2345 was introduced by the suite and fixed rather than ledgered). Note runtime's tsc --noEmit excludes test files by its tsconfig (pre-existing), so the new integration test is proven by execution, not by tsc.
  • full workspace build: turbo 71/71 tasks successful (9m50s); check:dev-prereqs green after the post-merge spec rebuild ("built from the sources on disk"); pnpm check:dual-build-cjs-loads green (93 self-test cases + ledger)
  • pnpm --filter @objectstack/spec check:generated: one stale artifact (check:docs) regenerated via --fix; full re-run green. check:authorable-surface green (data shard gained the one declared row)
  • pnpm check:type-check-debt on the merged tree: "27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number; surplus: none" — nothing raised
  • gate families derived from the actual change set (node scripts/pm/dispatch-gates.mjs, no hand-fed paths, re-derived after fetching origin/main): all derived local gates green, including check:system-context-census (line-rot re-anchored via its own --fix, verified "109 sites, all anchored"), check:engine-split-ratio (after the gate's own deepen remedy), check:corpus-claim-drift, check:doc-anchors, check:kernel-hook-pairs, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, and the spec-scoped family (docs/liveness/strictness-ledger/llms-txt/skill-refs/variant-docs/yaml-examples/empty-state/skill-examples)
  • node scripts/check-test-completeness.mjs: exit 3 PREREQUISITE NOT MET — by its own text the local reading is NOT MEASURED (it grades a saved turbo test log CI produces); left to CI
  • check:nul-bytes green (7751 files scanned)

Out of scope: #13331 remains open and untouched by this PR (its branch carries no commits; its lane escalated to a maintainer decision independently of this card).

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 8 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via HookContextSchema (symbol, a top-level const), cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 1 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 — 139 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea363f033874f43918c13e0f1d6e8714fe3782ee — the merge of head 1108016ddda0e77ef797776bd9f8a2d146e4309f into base 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33, 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 ea363f033874f43918c13e0f1d6e8714fe3782ee && git checkout ea363f033874f43918c13e0f1d6e8714fe3782ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 1108016ddda0e77ef797776bd9f8a2d146e4309f && git checkout -B drift-repro 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 && git merge --no-ff 1108016ddda0e77ef797776bd9f8a2d146e4309f
node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33

⚠️ 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 1, 2026 11:57
@os-support-ai
os-support-ai added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 34ce8e7Sep 1, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-13644-referential-field-clear branch September 1, 2026 12:41
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
Same mechanism as the previous merge: main's #14199 re-anchored 11 rows
of this machine-maintained table while this branch re-anchors row 21,
and a textually clean merge proves nothing about the line numbers.
Resolution per the tool: took main's page wholesale, re-derived with
check-system-context-census --fix on the merged tree — exactly ONE
anchor rewritten (row 21, protocol.ts 1737 to 1741; re-derived, not
carried — main has zero commits on protocol.ts, so the insertion shift
is unchanged). Delta vs origin/main is that single line; bare gate
green (109 sites, 145 anchors). engine-double-contract ledger: zero
main-side commits, gate OK on the merged tree — no regeneration owed.
Co-Authored-By: Claude Opus 5 <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 documentationprotocol:datasize/lteststooling

Projects

None yet

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

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary - #14199

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear
Sep 1, 2026
Merged

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary#14199
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#13644

Executes the 2026-08-31 adoption ruling (decision record: issue comment 5478756633): declare a first-class ctx.referentialFieldClear on HookContextSchema, populated by the engine on every reference-cleanup write (deleteBehavior: 'set_null' cascade), and carried across the sandbox boundary by contract — with BOTH mandated conditions landed as executable pins in this same PR.

Clause-②: yes — published contract-surface widening (one new optional, engine-produced key on HookContextSchema). Parked for the contract-review tier per the ruling; no enqueue from this seat.

Freshness gate honoured

Designed from the filer's correction comment (5474384377), not from the body's "Measured on 17.1.0" table (a rig artifact, per the ruling's own record). The engine builds the cleanup write's context by spreading the caller's envelope and adding the transaction handle and the operation-private marker — it inherits whatever identity the caller supplied — so on the path a real request takes (a REST DELETE carrying a userId), ctx.user, ctx.session and ctx.input are identical between the engine's cascade and a user's hand-clear of the same lookup. Consequences taken in this PR:

  • every pin runs under a full REST-shaped caller envelope (userId plus isSystem), so the discriminator-erasing condition is in force inside the tests rather than dodged by an identity-less rig context;
  • the identity-less shape (what the original card's rig happened to measure) is additionally pinned, proving the marker keys on the operation and not on any identity member.

What landed

  • specpackages/spec/src/data/hook.zod.ts: referentialFieldClear (boolean, optional) declared on HookContextSchema, placed with the other server-stamped write metadata (beside provenance). The doc block carries the one-fact-two-faces relationship to the operation-private key, the sandbox-carry contract, and the trust model (server-derived, never client-supplied). Parse pins added in packages/spec/src/data/hook.test.ts — load-bearing because this schema STRIPS undeclared keys on parse, so without the declaration a parsed context would silently lose the key.
  • objectqlpackages/objectql/src/engine.ts: new buildReferentialFieldClear (sibling of buildSession / buildProvenance) projects the operation envelope's private marker onto the declared key at update()'s hook-context assembly — a single site that covers both phases and the per-row fan-out, because the after phase reuses the assembled context and the per-row contexts spread it. Conditional spread keeps the key truly absent (never present-but-undefined) on every other write. The 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 cascade-site comment now names the projection.
  • runtimepackages/runtime/src/sandbox/script-runner.ts (ScriptContext.referentialFieldClear, documented), body-runner.ts (buildSandboxContext copies it, hook face only, only in its declared shape true), quickjs-runner.ts (installCtx installs it on the VM ctx).
  • core — comment-only: packages/core/src/security/operation-private-keys.ts header now names the declared projection, so the operation-private registration cannot be misread as the app-facing channel. No behavior change, no changeset.
  • changeset .changeset/hook-ctx-referential-field-clear.md (spec minor / objectql minor / runtime minor), citing the ruling.
  • docs: content/docs/references/data/hook.mdx regenerated (gen:docs); content/docs/permissions/system-context.mdx re-anchored by the census gate's own --fix (pure line rot from the engine.ts insertions, 14 + 5 anchors, verified OK after).

Mandated condition 1 — sandbox reachability, pinned from inside the VM

packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts — real ObjectQL + real SqlDriver (better-sqlite3) + real QuickJSScriptRunner behind hookBodyRunnerFactory, the same wiring AppPlugin performs. Every assertion lands on what the body OBSERVED (reported out through the log capability):

  • on the cleanup write, ctx.referentialFieldClear === true inside the VM, in both phases, with the inherited caller identity present alongside;
  • on the user's hand-clear of the SAME lookup under the SAME identity, the key is ABSENT (typeof reads 'undefined'), so the guard idiom reads false.

This is deliberately NOT a kernel-rig read — the ruling names the #11552 declared-vs-observable family as the reason sandbox reachability is a condition of adoption, and the operation-private spelling is exactly what a kernel rig could read and a shipped body could not (buildSandboxApi may hand a body an object shim with no executionContext at all).

Mandated condition 2 — populate surface, pinned write site by write site

New describe in packages/objectql/src/engine-cascade-delete.test.ts:

  • scalar set_null clear: true in both phases, caller identity inherited, and the hand-clear control has NO key at all (absence asserted with an in check, not just an undefined read);
  • multiple: true member removal — the second cleanup write site in cascadeDeleteRelations — same assertions against its own hand-edit control;
  • an identity-less delete still marks its cleanup writes;
  • the one-fact-two-faces consistency leg: middleware captures the operation-private marker on the same writes, and the declared face is true exactly where the envelope carries it, write for write (three ops asserted in order: hand-clear, restore, cleanup).

Derived judgments — each stated, none silently decided

  1. Accept-set change: pure widening by one optional, engine-produced key on the deliberately non-strict runtime context shape. No previously valid context changes meaning; a context without the key parses exactly as before; the engine writes true or omits the key, never false.
  2. Old operation-private key disposition: KEPT, unchanged.__referentialFieldClear remains the engine/middleware authorization channel — plugin-security's ownership-anchor exemption reads it off the operation context before any hook runs — and the operation-private-keys stripping rule is untouched. The declared key is a read-only projection, not a replacement; no dual-write transition is needed because the two faces have one producer site each and the projection is derived at assembly. The governance ledger needed no mechanical change (comment updated only).
  3. Envelope-scoped semantics, flagged: the declared key faithfully projects the envelope fact. A write a hook itself issues through ctx.api DURING a cleanup dispatch runs on the same inherited envelope and therefore carries the marker too — this is the semantics the operation-private key has always had (and what plugin-security keys on), projected rather than quietly re-scoped. If a strictly per-write-site signal is ever wanted instead, that is a contract decision to take explicitly, not a default this PR assumes.
  4. Populate site singular (update assembly only), flagged: every reference-cleanup write the engine issues is an update, so update()'s assembly is the complete populate surface. The find/insert/delete assemblies deliberately do NOT project the envelope — a delete or insert is never itself a reference-cleanup write, and advertising the envelope there would widen the key's meaning without a pull.
  5. Semver: spec minor (declared contract-surface widening), objectql minor (the engine now populates a declared contract face), runtime minor (a new marshalled member of the sandbox contract). core is comment-only: no changeset.
  6. ADR-0087: non-breaking addition — no disposition marker owed on a non-breaking changeset; check:adr-0087-registration green.

Tests and gates — real readings, final tree

All exit codes captured redirect-first (never through a pipe); heavy runs serialized through scripts/pm/os-verify-lock.sh. Final union re-run at HEAD 1108016dd (post-merge of origin/main, census deferral discharged):

  • spec src/data/hook.test.ts: 79 passed (79) — includes the two new parse pins
  • core src/security/operation-private-keys.test.ts: 8 passed (8)
  • objectql src/engine-cascade-delete.test.ts + src/hook-input-shape-contract.test.ts: 35 passed (35)
  • runtime src/sandbox/referential-field-clear-signal.integration.test.ts: 1 passed (real QuickJS; earlier full sandbox sweep also green: perrow-dispatch-signal.integration + body-runner + quickjs-runner = 101 passed (101))
  • typecheck: spec, objectql, runtime all green on the merged tree (objectql's check:test-typecheck ratchet held at its ledgered count — one new TS2345 was introduced by the suite and fixed rather than ledgered). Note runtime's tsc --noEmit excludes test files by its tsconfig (pre-existing), so the new integration test is proven by execution, not by tsc.
  • full workspace build: turbo 71/71 tasks successful (9m50s); check:dev-prereqs green after the post-merge spec rebuild ("built from the sources on disk"); pnpm check:dual-build-cjs-loads green (93 self-test cases + ledger)
  • pnpm --filter @objectstack/spec check:generated: one stale artifact (check:docs) regenerated via --fix; full re-run green. check:authorable-surface green (data shard gained the one declared row)
  • pnpm check:type-check-debt on the merged tree: "27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number; surplus: none" — nothing raised
  • gate families derived from the actual change set (node scripts/pm/dispatch-gates.mjs, no hand-fed paths, re-derived after fetching origin/main): all derived local gates green, including check:system-context-census (line-rot re-anchored via its own --fix, verified "109 sites, all anchored"), check:engine-split-ratio (after the gate's own deepen remedy), check:corpus-claim-drift, check:doc-anchors, check:kernel-hook-pairs, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, and the spec-scoped family (docs/liveness/strictness-ledger/llms-txt/skill-refs/variant-docs/yaml-examples/empty-state/skill-examples)
  • node scripts/check-test-completeness.mjs: exit 3 PREREQUISITE NOT MET — by its own text the local reading is NOT MEASURED (it grades a saved turbo test log CI produces); left to CI
  • check:nul-bytes green (7751 files scanned)

Out of scope: #13331 remains open and untouched by this PR (its branch carries no commits; its lane escalated to a maintainer decision independently of this card).

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 8 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via HookContextSchema (symbol, a top-level const), cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 1 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 — 139 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea363f033874f43918c13e0f1d6e8714fe3782ee — the merge of head 1108016ddda0e77ef797776bd9f8a2d146e4309f into base 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33, 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 ea363f033874f43918c13e0f1d6e8714fe3782ee && git checkout ea363f033874f43918c13e0f1d6e8714fe3782ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 1108016ddda0e77ef797776bd9f8a2d146e4309f && git checkout -B drift-repro 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 && git merge --no-ff 1108016ddda0e77ef797776bd9f8a2d146e4309f
node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33

⚠️ 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 1, 2026 11:57
@os-support-ai
os-support-ai added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 34ce8e7Sep 1, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-13644-referential-field-clear branch September 1, 2026 12:41
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
Same mechanism as the previous merge: main's #14199 re-anchored 11 rows
of this machine-maintained table while this branch re-anchors row 21,
and a textually clean merge proves nothing about the line numbers.
Resolution per the tool: took main's page wholesale, re-derived with
check-system-context-census --fix on the merged tree — exactly ONE
anchor rewritten (row 21, protocol.ts 1737 to 1741; re-derived, not
carried — main has zero commits on protocol.ts, so the insertion shift
is unchanged). Delta vs origin/main is that single line; bare gate
green (109 sites, 145 anchors). engine-double-contract ledger: zero
main-side commits, gate OK on the merged tree — no regeneration owed.
Co-Authored-By: Claude Opus 5 <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 documentationprotocol:datasize/lteststooling

Projects

None yet

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

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary - #14199

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear
Sep 1, 2026
Merged

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary#14199
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#13644

Executes the 2026-08-31 adoption ruling (decision record: issue comment 5478756633): declare a first-class ctx.referentialFieldClear on HookContextSchema, populated by the engine on every reference-cleanup write (deleteBehavior: 'set_null' cascade), and carried across the sandbox boundary by contract — with BOTH mandated conditions landed as executable pins in this same PR.

Clause-②: yes — published contract-surface widening (one new optional, engine-produced key on HookContextSchema). Parked for the contract-review tier per the ruling; no enqueue from this seat.

Freshness gate honoured

Designed from the filer's correction comment (5474384377), not from the body's "Measured on 17.1.0" table (a rig artifact, per the ruling's own record). The engine builds the cleanup write's context by spreading the caller's envelope and adding the transaction handle and the operation-private marker — it inherits whatever identity the caller supplied — so on the path a real request takes (a REST DELETE carrying a userId), ctx.user, ctx.session and ctx.input are identical between the engine's cascade and a user's hand-clear of the same lookup. Consequences taken in this PR:

  • every pin runs under a full REST-shaped caller envelope (userId plus isSystem), so the discriminator-erasing condition is in force inside the tests rather than dodged by an identity-less rig context;
  • the identity-less shape (what the original card's rig happened to measure) is additionally pinned, proving the marker keys on the operation and not on any identity member.

What landed

  • specpackages/spec/src/data/hook.zod.ts: referentialFieldClear (boolean, optional) declared on HookContextSchema, placed with the other server-stamped write metadata (beside provenance). The doc block carries the one-fact-two-faces relationship to the operation-private key, the sandbox-carry contract, and the trust model (server-derived, never client-supplied). Parse pins added in packages/spec/src/data/hook.test.ts — load-bearing because this schema STRIPS undeclared keys on parse, so without the declaration a parsed context would silently lose the key.
  • objectqlpackages/objectql/src/engine.ts: new buildReferentialFieldClear (sibling of buildSession / buildProvenance) projects the operation envelope's private marker onto the declared key at update()'s hook-context assembly — a single site that covers both phases and the per-row fan-out, because the after phase reuses the assembled context and the per-row contexts spread it. Conditional spread keeps the key truly absent (never present-but-undefined) on every other write. The 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 cascade-site comment now names the projection.
  • runtimepackages/runtime/src/sandbox/script-runner.ts (ScriptContext.referentialFieldClear, documented), body-runner.ts (buildSandboxContext copies it, hook face only, only in its declared shape true), quickjs-runner.ts (installCtx installs it on the VM ctx).
  • core — comment-only: packages/core/src/security/operation-private-keys.ts header now names the declared projection, so the operation-private registration cannot be misread as the app-facing channel. No behavior change, no changeset.
  • changeset .changeset/hook-ctx-referential-field-clear.md (spec minor / objectql minor / runtime minor), citing the ruling.
  • docs: content/docs/references/data/hook.mdx regenerated (gen:docs); content/docs/permissions/system-context.mdx re-anchored by the census gate's own --fix (pure line rot from the engine.ts insertions, 14 + 5 anchors, verified OK after).

Mandated condition 1 — sandbox reachability, pinned from inside the VM

packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts — real ObjectQL + real SqlDriver (better-sqlite3) + real QuickJSScriptRunner behind hookBodyRunnerFactory, the same wiring AppPlugin performs. Every assertion lands on what the body OBSERVED (reported out through the log capability):

  • on the cleanup write, ctx.referentialFieldClear === true inside the VM, in both phases, with the inherited caller identity present alongside;
  • on the user's hand-clear of the SAME lookup under the SAME identity, the key is ABSENT (typeof reads 'undefined'), so the guard idiom reads false.

This is deliberately NOT a kernel-rig read — the ruling names the #11552 declared-vs-observable family as the reason sandbox reachability is a condition of adoption, and the operation-private spelling is exactly what a kernel rig could read and a shipped body could not (buildSandboxApi may hand a body an object shim with no executionContext at all).

Mandated condition 2 — populate surface, pinned write site by write site

New describe in packages/objectql/src/engine-cascade-delete.test.ts:

  • scalar set_null clear: true in both phases, caller identity inherited, and the hand-clear control has NO key at all (absence asserted with an in check, not just an undefined read);
  • multiple: true member removal — the second cleanup write site in cascadeDeleteRelations — same assertions against its own hand-edit control;
  • an identity-less delete still marks its cleanup writes;
  • the one-fact-two-faces consistency leg: middleware captures the operation-private marker on the same writes, and the declared face is true exactly where the envelope carries it, write for write (three ops asserted in order: hand-clear, restore, cleanup).

Derived judgments — each stated, none silently decided

  1. Accept-set change: pure widening by one optional, engine-produced key on the deliberately non-strict runtime context shape. No previously valid context changes meaning; a context without the key parses exactly as before; the engine writes true or omits the key, never false.
  2. Old operation-private key disposition: KEPT, unchanged.__referentialFieldClear remains the engine/middleware authorization channel — plugin-security's ownership-anchor exemption reads it off the operation context before any hook runs — and the operation-private-keys stripping rule is untouched. The declared key is a read-only projection, not a replacement; no dual-write transition is needed because the two faces have one producer site each and the projection is derived at assembly. The governance ledger needed no mechanical change (comment updated only).
  3. Envelope-scoped semantics, flagged: the declared key faithfully projects the envelope fact. A write a hook itself issues through ctx.api DURING a cleanup dispatch runs on the same inherited envelope and therefore carries the marker too — this is the semantics the operation-private key has always had (and what plugin-security keys on), projected rather than quietly re-scoped. If a strictly per-write-site signal is ever wanted instead, that is a contract decision to take explicitly, not a default this PR assumes.
  4. Populate site singular (update assembly only), flagged: every reference-cleanup write the engine issues is an update, so update()'s assembly is the complete populate surface. The find/insert/delete assemblies deliberately do NOT project the envelope — a delete or insert is never itself a reference-cleanup write, and advertising the envelope there would widen the key's meaning without a pull.
  5. Semver: spec minor (declared contract-surface widening), objectql minor (the engine now populates a declared contract face), runtime minor (a new marshalled member of the sandbox contract). core is comment-only: no changeset.
  6. ADR-0087: non-breaking addition — no disposition marker owed on a non-breaking changeset; check:adr-0087-registration green.

Tests and gates — real readings, final tree

All exit codes captured redirect-first (never through a pipe); heavy runs serialized through scripts/pm/os-verify-lock.sh. Final union re-run at HEAD 1108016dd (post-merge of origin/main, census deferral discharged):

  • spec src/data/hook.test.ts: 79 passed (79) — includes the two new parse pins
  • core src/security/operation-private-keys.test.ts: 8 passed (8)
  • objectql src/engine-cascade-delete.test.ts + src/hook-input-shape-contract.test.ts: 35 passed (35)
  • runtime src/sandbox/referential-field-clear-signal.integration.test.ts: 1 passed (real QuickJS; earlier full sandbox sweep also green: perrow-dispatch-signal.integration + body-runner + quickjs-runner = 101 passed (101))
  • typecheck: spec, objectql, runtime all green on the merged tree (objectql's check:test-typecheck ratchet held at its ledgered count — one new TS2345 was introduced by the suite and fixed rather than ledgered). Note runtime's tsc --noEmit excludes test files by its tsconfig (pre-existing), so the new integration test is proven by execution, not by tsc.
  • full workspace build: turbo 71/71 tasks successful (9m50s); check:dev-prereqs green after the post-merge spec rebuild ("built from the sources on disk"); pnpm check:dual-build-cjs-loads green (93 self-test cases + ledger)
  • pnpm --filter @objectstack/spec check:generated: one stale artifact (check:docs) regenerated via --fix; full re-run green. check:authorable-surface green (data shard gained the one declared row)
  • pnpm check:type-check-debt on the merged tree: "27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number; surplus: none" — nothing raised
  • gate families derived from the actual change set (node scripts/pm/dispatch-gates.mjs, no hand-fed paths, re-derived after fetching origin/main): all derived local gates green, including check:system-context-census (line-rot re-anchored via its own --fix, verified "109 sites, all anchored"), check:engine-split-ratio (after the gate's own deepen remedy), check:corpus-claim-drift, check:doc-anchors, check:kernel-hook-pairs, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, and the spec-scoped family (docs/liveness/strictness-ledger/llms-txt/skill-refs/variant-docs/yaml-examples/empty-state/skill-examples)
  • node scripts/check-test-completeness.mjs: exit 3 PREREQUISITE NOT MET — by its own text the local reading is NOT MEASURED (it grades a saved turbo test log CI produces); left to CI
  • check:nul-bytes green (7751 files scanned)

Out of scope: #13331 remains open and untouched by this PR (its branch carries no commits; its lane escalated to a maintainer decision independently of this card).

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 8 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via HookContextSchema (symbol, a top-level const), cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 1 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 — 139 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea363f033874f43918c13e0f1d6e8714fe3782ee — the merge of head 1108016ddda0e77ef797776bd9f8a2d146e4309f into base 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33, 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 ea363f033874f43918c13e0f1d6e8714fe3782ee && git checkout ea363f033874f43918c13e0f1d6e8714fe3782ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 1108016ddda0e77ef797776bd9f8a2d146e4309f && git checkout -B drift-repro 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 && git merge --no-ff 1108016ddda0e77ef797776bd9f8a2d146e4309f
node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33

⚠️ 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 1, 2026 11:57
@os-support-ai
os-support-ai added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 34ce8e7Sep 1, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-13644-referential-field-clear branch September 1, 2026 12:41
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
Same mechanism as the previous merge: main's #14199 re-anchored 11 rows
of this machine-maintained table while this branch re-anchors row 21,
and a textually clean merge proves nothing about the line numbers.
Resolution per the tool: took main's page wholesale, re-derived with
check-system-context-census --fix on the merged tree — exactly ONE
anchor rewritten (row 21, protocol.ts 1737 to 1741; re-derived, not
carried — main has zero commits on protocol.ts, so the insertion shift
is unchanged). Delta vs origin/main is that single line; bare gate
green (109 sites, 145 anchors). engine-double-contract ledger: zero
main-side commits, gate OK on the merged tree — no regeneration owed.
Co-Authored-By: Claude Opus 5 <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 documentationprotocol:datasize/lteststooling

Projects

None yet

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

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary - #14199

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear
Sep 1, 2026
Merged

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary#14199
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#13644

Executes the 2026-08-31 adoption ruling (decision record: issue comment 5478756633): declare a first-class ctx.referentialFieldClear on HookContextSchema, populated by the engine on every reference-cleanup write (deleteBehavior: 'set_null' cascade), and carried across the sandbox boundary by contract — with BOTH mandated conditions landed as executable pins in this same PR.

Clause-②: yes — published contract-surface widening (one new optional, engine-produced key on HookContextSchema). Parked for the contract-review tier per the ruling; no enqueue from this seat.

Freshness gate honoured

Designed from the filer's correction comment (5474384377), not from the body's "Measured on 17.1.0" table (a rig artifact, per the ruling's own record). The engine builds the cleanup write's context by spreading the caller's envelope and adding the transaction handle and the operation-private marker — it inherits whatever identity the caller supplied — so on the path a real request takes (a REST DELETE carrying a userId), ctx.user, ctx.session and ctx.input are identical between the engine's cascade and a user's hand-clear of the same lookup. Consequences taken in this PR:

  • every pin runs under a full REST-shaped caller envelope (userId plus isSystem), so the discriminator-erasing condition is in force inside the tests rather than dodged by an identity-less rig context;
  • the identity-less shape (what the original card's rig happened to measure) is additionally pinned, proving the marker keys on the operation and not on any identity member.

What landed

  • specpackages/spec/src/data/hook.zod.ts: referentialFieldClear (boolean, optional) declared on HookContextSchema, placed with the other server-stamped write metadata (beside provenance). The doc block carries the one-fact-two-faces relationship to the operation-private key, the sandbox-carry contract, and the trust model (server-derived, never client-supplied). Parse pins added in packages/spec/src/data/hook.test.ts — load-bearing because this schema STRIPS undeclared keys on parse, so without the declaration a parsed context would silently lose the key.
  • objectqlpackages/objectql/src/engine.ts: new buildReferentialFieldClear (sibling of buildSession / buildProvenance) projects the operation envelope's private marker onto the declared key at update()'s hook-context assembly — a single site that covers both phases and the per-row fan-out, because the after phase reuses the assembled context and the per-row contexts spread it. Conditional spread keeps the key truly absent (never present-but-undefined) on every other write. The 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 cascade-site comment now names the projection.
  • runtimepackages/runtime/src/sandbox/script-runner.ts (ScriptContext.referentialFieldClear, documented), body-runner.ts (buildSandboxContext copies it, hook face only, only in its declared shape true), quickjs-runner.ts (installCtx installs it on the VM ctx).
  • core — comment-only: packages/core/src/security/operation-private-keys.ts header now names the declared projection, so the operation-private registration cannot be misread as the app-facing channel. No behavior change, no changeset.
  • changeset .changeset/hook-ctx-referential-field-clear.md (spec minor / objectql minor / runtime minor), citing the ruling.
  • docs: content/docs/references/data/hook.mdx regenerated (gen:docs); content/docs/permissions/system-context.mdx re-anchored by the census gate's own --fix (pure line rot from the engine.ts insertions, 14 + 5 anchors, verified OK after).

Mandated condition 1 — sandbox reachability, pinned from inside the VM

packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts — real ObjectQL + real SqlDriver (better-sqlite3) + real QuickJSScriptRunner behind hookBodyRunnerFactory, the same wiring AppPlugin performs. Every assertion lands on what the body OBSERVED (reported out through the log capability):

  • on the cleanup write, ctx.referentialFieldClear === true inside the VM, in both phases, with the inherited caller identity present alongside;
  • on the user's hand-clear of the SAME lookup under the SAME identity, the key is ABSENT (typeof reads 'undefined'), so the guard idiom reads false.

This is deliberately NOT a kernel-rig read — the ruling names the #11552 declared-vs-observable family as the reason sandbox reachability is a condition of adoption, and the operation-private spelling is exactly what a kernel rig could read and a shipped body could not (buildSandboxApi may hand a body an object shim with no executionContext at all).

Mandated condition 2 — populate surface, pinned write site by write site

New describe in packages/objectql/src/engine-cascade-delete.test.ts:

  • scalar set_null clear: true in both phases, caller identity inherited, and the hand-clear control has NO key at all (absence asserted with an in check, not just an undefined read);
  • multiple: true member removal — the second cleanup write site in cascadeDeleteRelations — same assertions against its own hand-edit control;
  • an identity-less delete still marks its cleanup writes;
  • the one-fact-two-faces consistency leg: middleware captures the operation-private marker on the same writes, and the declared face is true exactly where the envelope carries it, write for write (three ops asserted in order: hand-clear, restore, cleanup).

Derived judgments — each stated, none silently decided

  1. Accept-set change: pure widening by one optional, engine-produced key on the deliberately non-strict runtime context shape. No previously valid context changes meaning; a context without the key parses exactly as before; the engine writes true or omits the key, never false.
  2. Old operation-private key disposition: KEPT, unchanged.__referentialFieldClear remains the engine/middleware authorization channel — plugin-security's ownership-anchor exemption reads it off the operation context before any hook runs — and the operation-private-keys stripping rule is untouched. The declared key is a read-only projection, not a replacement; no dual-write transition is needed because the two faces have one producer site each and the projection is derived at assembly. The governance ledger needed no mechanical change (comment updated only).
  3. Envelope-scoped semantics, flagged: the declared key faithfully projects the envelope fact. A write a hook itself issues through ctx.api DURING a cleanup dispatch runs on the same inherited envelope and therefore carries the marker too — this is the semantics the operation-private key has always had (and what plugin-security keys on), projected rather than quietly re-scoped. If a strictly per-write-site signal is ever wanted instead, that is a contract decision to take explicitly, not a default this PR assumes.
  4. Populate site singular (update assembly only), flagged: every reference-cleanup write the engine issues is an update, so update()'s assembly is the complete populate surface. The find/insert/delete assemblies deliberately do NOT project the envelope — a delete or insert is never itself a reference-cleanup write, and advertising the envelope there would widen the key's meaning without a pull.
  5. Semver: spec minor (declared contract-surface widening), objectql minor (the engine now populates a declared contract face), runtime minor (a new marshalled member of the sandbox contract). core is comment-only: no changeset.
  6. ADR-0087: non-breaking addition — no disposition marker owed on a non-breaking changeset; check:adr-0087-registration green.

Tests and gates — real readings, final tree

All exit codes captured redirect-first (never through a pipe); heavy runs serialized through scripts/pm/os-verify-lock.sh. Final union re-run at HEAD 1108016dd (post-merge of origin/main, census deferral discharged):

  • spec src/data/hook.test.ts: 79 passed (79) — includes the two new parse pins
  • core src/security/operation-private-keys.test.ts: 8 passed (8)
  • objectql src/engine-cascade-delete.test.ts + src/hook-input-shape-contract.test.ts: 35 passed (35)
  • runtime src/sandbox/referential-field-clear-signal.integration.test.ts: 1 passed (real QuickJS; earlier full sandbox sweep also green: perrow-dispatch-signal.integration + body-runner + quickjs-runner = 101 passed (101))
  • typecheck: spec, objectql, runtime all green on the merged tree (objectql's check:test-typecheck ratchet held at its ledgered count — one new TS2345 was introduced by the suite and fixed rather than ledgered). Note runtime's tsc --noEmit excludes test files by its tsconfig (pre-existing), so the new integration test is proven by execution, not by tsc.
  • full workspace build: turbo 71/71 tasks successful (9m50s); check:dev-prereqs green after the post-merge spec rebuild ("built from the sources on disk"); pnpm check:dual-build-cjs-loads green (93 self-test cases + ledger)
  • pnpm --filter @objectstack/spec check:generated: one stale artifact (check:docs) regenerated via --fix; full re-run green. check:authorable-surface green (data shard gained the one declared row)
  • pnpm check:type-check-debt on the merged tree: "27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number; surplus: none" — nothing raised
  • gate families derived from the actual change set (node scripts/pm/dispatch-gates.mjs, no hand-fed paths, re-derived after fetching origin/main): all derived local gates green, including check:system-context-census (line-rot re-anchored via its own --fix, verified "109 sites, all anchored"), check:engine-split-ratio (after the gate's own deepen remedy), check:corpus-claim-drift, check:doc-anchors, check:kernel-hook-pairs, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, and the spec-scoped family (docs/liveness/strictness-ledger/llms-txt/skill-refs/variant-docs/yaml-examples/empty-state/skill-examples)
  • node scripts/check-test-completeness.mjs: exit 3 PREREQUISITE NOT MET — by its own text the local reading is NOT MEASURED (it grades a saved turbo test log CI produces); left to CI
  • check:nul-bytes green (7751 files scanned)

Out of scope: #13331 remains open and untouched by this PR (its branch carries no commits; its lane escalated to a maintainer decision independently of this card).

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 8 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via HookContextSchema (symbol, a top-level const), cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 1 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 — 139 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea363f033874f43918c13e0f1d6e8714fe3782ee — the merge of head 1108016ddda0e77ef797776bd9f8a2d146e4309f into base 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33, 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 ea363f033874f43918c13e0f1d6e8714fe3782ee && git checkout ea363f033874f43918c13e0f1d6e8714fe3782ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 1108016ddda0e77ef797776bd9f8a2d146e4309f && git checkout -B drift-repro 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 && git merge --no-ff 1108016ddda0e77ef797776bd9f8a2d146e4309f
node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33

⚠️ 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 1, 2026 11:57
@os-support-ai
os-support-ai added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 34ce8e7Sep 1, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-13644-referential-field-clear branch September 1, 2026 12:41
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
Same mechanism as the previous merge: main's #14199 re-anchored 11 rows
of this machine-maintained table while this branch re-anchors row 21,
and a textually clean merge proves nothing about the line numbers.
Resolution per the tool: took main's page wholesale, re-derived with
check-system-context-census --fix on the merged tree — exactly ONE
anchor rewritten (row 21, protocol.ts 1737 to 1741; re-derived, not
carried — main has zero commits on protocol.ts, so the insertion shift
is unchanged). Delta vs origin/main is that single line; bare gate
green (109 sites, 145 anchors). engine-double-contract ledger: zero
main-side commits, gate OK on the merged tree — no regeneration owed.
Co-Authored-By: Claude Opus 5 <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 documentationprotocol:datasize/lteststooling

Projects

None yet

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

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary - #14199

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear
Sep 1, 2026
Merged

feat(spec,objectql,runtime): declare ctx.referentialFieldClear on HookContextSchema, populate every set_null cleanup write, and carry it across the QuickJS sandbox boundary#14199
os-support-ai merged 7 commits into
mainfrom
claude/issue-13644-referential-field-clear

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#13644

Executes the 2026-08-31 adoption ruling (decision record: issue comment 5478756633): declare a first-class ctx.referentialFieldClear on HookContextSchema, populated by the engine on every reference-cleanup write (deleteBehavior: 'set_null' cascade), and carried across the sandbox boundary by contract — with BOTH mandated conditions landed as executable pins in this same PR.

Clause-②: yes — published contract-surface widening (one new optional, engine-produced key on HookContextSchema). Parked for the contract-review tier per the ruling; no enqueue from this seat.

Freshness gate honoured

Designed from the filer's correction comment (5474384377), not from the body's "Measured on 17.1.0" table (a rig artifact, per the ruling's own record). The engine builds the cleanup write's context by spreading the caller's envelope and adding the transaction handle and the operation-private marker — it inherits whatever identity the caller supplied — so on the path a real request takes (a REST DELETE carrying a userId), ctx.user, ctx.session and ctx.input are identical between the engine's cascade and a user's hand-clear of the same lookup. Consequences taken in this PR:

  • every pin runs under a full REST-shaped caller envelope (userId plus isSystem), so the discriminator-erasing condition is in force inside the tests rather than dodged by an identity-less rig context;
  • the identity-less shape (what the original card's rig happened to measure) is additionally pinned, proving the marker keys on the operation and not on any identity member.

What landed

  • specpackages/spec/src/data/hook.zod.ts: referentialFieldClear (boolean, optional) declared on HookContextSchema, placed with the other server-stamped write metadata (beside provenance). The doc block carries the one-fact-two-faces relationship to the operation-private key, the sandbox-carry contract, and the trust model (server-derived, never client-supplied). Parse pins added in packages/spec/src/data/hook.test.ts — load-bearing because this schema STRIPS undeclared keys on parse, so without the declaration a parsed context would silently lose the key.
  • objectqlpackages/objectql/src/engine.ts: new buildReferentialFieldClear (sibling of buildSession / buildProvenance) projects the operation envelope's private marker onto the declared key at update()'s hook-context assembly — a single site that covers both phases and the per-row fan-out, because the after phase reuses the assembled context and the per-row contexts spread it. Conditional spread keeps the key truly absent (never present-but-undefined) on every other write. The 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 cascade-site comment now names the projection.
  • runtimepackages/runtime/src/sandbox/script-runner.ts (ScriptContext.referentialFieldClear, documented), body-runner.ts (buildSandboxContext copies it, hook face only, only in its declared shape true), quickjs-runner.ts (installCtx installs it on the VM ctx).
  • core — comment-only: packages/core/src/security/operation-private-keys.ts header now names the declared projection, so the operation-private registration cannot be misread as the app-facing channel. No behavior change, no changeset.
  • changeset .changeset/hook-ctx-referential-field-clear.md (spec minor / objectql minor / runtime minor), citing the ruling.
  • docs: content/docs/references/data/hook.mdx regenerated (gen:docs); content/docs/permissions/system-context.mdx re-anchored by the census gate's own --fix (pure line rot from the engine.ts insertions, 14 + 5 anchors, verified OK after).

Mandated condition 1 — sandbox reachability, pinned from inside the VM

packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts — real ObjectQL + real SqlDriver (better-sqlite3) + real QuickJSScriptRunner behind hookBodyRunnerFactory, the same wiring AppPlugin performs. Every assertion lands on what the body OBSERVED (reported out through the log capability):

  • on the cleanup write, ctx.referentialFieldClear === true inside the VM, in both phases, with the inherited caller identity present alongside;
  • on the user's hand-clear of the SAME lookup under the SAME identity, the key is ABSENT (typeof reads 'undefined'), so the guard idiom reads false.

This is deliberately NOT a kernel-rig read — the ruling names the #11552 declared-vs-observable family as the reason sandbox reachability is a condition of adoption, and the operation-private spelling is exactly what a kernel rig could read and a shipped body could not (buildSandboxApi may hand a body an object shim with no executionContext at all).

Mandated condition 2 — populate surface, pinned write site by write site

New describe in packages/objectql/src/engine-cascade-delete.test.ts:

  • scalar set_null clear: true in both phases, caller identity inherited, and the hand-clear control has NO key at all (absence asserted with an in check, not just an undefined read);
  • multiple: true member removal — the second cleanup write site in cascadeDeleteRelations — same assertions against its own hand-edit control;
  • an identity-less delete still marks its cleanup writes;
  • the one-fact-two-faces consistency leg: middleware captures the operation-private marker on the same writes, and the declared face is true exactly where the envelope carries it, write for write (three ops asserted in order: hand-clear, restore, cleanup).

Derived judgments — each stated, none silently decided

  1. Accept-set change: pure widening by one optional, engine-produced key on the deliberately non-strict runtime context shape. No previously valid context changes meaning; a context without the key parses exactly as before; the engine writes true or omits the key, never false.
  2. Old operation-private key disposition: KEPT, unchanged.__referentialFieldClear remains the engine/middleware authorization channel — plugin-security's ownership-anchor exemption reads it off the operation context before any hook runs — and the operation-private-keys stripping rule is untouched. The declared key is a read-only projection, not a replacement; no dual-write transition is needed because the two faces have one producer site each and the projection is derived at assembly. The governance ledger needed no mechanical change (comment updated only).
  3. Envelope-scoped semantics, flagged: the declared key faithfully projects the envelope fact. A write a hook itself issues through ctx.api DURING a cleanup dispatch runs on the same inherited envelope and therefore carries the marker too — this is the semantics the operation-private key has always had (and what plugin-security keys on), projected rather than quietly re-scoped. If a strictly per-write-site signal is ever wanted instead, that is a contract decision to take explicitly, not a default this PR assumes.
  4. Populate site singular (update assembly only), flagged: every reference-cleanup write the engine issues is an update, so update()'s assembly is the complete populate surface. The find/insert/delete assemblies deliberately do NOT project the envelope — a delete or insert is never itself a reference-cleanup write, and advertising the envelope there would widen the key's meaning without a pull.
  5. Semver: spec minor (declared contract-surface widening), objectql minor (the engine now populates a declared contract face), runtime minor (a new marshalled member of the sandbox contract). core is comment-only: no changeset.
  6. ADR-0087: non-breaking addition — no disposition marker owed on a non-breaking changeset; check:adr-0087-registration green.

Tests and gates — real readings, final tree

All exit codes captured redirect-first (never through a pipe); heavy runs serialized through scripts/pm/os-verify-lock.sh. Final union re-run at HEAD 1108016dd (post-merge of origin/main, census deferral discharged):

  • spec src/data/hook.test.ts: 79 passed (79) — includes the two new parse pins
  • core src/security/operation-private-keys.test.ts: 8 passed (8)
  • objectql src/engine-cascade-delete.test.ts + src/hook-input-shape-contract.test.ts: 35 passed (35)
  • runtime src/sandbox/referential-field-clear-signal.integration.test.ts: 1 passed (real QuickJS; earlier full sandbox sweep also green: perrow-dispatch-signal.integration + body-runner + quickjs-runner = 101 passed (101))
  • typecheck: spec, objectql, runtime all green on the merged tree (objectql's check:test-typecheck ratchet held at its ledgered count — one new TS2345 was introduced by the suite and fixed rather than ledgered). Note runtime's tsc --noEmit excludes test files by its tsconfig (pre-existing), so the new integration test is proven by execution, not by tsc.
  • full workspace build: turbo 71/71 tasks successful (9m50s); check:dev-prereqs green after the post-merge spec rebuild ("built from the sources on disk"); pnpm check:dual-build-cjs-loads green (93 self-test cases + ledger)
  • pnpm --filter @objectstack/spec check:generated: one stale artifact (check:docs) regenerated via --fix; full re-run green. check:authorable-surface green (data shard gained the one declared row)
  • pnpm check:type-check-debt on the merged tree: "27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number; surplus: none" — nothing raised
  • gate families derived from the actual change set (node scripts/pm/dispatch-gates.mjs, no hand-fed paths, re-derived after fetching origin/main): all derived local gates green, including check:system-context-census (line-rot re-anchored via its own --fix, verified "109 sites, all anchored"), check:engine-split-ratio (after the gate's own deepen remedy), check:corpus-claim-drift, check:doc-anchors, check:kernel-hook-pairs, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, and the spec-scoped family (docs/liveness/strictness-ledger/llms-txt/skill-refs/variant-docs/yaml-examples/empty-state/skill-examples)
  • node scripts/check-test-completeness.mjs: exit 3 PREREQUISITE NOT MET — by its own text the local reading is NOT MEASURED (it grades a saved turbo test log CI produces); left to CI
  • check:nul-bytes green (7751 files scanned)

Out of scope: #13331 remains open and untouched by this PR (its branch carries no commits; its lane escalated to a maintainer decision independently of this card).

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 8 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/hook-bodies.mdx(via ScriptContext (symbol, a top-level interface))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via HookContextSchema (symbol, a top-level const), cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts, packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 1 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 — 139 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea363f033874f43918c13e0f1d6e8714fe3782ee — the merge of head 1108016ddda0e77ef797776bd9f8a2d146e4309f into base 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33, 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 ea363f033874f43918c13e0f1d6e8714fe3782ee && git checkout ea363f033874f43918c13e0f1d6e8714fe3782ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 1108016ddda0e77ef797776bd9f8a2d146e4309f && git checkout -B drift-repro 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 && git merge --no-ff 1108016ddda0e77ef797776bd9f8a2d146e4309f
node scripts/docs-audit/affected-docs.mjs --json 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33

⚠️ 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 4d672c4ffe15b1fe2365e5efbf7251778f2c3e33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 1, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 1, 2026 11:57
@os-support-ai
os-support-ai added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 34ce8e7Sep 1, 2026
41 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-13644-referential-field-clear branch September 1, 2026 12:41
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
Same mechanism as the previous merge: main's #14199 re-anchored 11 rows
of this machine-maintained table while this branch re-anchors row 21,
and a textually clean merge proves nothing about the line numbers.
Resolution per the tool: took main's page wholesale, re-derived with
check-system-context-census --fix on the merged tree — exactly ONE
anchor rewritten (row 21, protocol.ts 1737 to 1741; re-derived, not
carried — main has zero commits on protocol.ts, so the insertion shift
is unchanged). Delta vs origin/main is that single line; bare gate
green (109 sites, 145 anchors). engine-double-contract ledger: zero
main-side commits, gate OK on the merged tree — no regeneration owed.
Co-Authored-By: Claude Opus 5 <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 documentationprotocol:datasize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude