fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL - #14505

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries
Sep 2, 2026
Merged

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL#14505
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14126

What

os migrate plan / os migrate apply boot host plugins for their declarations behind a write guard (#13332 / PR #14053) whose own census named two paths on which a declaration boot could still write while the notes printed "a plan writes nothing". Both are closed here, under ONE outcome-line rule, inside packages/cli/src/utils/schema-migration-plugins.ts only — packages/objectql/** and packages/services/** are untouched.

Residue 1 — engine-held drivers (the in-lane shape the dispatch ruled)

  • The guard now shadows registerDriver on the engine INSTANCE the kernel publishes (objectql / data — one object, shadowed once, keyed by instance), with the same in-place own-property technique armDriver already uses; disarm() deletes the own property so the instance resolves through its prototype again.
  • Each driver instance is armed BEFORE the SAME instance is forwarded to the engine's own method — never a wrapper in its place, never a second registration under a held name (the engine keeps the first instance and discards the second; identity decides).
  • Drivers the engine already holds when the guard arms are reached through its public accessors: getDefaultDriverName() + getDriverByName(), and registry.getAllObjects() + getDriverForObject() (both scans; idempotent).
  • A refusal on such a driver is reported via engine.NAME; the driver.* scan runs first, so the default keeps the driver.* label it always had.

The engine-instance shadow measurement (the FORK CLAUSE did not fire).ObjectQL.registerDriver is a prototype method (packages/objectql/src/engine.ts); the instance is neither frozen nor sealed — Object.isFrozen(engine) === false, Object.isExtensible(engine) === true, and registerDriver resolving through the prototype are asserted on the real class in the pin. Every runtime caller reaches it by a call-time property lookup on the published instance: DatasourceConnectionService.connect() via this.cfg.engine()ctx.getService('data'); AppPlugin's drivers.register via ctx.getService('objectql'); memory-driver.ts via ctx.engine.ql.registerDriver(this). No bound or copied registerDriver exists in runtime code — the only .registerDriver = in the repo is a runtime test patching the prototype. In the CLI boot, DefaultDatasourcePlugin.init() (which dependencies-orders itself after ObjectQLPlugin.init(), where objectql/data are published) registers the default THROUGH the engine and then republishes it as driver.NAME, and the guard's optionalDependencies orders its init() after that — so when the guard arms, the engine holds exactly the default, already armed under driver.*; no base-stack plugin registers a second driver. Every later registration — host init()s (Phase 1, after the guard), AppPlugin.start()connectAll (Phase 2) — arrives through the shadow.

What remains open, stated in the header census: an engine the kernel never publishes as a service, and a non-default driver registered BEFORE the guard armed that no registered object resolves to at either scan. Neither is reachable from the CLI without an engine change, and neither occurs in this repo's boot.

Residue 2 — immediate DDL (dropTable / rotateShards)

  • DRIVER_IMMEDIATE_DDL_METHODS = ['dropTable', 'rotateShards']: IDataDriver.dropTable() (a REQUIRED contract member, packages/spec/src/contracts/data-driver.ts) and driver-sql's rotateShards(objectDef, nowMs) (packages/drivers/driver-sql/src/sql-driver.ts); both run assertSchemaMutable, not the deferral.
  • Exactly the treatment execute() has: FORWARDED (⛔ not refused — refusing DDL an operator's own hook asked for is a behaviour change beyond this card), counted per driver/method/object (rotateShards takes the object DEFINITION, so its name is read off it), warned once per driver on stderr, named in the notes — and the claim is withheld.

The outcome-line rule, decided once for every remaining path

disarm() appends " — a plan writes nothing" to the refusal line only when rawExecutions.size === 0 && immediateDdl.size === 0 && unguardable.size === 0 && unshadowable.size === 0. Each of those four prints its own line naming what was forwarded (raw execute(), immediate DDL) or what refused the override (a frozen driver member, an engine whose registerDriver could not be shadowed) and says the run does NOT claim to have written nothing. A quiet boot — nothing refused, nothing forwarded, nothing unarmable — still returns null, so the notes of an embedder with no data plane, or of read/log-only hooks, are byte-identical to before.

Tests

packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts (extended; the new block boots the REAL ObjectQL behind stand-ins for ObjectQLPlugin and DefaultDatasourcePlugin) and packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts (one case added, real SqlDriver over a real sqlite file through bootSchemaStack):

  1. Residue 1 — POSITIVE CONTROL (the defect as shipped): without the guard, a hook writing to an object bound to an engine-registered second datasource LANDS.
  2. Residue 1 — THE FIX: the engine is shadowed once (shadowedEngines === ['objectql']), engine.getDriverByName('reporting') is the very instance the host registered, the write is refused and reported via engine.reporting, log-only hooks still ran, the claim HELD, and disarm() restores ObjectQL.prototype.registerDriver and the driver's own methods.
  3. Residue 1 — a driver the engine already held before the guard armed (registered from the datasource plugin, bound to archive_row) is armed through getDriverForObject(); its write is refused via engine.archive.
  4. Residue 1 — the driver.* default path unchanged: writes through driver.recording and through engine.getDriverForObject() to the same instance are refused under ONE label, driver.recording, count 2.
  5. Residue 2 — dropTable() ×2 and rotateShards() ×1 are forwarded (the recording driver ran them, their own return values came back), counted [dropTable on sys_old_table x2, rotateShards on sys_log x1], warned exactly once on stderr for the driver, named in the note, and the claim is withheld while the in-run create() control is still refused.
  6. Residue 2 — a forwarded DDL call with no refusal at all produces a note (before: null, indistinguishable from a quiet boot).
  7. ONE RULE — a frozen driver (Object.freeze, prototype methods still run, the write LANDS) is named in Could NOT guard … and withholds the claim although the other write was refused.
  8. ONE RULE — a frozen engine (registerDriver un-shadowable) is named in Could NOT shadow objectql.registerDriver and withholds the claim.
  9. POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing armed, nothing reported, disarm()null, hooks untouched.
  10. Integration — a hook calling dropTable('defer_widget') on the real sqlite driver during a composed declaration boot: the table is really gone afterwards (forwarded), composition.writeGuard.immediateDdl counts it, the notes name it, and no note claims "a plan writes nothing".

The seven pre-existing #13332 / #14053 pins are unchanged and green — including THE FIX's "Refused 3 write(s) … a plan writes nothing" and the execute() pin, which are the positive controls for the unchanged shapes.

Ablation (on the committed tree, trap-restored): the one line in scan() that calls shadowEngine replaced by a marker — mutation proven on disk (anchor count 1 → 0, marker count 1, blob 2f92252f…d42d9e53…); result: 3 failed / 13 passed — exactly cases 2, 3 and 8 above went red (shadowedEngines came back [], the archive write landed, no Could NOT shadow line), every residue-2 and control case stayed green; restore proven (git checkout HEAD -- PATH: on-disk blob == HEAD blob, git diff HEAD 0 lines, git status --porcelain empty). The subject resolves from source (same-package relative import), so no dist rebuild leg applies to it; the dependency closure was built once before the runs.

Base-source leg (the new tests against the pre-fix source at 8094834060, tree-only git restore --source=BASE, trap-restored): 7 failed / 9 passed — all seven new pins red (residue 1 ×2: shadowedEngines came back undefined, the archive write landed; residue 2 ×2: guard.immediateDdlundefined, the note still printed "a plan writes nothing" over a dropped table; ONE RULE ×2; the embedder control asserting shadowedEngines), the seven pre-existing pins and the two fixture controls green; restore proven the same way (on-disk blob == HEAD blob, git diff HEAD 0 lines, porcelain empty).

Verification

See the report comment on #14126 for the exit-code table (test run, cli typecheck, the 35-family dispatch-gates union, eslint narrowing evidence), each read from the gate's own verdict line with the exit code captured before any pipe.

Out of scope / not done

  • ⛔ No regex classification of SQL; hooks are not neutralised by phase; packages/objectql/** and packages/services/** are untouched (the in-lane shape did not need them).
  • The DDL still EXECUTES on a declaration boot — deliberately: this card makes it reported, not refused.
  • origin/main was not merged into this branch in the worktree (base 8094834060; origin/main = 00ff228fe0, 53 commits ahead, at the time of this PR). That delta touches neither registerDriver, nor this file, its tests or the changeset. It does move .github/workflows/lint.yml (adds check:swallow-census-controls, a self-test of measure-durability-swallow-family.mjs's control corpora, not a scan of this diff), release.yml, and scripts/pm/dispatch-gates.mjs itself — so the local gate derivation is from the base tree. CI on the merge ref and the queue's rebuilt generation are the authority for the joint tree.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…d-and-count immediate DDL
Engine-held drivers: shadow `registerDriver` on the engine instance the kernel
publishes (`objectql` / `data`), arm each driver instance in place as it is
registered and forward the SAME instance; reach already-held drivers through
the engine's public accessors; restore on `disarm()`.
Immediate DDL: `dropTable()` / `rotateShards()` get `execute()`'s treatment —
forwarded, counted per driver/method/object, warned once per driver, named in
the notes.
One outcome-line rule: "a plan writes nothing" prints only when nothing was
forwarded and nothing refused the override.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 22 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 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378packageMentionDocs.

Which tree this was computed on

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

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

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL - #14505

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries
Sep 2, 2026
Merged

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL#14505
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14126

What

os migrate plan / os migrate apply boot host plugins for their declarations behind a write guard (#13332 / PR #14053) whose own census named two paths on which a declaration boot could still write while the notes printed "a plan writes nothing". Both are closed here, under ONE outcome-line rule, inside packages/cli/src/utils/schema-migration-plugins.ts only — packages/objectql/** and packages/services/** are untouched.

Residue 1 — engine-held drivers (the in-lane shape the dispatch ruled)

  • The guard now shadows registerDriver on the engine INSTANCE the kernel publishes (objectql / data — one object, shadowed once, keyed by instance), with the same in-place own-property technique armDriver already uses; disarm() deletes the own property so the instance resolves through its prototype again.
  • Each driver instance is armed BEFORE the SAME instance is forwarded to the engine's own method — never a wrapper in its place, never a second registration under a held name (the engine keeps the first instance and discards the second; identity decides).
  • Drivers the engine already holds when the guard arms are reached through its public accessors: getDefaultDriverName() + getDriverByName(), and registry.getAllObjects() + getDriverForObject() (both scans; idempotent).
  • A refusal on such a driver is reported via engine.NAME; the driver.* scan runs first, so the default keeps the driver.* label it always had.

The engine-instance shadow measurement (the FORK CLAUSE did not fire).ObjectQL.registerDriver is a prototype method (packages/objectql/src/engine.ts); the instance is neither frozen nor sealed — Object.isFrozen(engine) === false, Object.isExtensible(engine) === true, and registerDriver resolving through the prototype are asserted on the real class in the pin. Every runtime caller reaches it by a call-time property lookup on the published instance: DatasourceConnectionService.connect() via this.cfg.engine()ctx.getService('data'); AppPlugin's drivers.register via ctx.getService('objectql'); memory-driver.ts via ctx.engine.ql.registerDriver(this). No bound or copied registerDriver exists in runtime code — the only .registerDriver = in the repo is a runtime test patching the prototype. In the CLI boot, DefaultDatasourcePlugin.init() (which dependencies-orders itself after ObjectQLPlugin.init(), where objectql/data are published) registers the default THROUGH the engine and then republishes it as driver.NAME, and the guard's optionalDependencies orders its init() after that — so when the guard arms, the engine holds exactly the default, already armed under driver.*; no base-stack plugin registers a second driver. Every later registration — host init()s (Phase 1, after the guard), AppPlugin.start()connectAll (Phase 2) — arrives through the shadow.

What remains open, stated in the header census: an engine the kernel never publishes as a service, and a non-default driver registered BEFORE the guard armed that no registered object resolves to at either scan. Neither is reachable from the CLI without an engine change, and neither occurs in this repo's boot.

Residue 2 — immediate DDL (dropTable / rotateShards)

  • DRIVER_IMMEDIATE_DDL_METHODS = ['dropTable', 'rotateShards']: IDataDriver.dropTable() (a REQUIRED contract member, packages/spec/src/contracts/data-driver.ts) and driver-sql's rotateShards(objectDef, nowMs) (packages/drivers/driver-sql/src/sql-driver.ts); both run assertSchemaMutable, not the deferral.
  • Exactly the treatment execute() has: FORWARDED (⛔ not refused — refusing DDL an operator's own hook asked for is a behaviour change beyond this card), counted per driver/method/object (rotateShards takes the object DEFINITION, so its name is read off it), warned once per driver on stderr, named in the notes — and the claim is withheld.

The outcome-line rule, decided once for every remaining path

disarm() appends " — a plan writes nothing" to the refusal line only when rawExecutions.size === 0 && immediateDdl.size === 0 && unguardable.size === 0 && unshadowable.size === 0. Each of those four prints its own line naming what was forwarded (raw execute(), immediate DDL) or what refused the override (a frozen driver member, an engine whose registerDriver could not be shadowed) and says the run does NOT claim to have written nothing. A quiet boot — nothing refused, nothing forwarded, nothing unarmable — still returns null, so the notes of an embedder with no data plane, or of read/log-only hooks, are byte-identical to before.

Tests

packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts (extended; the new block boots the REAL ObjectQL behind stand-ins for ObjectQLPlugin and DefaultDatasourcePlugin) and packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts (one case added, real SqlDriver over a real sqlite file through bootSchemaStack):

  1. Residue 1 — POSITIVE CONTROL (the defect as shipped): without the guard, a hook writing to an object bound to an engine-registered second datasource LANDS.
  2. Residue 1 — THE FIX: the engine is shadowed once (shadowedEngines === ['objectql']), engine.getDriverByName('reporting') is the very instance the host registered, the write is refused and reported via engine.reporting, log-only hooks still ran, the claim HELD, and disarm() restores ObjectQL.prototype.registerDriver and the driver's own methods.
  3. Residue 1 — a driver the engine already held before the guard armed (registered from the datasource plugin, bound to archive_row) is armed through getDriverForObject(); its write is refused via engine.archive.
  4. Residue 1 — the driver.* default path unchanged: writes through driver.recording and through engine.getDriverForObject() to the same instance are refused under ONE label, driver.recording, count 2.
  5. Residue 2 — dropTable() ×2 and rotateShards() ×1 are forwarded (the recording driver ran them, their own return values came back), counted [dropTable on sys_old_table x2, rotateShards on sys_log x1], warned exactly once on stderr for the driver, named in the note, and the claim is withheld while the in-run create() control is still refused.
  6. Residue 2 — a forwarded DDL call with no refusal at all produces a note (before: null, indistinguishable from a quiet boot).
  7. ONE RULE — a frozen driver (Object.freeze, prototype methods still run, the write LANDS) is named in Could NOT guard … and withholds the claim although the other write was refused.
  8. ONE RULE — a frozen engine (registerDriver un-shadowable) is named in Could NOT shadow objectql.registerDriver and withholds the claim.
  9. POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing armed, nothing reported, disarm()null, hooks untouched.
  10. Integration — a hook calling dropTable('defer_widget') on the real sqlite driver during a composed declaration boot: the table is really gone afterwards (forwarded), composition.writeGuard.immediateDdl counts it, the notes name it, and no note claims "a plan writes nothing".

The seven pre-existing #13332 / #14053 pins are unchanged and green — including THE FIX's "Refused 3 write(s) … a plan writes nothing" and the execute() pin, which are the positive controls for the unchanged shapes.

Ablation (on the committed tree, trap-restored): the one line in scan() that calls shadowEngine replaced by a marker — mutation proven on disk (anchor count 1 → 0, marker count 1, blob 2f92252f…d42d9e53…); result: 3 failed / 13 passed — exactly cases 2, 3 and 8 above went red (shadowedEngines came back [], the archive write landed, no Could NOT shadow line), every residue-2 and control case stayed green; restore proven (git checkout HEAD -- PATH: on-disk blob == HEAD blob, git diff HEAD 0 lines, git status --porcelain empty). The subject resolves from source (same-package relative import), so no dist rebuild leg applies to it; the dependency closure was built once before the runs.

Base-source leg (the new tests against the pre-fix source at 8094834060, tree-only git restore --source=BASE, trap-restored): 7 failed / 9 passed — all seven new pins red (residue 1 ×2: shadowedEngines came back undefined, the archive write landed; residue 2 ×2: guard.immediateDdlundefined, the note still printed "a plan writes nothing" over a dropped table; ONE RULE ×2; the embedder control asserting shadowedEngines), the seven pre-existing pins and the two fixture controls green; restore proven the same way (on-disk blob == HEAD blob, git diff HEAD 0 lines, porcelain empty).

Verification

See the report comment on #14126 for the exit-code table (test run, cli typecheck, the 35-family dispatch-gates union, eslint narrowing evidence), each read from the gate's own verdict line with the exit code captured before any pipe.

Out of scope / not done

  • ⛔ No regex classification of SQL; hooks are not neutralised by phase; packages/objectql/** and packages/services/** are untouched (the in-lane shape did not need them).
  • The DDL still EXECUTES on a declaration boot — deliberately: this card makes it reported, not refused.
  • origin/main was not merged into this branch in the worktree (base 8094834060; origin/main = 00ff228fe0, 53 commits ahead, at the time of this PR). That delta touches neither registerDriver, nor this file, its tests or the changeset. It does move .github/workflows/lint.yml (adds check:swallow-census-controls, a self-test of measure-durability-swallow-family.mjs's control corpora, not a scan of this diff), release.yml, and scripts/pm/dispatch-gates.mjs itself — so the local gate derivation is from the base tree. CI on the merge ref and the queue's rebuilt generation are the authority for the joint tree.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…d-and-count immediate DDL
Engine-held drivers: shadow `registerDriver` on the engine instance the kernel
publishes (`objectql` / `data`), arm each driver instance in place as it is
registered and forward the SAME instance; reach already-held drivers through
the engine's public accessors; restore on `disarm()`.
Immediate DDL: `dropTable()` / `rotateShards()` get `execute()`'s treatment —
forwarded, counted per driver/method/object, warned once per driver, named in
the notes.
One outcome-line rule: "a plan writes nothing" prints only when nothing was
forwarded and nothing refused the override.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 22 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 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378packageMentionDocs.

Which tree this was computed on

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

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

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL - #14505

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries
Sep 2, 2026
Merged

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL#14505
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14126

What

os migrate plan / os migrate apply boot host plugins for their declarations behind a write guard (#13332 / PR #14053) whose own census named two paths on which a declaration boot could still write while the notes printed "a plan writes nothing". Both are closed here, under ONE outcome-line rule, inside packages/cli/src/utils/schema-migration-plugins.ts only — packages/objectql/** and packages/services/** are untouched.

Residue 1 — engine-held drivers (the in-lane shape the dispatch ruled)

  • The guard now shadows registerDriver on the engine INSTANCE the kernel publishes (objectql / data — one object, shadowed once, keyed by instance), with the same in-place own-property technique armDriver already uses; disarm() deletes the own property so the instance resolves through its prototype again.
  • Each driver instance is armed BEFORE the SAME instance is forwarded to the engine's own method — never a wrapper in its place, never a second registration under a held name (the engine keeps the first instance and discards the second; identity decides).
  • Drivers the engine already holds when the guard arms are reached through its public accessors: getDefaultDriverName() + getDriverByName(), and registry.getAllObjects() + getDriverForObject() (both scans; idempotent).
  • A refusal on such a driver is reported via engine.NAME; the driver.* scan runs first, so the default keeps the driver.* label it always had.

The engine-instance shadow measurement (the FORK CLAUSE did not fire).ObjectQL.registerDriver is a prototype method (packages/objectql/src/engine.ts); the instance is neither frozen nor sealed — Object.isFrozen(engine) === false, Object.isExtensible(engine) === true, and registerDriver resolving through the prototype are asserted on the real class in the pin. Every runtime caller reaches it by a call-time property lookup on the published instance: DatasourceConnectionService.connect() via this.cfg.engine()ctx.getService('data'); AppPlugin's drivers.register via ctx.getService('objectql'); memory-driver.ts via ctx.engine.ql.registerDriver(this). No bound or copied registerDriver exists in runtime code — the only .registerDriver = in the repo is a runtime test patching the prototype. In the CLI boot, DefaultDatasourcePlugin.init() (which dependencies-orders itself after ObjectQLPlugin.init(), where objectql/data are published) registers the default THROUGH the engine and then republishes it as driver.NAME, and the guard's optionalDependencies orders its init() after that — so when the guard arms, the engine holds exactly the default, already armed under driver.*; no base-stack plugin registers a second driver. Every later registration — host init()s (Phase 1, after the guard), AppPlugin.start()connectAll (Phase 2) — arrives through the shadow.

What remains open, stated in the header census: an engine the kernel never publishes as a service, and a non-default driver registered BEFORE the guard armed that no registered object resolves to at either scan. Neither is reachable from the CLI without an engine change, and neither occurs in this repo's boot.

Residue 2 — immediate DDL (dropTable / rotateShards)

  • DRIVER_IMMEDIATE_DDL_METHODS = ['dropTable', 'rotateShards']: IDataDriver.dropTable() (a REQUIRED contract member, packages/spec/src/contracts/data-driver.ts) and driver-sql's rotateShards(objectDef, nowMs) (packages/drivers/driver-sql/src/sql-driver.ts); both run assertSchemaMutable, not the deferral.
  • Exactly the treatment execute() has: FORWARDED (⛔ not refused — refusing DDL an operator's own hook asked for is a behaviour change beyond this card), counted per driver/method/object (rotateShards takes the object DEFINITION, so its name is read off it), warned once per driver on stderr, named in the notes — and the claim is withheld.

The outcome-line rule, decided once for every remaining path

disarm() appends " — a plan writes nothing" to the refusal line only when rawExecutions.size === 0 && immediateDdl.size === 0 && unguardable.size === 0 && unshadowable.size === 0. Each of those four prints its own line naming what was forwarded (raw execute(), immediate DDL) or what refused the override (a frozen driver member, an engine whose registerDriver could not be shadowed) and says the run does NOT claim to have written nothing. A quiet boot — nothing refused, nothing forwarded, nothing unarmable — still returns null, so the notes of an embedder with no data plane, or of read/log-only hooks, are byte-identical to before.

Tests

packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts (extended; the new block boots the REAL ObjectQL behind stand-ins for ObjectQLPlugin and DefaultDatasourcePlugin) and packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts (one case added, real SqlDriver over a real sqlite file through bootSchemaStack):

  1. Residue 1 — POSITIVE CONTROL (the defect as shipped): without the guard, a hook writing to an object bound to an engine-registered second datasource LANDS.
  2. Residue 1 — THE FIX: the engine is shadowed once (shadowedEngines === ['objectql']), engine.getDriverByName('reporting') is the very instance the host registered, the write is refused and reported via engine.reporting, log-only hooks still ran, the claim HELD, and disarm() restores ObjectQL.prototype.registerDriver and the driver's own methods.
  3. Residue 1 — a driver the engine already held before the guard armed (registered from the datasource plugin, bound to archive_row) is armed through getDriverForObject(); its write is refused via engine.archive.
  4. Residue 1 — the driver.* default path unchanged: writes through driver.recording and through engine.getDriverForObject() to the same instance are refused under ONE label, driver.recording, count 2.
  5. Residue 2 — dropTable() ×2 and rotateShards() ×1 are forwarded (the recording driver ran them, their own return values came back), counted [dropTable on sys_old_table x2, rotateShards on sys_log x1], warned exactly once on stderr for the driver, named in the note, and the claim is withheld while the in-run create() control is still refused.
  6. Residue 2 — a forwarded DDL call with no refusal at all produces a note (before: null, indistinguishable from a quiet boot).
  7. ONE RULE — a frozen driver (Object.freeze, prototype methods still run, the write LANDS) is named in Could NOT guard … and withholds the claim although the other write was refused.
  8. ONE RULE — a frozen engine (registerDriver un-shadowable) is named in Could NOT shadow objectql.registerDriver and withholds the claim.
  9. POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing armed, nothing reported, disarm()null, hooks untouched.
  10. Integration — a hook calling dropTable('defer_widget') on the real sqlite driver during a composed declaration boot: the table is really gone afterwards (forwarded), composition.writeGuard.immediateDdl counts it, the notes name it, and no note claims "a plan writes nothing".

The seven pre-existing #13332 / #14053 pins are unchanged and green — including THE FIX's "Refused 3 write(s) … a plan writes nothing" and the execute() pin, which are the positive controls for the unchanged shapes.

Ablation (on the committed tree, trap-restored): the one line in scan() that calls shadowEngine replaced by a marker — mutation proven on disk (anchor count 1 → 0, marker count 1, blob 2f92252f…d42d9e53…); result: 3 failed / 13 passed — exactly cases 2, 3 and 8 above went red (shadowedEngines came back [], the archive write landed, no Could NOT shadow line), every residue-2 and control case stayed green; restore proven (git checkout HEAD -- PATH: on-disk blob == HEAD blob, git diff HEAD 0 lines, git status --porcelain empty). The subject resolves from source (same-package relative import), so no dist rebuild leg applies to it; the dependency closure was built once before the runs.

Base-source leg (the new tests against the pre-fix source at 8094834060, tree-only git restore --source=BASE, trap-restored): 7 failed / 9 passed — all seven new pins red (residue 1 ×2: shadowedEngines came back undefined, the archive write landed; residue 2 ×2: guard.immediateDdlundefined, the note still printed "a plan writes nothing" over a dropped table; ONE RULE ×2; the embedder control asserting shadowedEngines), the seven pre-existing pins and the two fixture controls green; restore proven the same way (on-disk blob == HEAD blob, git diff HEAD 0 lines, porcelain empty).

Verification

See the report comment on #14126 for the exit-code table (test run, cli typecheck, the 35-family dispatch-gates union, eslint narrowing evidence), each read from the gate's own verdict line with the exit code captured before any pipe.

Out of scope / not done

  • ⛔ No regex classification of SQL; hooks are not neutralised by phase; packages/objectql/** and packages/services/** are untouched (the in-lane shape did not need them).
  • The DDL still EXECUTES on a declaration boot — deliberately: this card makes it reported, not refused.
  • origin/main was not merged into this branch in the worktree (base 8094834060; origin/main = 00ff228fe0, 53 commits ahead, at the time of this PR). That delta touches neither registerDriver, nor this file, its tests or the changeset. It does move .github/workflows/lint.yml (adds check:swallow-census-controls, a self-test of measure-durability-swallow-family.mjs's control corpora, not a scan of this diff), release.yml, and scripts/pm/dispatch-gates.mjs itself — so the local gate derivation is from the base tree. CI on the merge ref and the queue's rebuilt generation are the authority for the joint tree.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…d-and-count immediate DDL
Engine-held drivers: shadow `registerDriver` on the engine instance the kernel
publishes (`objectql` / `data`), arm each driver instance in place as it is
registered and forward the SAME instance; reach already-held drivers through
the engine's public accessors; restore on `disarm()`.
Immediate DDL: `dropTable()` / `rotateShards()` get `execute()`'s treatment —
forwarded, counted per driver/method/object, warned once per driver, named in
the notes.
One outcome-line rule: "a plan writes nothing" prints only when nothing was
forwarded and nothing refused the override.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 22 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 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378packageMentionDocs.

Which tree this was computed on

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

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

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL - #14505

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries
Sep 2, 2026
Merged

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL#14505
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14126

What

os migrate plan / os migrate apply boot host plugins for their declarations behind a write guard (#13332 / PR #14053) whose own census named two paths on which a declaration boot could still write while the notes printed "a plan writes nothing". Both are closed here, under ONE outcome-line rule, inside packages/cli/src/utils/schema-migration-plugins.ts only — packages/objectql/** and packages/services/** are untouched.

Residue 1 — engine-held drivers (the in-lane shape the dispatch ruled)

  • The guard now shadows registerDriver on the engine INSTANCE the kernel publishes (objectql / data — one object, shadowed once, keyed by instance), with the same in-place own-property technique armDriver already uses; disarm() deletes the own property so the instance resolves through its prototype again.
  • Each driver instance is armed BEFORE the SAME instance is forwarded to the engine's own method — never a wrapper in its place, never a second registration under a held name (the engine keeps the first instance and discards the second; identity decides).
  • Drivers the engine already holds when the guard arms are reached through its public accessors: getDefaultDriverName() + getDriverByName(), and registry.getAllObjects() + getDriverForObject() (both scans; idempotent).
  • A refusal on such a driver is reported via engine.NAME; the driver.* scan runs first, so the default keeps the driver.* label it always had.

The engine-instance shadow measurement (the FORK CLAUSE did not fire).ObjectQL.registerDriver is a prototype method (packages/objectql/src/engine.ts); the instance is neither frozen nor sealed — Object.isFrozen(engine) === false, Object.isExtensible(engine) === true, and registerDriver resolving through the prototype are asserted on the real class in the pin. Every runtime caller reaches it by a call-time property lookup on the published instance: DatasourceConnectionService.connect() via this.cfg.engine()ctx.getService('data'); AppPlugin's drivers.register via ctx.getService('objectql'); memory-driver.ts via ctx.engine.ql.registerDriver(this). No bound or copied registerDriver exists in runtime code — the only .registerDriver = in the repo is a runtime test patching the prototype. In the CLI boot, DefaultDatasourcePlugin.init() (which dependencies-orders itself after ObjectQLPlugin.init(), where objectql/data are published) registers the default THROUGH the engine and then republishes it as driver.NAME, and the guard's optionalDependencies orders its init() after that — so when the guard arms, the engine holds exactly the default, already armed under driver.*; no base-stack plugin registers a second driver. Every later registration — host init()s (Phase 1, after the guard), AppPlugin.start()connectAll (Phase 2) — arrives through the shadow.

What remains open, stated in the header census: an engine the kernel never publishes as a service, and a non-default driver registered BEFORE the guard armed that no registered object resolves to at either scan. Neither is reachable from the CLI without an engine change, and neither occurs in this repo's boot.

Residue 2 — immediate DDL (dropTable / rotateShards)

  • DRIVER_IMMEDIATE_DDL_METHODS = ['dropTable', 'rotateShards']: IDataDriver.dropTable() (a REQUIRED contract member, packages/spec/src/contracts/data-driver.ts) and driver-sql's rotateShards(objectDef, nowMs) (packages/drivers/driver-sql/src/sql-driver.ts); both run assertSchemaMutable, not the deferral.
  • Exactly the treatment execute() has: FORWARDED (⛔ not refused — refusing DDL an operator's own hook asked for is a behaviour change beyond this card), counted per driver/method/object (rotateShards takes the object DEFINITION, so its name is read off it), warned once per driver on stderr, named in the notes — and the claim is withheld.

The outcome-line rule, decided once for every remaining path

disarm() appends " — a plan writes nothing" to the refusal line only when rawExecutions.size === 0 && immediateDdl.size === 0 && unguardable.size === 0 && unshadowable.size === 0. Each of those four prints its own line naming what was forwarded (raw execute(), immediate DDL) or what refused the override (a frozen driver member, an engine whose registerDriver could not be shadowed) and says the run does NOT claim to have written nothing. A quiet boot — nothing refused, nothing forwarded, nothing unarmable — still returns null, so the notes of an embedder with no data plane, or of read/log-only hooks, are byte-identical to before.

Tests

packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts (extended; the new block boots the REAL ObjectQL behind stand-ins for ObjectQLPlugin and DefaultDatasourcePlugin) and packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts (one case added, real SqlDriver over a real sqlite file through bootSchemaStack):

  1. Residue 1 — POSITIVE CONTROL (the defect as shipped): without the guard, a hook writing to an object bound to an engine-registered second datasource LANDS.
  2. Residue 1 — THE FIX: the engine is shadowed once (shadowedEngines === ['objectql']), engine.getDriverByName('reporting') is the very instance the host registered, the write is refused and reported via engine.reporting, log-only hooks still ran, the claim HELD, and disarm() restores ObjectQL.prototype.registerDriver and the driver's own methods.
  3. Residue 1 — a driver the engine already held before the guard armed (registered from the datasource plugin, bound to archive_row) is armed through getDriverForObject(); its write is refused via engine.archive.
  4. Residue 1 — the driver.* default path unchanged: writes through driver.recording and through engine.getDriverForObject() to the same instance are refused under ONE label, driver.recording, count 2.
  5. Residue 2 — dropTable() ×2 and rotateShards() ×1 are forwarded (the recording driver ran them, their own return values came back), counted [dropTable on sys_old_table x2, rotateShards on sys_log x1], warned exactly once on stderr for the driver, named in the note, and the claim is withheld while the in-run create() control is still refused.
  6. Residue 2 — a forwarded DDL call with no refusal at all produces a note (before: null, indistinguishable from a quiet boot).
  7. ONE RULE — a frozen driver (Object.freeze, prototype methods still run, the write LANDS) is named in Could NOT guard … and withholds the claim although the other write was refused.
  8. ONE RULE — a frozen engine (registerDriver un-shadowable) is named in Could NOT shadow objectql.registerDriver and withholds the claim.
  9. POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing armed, nothing reported, disarm()null, hooks untouched.
  10. Integration — a hook calling dropTable('defer_widget') on the real sqlite driver during a composed declaration boot: the table is really gone afterwards (forwarded), composition.writeGuard.immediateDdl counts it, the notes name it, and no note claims "a plan writes nothing".

The seven pre-existing #13332 / #14053 pins are unchanged and green — including THE FIX's "Refused 3 write(s) … a plan writes nothing" and the execute() pin, which are the positive controls for the unchanged shapes.

Ablation (on the committed tree, trap-restored): the one line in scan() that calls shadowEngine replaced by a marker — mutation proven on disk (anchor count 1 → 0, marker count 1, blob 2f92252f…d42d9e53…); result: 3 failed / 13 passed — exactly cases 2, 3 and 8 above went red (shadowedEngines came back [], the archive write landed, no Could NOT shadow line), every residue-2 and control case stayed green; restore proven (git checkout HEAD -- PATH: on-disk blob == HEAD blob, git diff HEAD 0 lines, git status --porcelain empty). The subject resolves from source (same-package relative import), so no dist rebuild leg applies to it; the dependency closure was built once before the runs.

Base-source leg (the new tests against the pre-fix source at 8094834060, tree-only git restore --source=BASE, trap-restored): 7 failed / 9 passed — all seven new pins red (residue 1 ×2: shadowedEngines came back undefined, the archive write landed; residue 2 ×2: guard.immediateDdlundefined, the note still printed "a plan writes nothing" over a dropped table; ONE RULE ×2; the embedder control asserting shadowedEngines), the seven pre-existing pins and the two fixture controls green; restore proven the same way (on-disk blob == HEAD blob, git diff HEAD 0 lines, porcelain empty).

Verification

See the report comment on #14126 for the exit-code table (test run, cli typecheck, the 35-family dispatch-gates union, eslint narrowing evidence), each read from the gate's own verdict line with the exit code captured before any pipe.

Out of scope / not done

  • ⛔ No regex classification of SQL; hooks are not neutralised by phase; packages/objectql/** and packages/services/** are untouched (the in-lane shape did not need them).
  • The DDL still EXECUTES on a declaration boot — deliberately: this card makes it reported, not refused.
  • origin/main was not merged into this branch in the worktree (base 8094834060; origin/main = 00ff228fe0, 53 commits ahead, at the time of this PR). That delta touches neither registerDriver, nor this file, its tests or the changeset. It does move .github/workflows/lint.yml (adds check:swallow-census-controls, a self-test of measure-durability-swallow-family.mjs's control corpora, not a scan of this diff), release.yml, and scripts/pm/dispatch-gates.mjs itself — so the local gate derivation is from the base tree. CI on the merge ref and the queue's rebuilt generation are the authority for the joint tree.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…d-and-count immediate DDL
Engine-held drivers: shadow `registerDriver` on the engine instance the kernel
publishes (`objectql` / `data`), arm each driver instance in place as it is
registered and forward the SAME instance; reach already-held drivers through
the engine's public accessors; restore on `disarm()`.
Immediate DDL: `dropTable()` / `rotateShards()` get `execute()`'s treatment —
forwarded, counted per driver/method/object, warned once per driver, named in
the notes.
One outcome-line rule: "a plan writes nothing" prints only when nothing was
forwarded and nothing refused the override.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 22 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 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378packageMentionDocs.

Which tree this was computed on

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

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

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL - #14505

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries
Sep 2, 2026
Merged

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL#14505
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14126

What

os migrate plan / os migrate apply boot host plugins for their declarations behind a write guard (#13332 / PR #14053) whose own census named two paths on which a declaration boot could still write while the notes printed "a plan writes nothing". Both are closed here, under ONE outcome-line rule, inside packages/cli/src/utils/schema-migration-plugins.ts only — packages/objectql/** and packages/services/** are untouched.

Residue 1 — engine-held drivers (the in-lane shape the dispatch ruled)

  • The guard now shadows registerDriver on the engine INSTANCE the kernel publishes (objectql / data — one object, shadowed once, keyed by instance), with the same in-place own-property technique armDriver already uses; disarm() deletes the own property so the instance resolves through its prototype again.
  • Each driver instance is armed BEFORE the SAME instance is forwarded to the engine's own method — never a wrapper in its place, never a second registration under a held name (the engine keeps the first instance and discards the second; identity decides).
  • Drivers the engine already holds when the guard arms are reached through its public accessors: getDefaultDriverName() + getDriverByName(), and registry.getAllObjects() + getDriverForObject() (both scans; idempotent).
  • A refusal on such a driver is reported via engine.NAME; the driver.* scan runs first, so the default keeps the driver.* label it always had.

The engine-instance shadow measurement (the FORK CLAUSE did not fire).ObjectQL.registerDriver is a prototype method (packages/objectql/src/engine.ts); the instance is neither frozen nor sealed — Object.isFrozen(engine) === false, Object.isExtensible(engine) === true, and registerDriver resolving through the prototype are asserted on the real class in the pin. Every runtime caller reaches it by a call-time property lookup on the published instance: DatasourceConnectionService.connect() via this.cfg.engine()ctx.getService('data'); AppPlugin's drivers.register via ctx.getService('objectql'); memory-driver.ts via ctx.engine.ql.registerDriver(this). No bound or copied registerDriver exists in runtime code — the only .registerDriver = in the repo is a runtime test patching the prototype. In the CLI boot, DefaultDatasourcePlugin.init() (which dependencies-orders itself after ObjectQLPlugin.init(), where objectql/data are published) registers the default THROUGH the engine and then republishes it as driver.NAME, and the guard's optionalDependencies orders its init() after that — so when the guard arms, the engine holds exactly the default, already armed under driver.*; no base-stack plugin registers a second driver. Every later registration — host init()s (Phase 1, after the guard), AppPlugin.start()connectAll (Phase 2) — arrives through the shadow.

What remains open, stated in the header census: an engine the kernel never publishes as a service, and a non-default driver registered BEFORE the guard armed that no registered object resolves to at either scan. Neither is reachable from the CLI without an engine change, and neither occurs in this repo's boot.

Residue 2 — immediate DDL (dropTable / rotateShards)

  • DRIVER_IMMEDIATE_DDL_METHODS = ['dropTable', 'rotateShards']: IDataDriver.dropTable() (a REQUIRED contract member, packages/spec/src/contracts/data-driver.ts) and driver-sql's rotateShards(objectDef, nowMs) (packages/drivers/driver-sql/src/sql-driver.ts); both run assertSchemaMutable, not the deferral.
  • Exactly the treatment execute() has: FORWARDED (⛔ not refused — refusing DDL an operator's own hook asked for is a behaviour change beyond this card), counted per driver/method/object (rotateShards takes the object DEFINITION, so its name is read off it), warned once per driver on stderr, named in the notes — and the claim is withheld.

The outcome-line rule, decided once for every remaining path

disarm() appends " — a plan writes nothing" to the refusal line only when rawExecutions.size === 0 && immediateDdl.size === 0 && unguardable.size === 0 && unshadowable.size === 0. Each of those four prints its own line naming what was forwarded (raw execute(), immediate DDL) or what refused the override (a frozen driver member, an engine whose registerDriver could not be shadowed) and says the run does NOT claim to have written nothing. A quiet boot — nothing refused, nothing forwarded, nothing unarmable — still returns null, so the notes of an embedder with no data plane, or of read/log-only hooks, are byte-identical to before.

Tests

packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts (extended; the new block boots the REAL ObjectQL behind stand-ins for ObjectQLPlugin and DefaultDatasourcePlugin) and packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts (one case added, real SqlDriver over a real sqlite file through bootSchemaStack):

  1. Residue 1 — POSITIVE CONTROL (the defect as shipped): without the guard, a hook writing to an object bound to an engine-registered second datasource LANDS.
  2. Residue 1 — THE FIX: the engine is shadowed once (shadowedEngines === ['objectql']), engine.getDriverByName('reporting') is the very instance the host registered, the write is refused and reported via engine.reporting, log-only hooks still ran, the claim HELD, and disarm() restores ObjectQL.prototype.registerDriver and the driver's own methods.
  3. Residue 1 — a driver the engine already held before the guard armed (registered from the datasource plugin, bound to archive_row) is armed through getDriverForObject(); its write is refused via engine.archive.
  4. Residue 1 — the driver.* default path unchanged: writes through driver.recording and through engine.getDriverForObject() to the same instance are refused under ONE label, driver.recording, count 2.
  5. Residue 2 — dropTable() ×2 and rotateShards() ×1 are forwarded (the recording driver ran them, their own return values came back), counted [dropTable on sys_old_table x2, rotateShards on sys_log x1], warned exactly once on stderr for the driver, named in the note, and the claim is withheld while the in-run create() control is still refused.
  6. Residue 2 — a forwarded DDL call with no refusal at all produces a note (before: null, indistinguishable from a quiet boot).
  7. ONE RULE — a frozen driver (Object.freeze, prototype methods still run, the write LANDS) is named in Could NOT guard … and withholds the claim although the other write was refused.
  8. ONE RULE — a frozen engine (registerDriver un-shadowable) is named in Could NOT shadow objectql.registerDriver and withholds the claim.
  9. POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing armed, nothing reported, disarm()null, hooks untouched.
  10. Integration — a hook calling dropTable('defer_widget') on the real sqlite driver during a composed declaration boot: the table is really gone afterwards (forwarded), composition.writeGuard.immediateDdl counts it, the notes name it, and no note claims "a plan writes nothing".

The seven pre-existing #13332 / #14053 pins are unchanged and green — including THE FIX's "Refused 3 write(s) … a plan writes nothing" and the execute() pin, which are the positive controls for the unchanged shapes.

Ablation (on the committed tree, trap-restored): the one line in scan() that calls shadowEngine replaced by a marker — mutation proven on disk (anchor count 1 → 0, marker count 1, blob 2f92252f…d42d9e53…); result: 3 failed / 13 passed — exactly cases 2, 3 and 8 above went red (shadowedEngines came back [], the archive write landed, no Could NOT shadow line), every residue-2 and control case stayed green; restore proven (git checkout HEAD -- PATH: on-disk blob == HEAD blob, git diff HEAD 0 lines, git status --porcelain empty). The subject resolves from source (same-package relative import), so no dist rebuild leg applies to it; the dependency closure was built once before the runs.

Base-source leg (the new tests against the pre-fix source at 8094834060, tree-only git restore --source=BASE, trap-restored): 7 failed / 9 passed — all seven new pins red (residue 1 ×2: shadowedEngines came back undefined, the archive write landed; residue 2 ×2: guard.immediateDdlundefined, the note still printed "a plan writes nothing" over a dropped table; ONE RULE ×2; the embedder control asserting shadowedEngines), the seven pre-existing pins and the two fixture controls green; restore proven the same way (on-disk blob == HEAD blob, git diff HEAD 0 lines, porcelain empty).

Verification

See the report comment on #14126 for the exit-code table (test run, cli typecheck, the 35-family dispatch-gates union, eslint narrowing evidence), each read from the gate's own verdict line with the exit code captured before any pipe.

Out of scope / not done

  • ⛔ No regex classification of SQL; hooks are not neutralised by phase; packages/objectql/** and packages/services/** are untouched (the in-lane shape did not need them).
  • The DDL still EXECUTES on a declaration boot — deliberately: this card makes it reported, not refused.
  • origin/main was not merged into this branch in the worktree (base 8094834060; origin/main = 00ff228fe0, 53 commits ahead, at the time of this PR). That delta touches neither registerDriver, nor this file, its tests or the changeset. It does move .github/workflows/lint.yml (adds check:swallow-census-controls, a self-test of measure-durability-swallow-family.mjs's control corpora, not a scan of this diff), release.yml, and scripts/pm/dispatch-gates.mjs itself — so the local gate derivation is from the base tree. CI on the merge ref and the queue's rebuilt generation are the authority for the joint tree.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…d-and-count immediate DDL
Engine-held drivers: shadow `registerDriver` on the engine instance the kernel
publishes (`objectql` / `data`), arm each driver instance in place as it is
registered and forward the SAME instance; reach already-held drivers through
the engine's public accessors; restore on `disarm()`.
Immediate DDL: `dropTable()` / `rotateShards()` get `execute()`'s treatment —
forwarded, counted per driver/method/object, warned once per driver, named in
the notes.
One outcome-line rule: "a plan writes nothing" prints only when nothing was
forwarded and nothing refused the override.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 22 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 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378packageMentionDocs.

Which tree this was computed on

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

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

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL - #14505

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries
Sep 2, 2026
Merged

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL#14505
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14126

What

os migrate plan / os migrate apply boot host plugins for their declarations behind a write guard (#13332 / PR #14053) whose own census named two paths on which a declaration boot could still write while the notes printed "a plan writes nothing". Both are closed here, under ONE outcome-line rule, inside packages/cli/src/utils/schema-migration-plugins.ts only — packages/objectql/** and packages/services/** are untouched.

Residue 1 — engine-held drivers (the in-lane shape the dispatch ruled)

  • The guard now shadows registerDriver on the engine INSTANCE the kernel publishes (objectql / data — one object, shadowed once, keyed by instance), with the same in-place own-property technique armDriver already uses; disarm() deletes the own property so the instance resolves through its prototype again.
  • Each driver instance is armed BEFORE the SAME instance is forwarded to the engine's own method — never a wrapper in its place, never a second registration under a held name (the engine keeps the first instance and discards the second; identity decides).
  • Drivers the engine already holds when the guard arms are reached through its public accessors: getDefaultDriverName() + getDriverByName(), and registry.getAllObjects() + getDriverForObject() (both scans; idempotent).
  • A refusal on such a driver is reported via engine.NAME; the driver.* scan runs first, so the default keeps the driver.* label it always had.

The engine-instance shadow measurement (the FORK CLAUSE did not fire).ObjectQL.registerDriver is a prototype method (packages/objectql/src/engine.ts); the instance is neither frozen nor sealed — Object.isFrozen(engine) === false, Object.isExtensible(engine) === true, and registerDriver resolving through the prototype are asserted on the real class in the pin. Every runtime caller reaches it by a call-time property lookup on the published instance: DatasourceConnectionService.connect() via this.cfg.engine()ctx.getService('data'); AppPlugin's drivers.register via ctx.getService('objectql'); memory-driver.ts via ctx.engine.ql.registerDriver(this). No bound or copied registerDriver exists in runtime code — the only .registerDriver = in the repo is a runtime test patching the prototype. In the CLI boot, DefaultDatasourcePlugin.init() (which dependencies-orders itself after ObjectQLPlugin.init(), where objectql/data are published) registers the default THROUGH the engine and then republishes it as driver.NAME, and the guard's optionalDependencies orders its init() after that — so when the guard arms, the engine holds exactly the default, already armed under driver.*; no base-stack plugin registers a second driver. Every later registration — host init()s (Phase 1, after the guard), AppPlugin.start()connectAll (Phase 2) — arrives through the shadow.

What remains open, stated in the header census: an engine the kernel never publishes as a service, and a non-default driver registered BEFORE the guard armed that no registered object resolves to at either scan. Neither is reachable from the CLI without an engine change, and neither occurs in this repo's boot.

Residue 2 — immediate DDL (dropTable / rotateShards)

  • DRIVER_IMMEDIATE_DDL_METHODS = ['dropTable', 'rotateShards']: IDataDriver.dropTable() (a REQUIRED contract member, packages/spec/src/contracts/data-driver.ts) and driver-sql's rotateShards(objectDef, nowMs) (packages/drivers/driver-sql/src/sql-driver.ts); both run assertSchemaMutable, not the deferral.
  • Exactly the treatment execute() has: FORWARDED (⛔ not refused — refusing DDL an operator's own hook asked for is a behaviour change beyond this card), counted per driver/method/object (rotateShards takes the object DEFINITION, so its name is read off it), warned once per driver on stderr, named in the notes — and the claim is withheld.

The outcome-line rule, decided once for every remaining path

disarm() appends " — a plan writes nothing" to the refusal line only when rawExecutions.size === 0 && immediateDdl.size === 0 && unguardable.size === 0 && unshadowable.size === 0. Each of those four prints its own line naming what was forwarded (raw execute(), immediate DDL) or what refused the override (a frozen driver member, an engine whose registerDriver could not be shadowed) and says the run does NOT claim to have written nothing. A quiet boot — nothing refused, nothing forwarded, nothing unarmable — still returns null, so the notes of an embedder with no data plane, or of read/log-only hooks, are byte-identical to before.

Tests

packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts (extended; the new block boots the REAL ObjectQL behind stand-ins for ObjectQLPlugin and DefaultDatasourcePlugin) and packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts (one case added, real SqlDriver over a real sqlite file through bootSchemaStack):

  1. Residue 1 — POSITIVE CONTROL (the defect as shipped): without the guard, a hook writing to an object bound to an engine-registered second datasource LANDS.
  2. Residue 1 — THE FIX: the engine is shadowed once (shadowedEngines === ['objectql']), engine.getDriverByName('reporting') is the very instance the host registered, the write is refused and reported via engine.reporting, log-only hooks still ran, the claim HELD, and disarm() restores ObjectQL.prototype.registerDriver and the driver's own methods.
  3. Residue 1 — a driver the engine already held before the guard armed (registered from the datasource plugin, bound to archive_row) is armed through getDriverForObject(); its write is refused via engine.archive.
  4. Residue 1 — the driver.* default path unchanged: writes through driver.recording and through engine.getDriverForObject() to the same instance are refused under ONE label, driver.recording, count 2.
  5. Residue 2 — dropTable() ×2 and rotateShards() ×1 are forwarded (the recording driver ran them, their own return values came back), counted [dropTable on sys_old_table x2, rotateShards on sys_log x1], warned exactly once on stderr for the driver, named in the note, and the claim is withheld while the in-run create() control is still refused.
  6. Residue 2 — a forwarded DDL call with no refusal at all produces a note (before: null, indistinguishable from a quiet boot).
  7. ONE RULE — a frozen driver (Object.freeze, prototype methods still run, the write LANDS) is named in Could NOT guard … and withholds the claim although the other write was refused.
  8. ONE RULE — a frozen engine (registerDriver un-shadowable) is named in Could NOT shadow objectql.registerDriver and withholds the claim.
  9. POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing armed, nothing reported, disarm()null, hooks untouched.
  10. Integration — a hook calling dropTable('defer_widget') on the real sqlite driver during a composed declaration boot: the table is really gone afterwards (forwarded), composition.writeGuard.immediateDdl counts it, the notes name it, and no note claims "a plan writes nothing".

The seven pre-existing #13332 / #14053 pins are unchanged and green — including THE FIX's "Refused 3 write(s) … a plan writes nothing" and the execute() pin, which are the positive controls for the unchanged shapes.

Ablation (on the committed tree, trap-restored): the one line in scan() that calls shadowEngine replaced by a marker — mutation proven on disk (anchor count 1 → 0, marker count 1, blob 2f92252f…d42d9e53…); result: 3 failed / 13 passed — exactly cases 2, 3 and 8 above went red (shadowedEngines came back [], the archive write landed, no Could NOT shadow line), every residue-2 and control case stayed green; restore proven (git checkout HEAD -- PATH: on-disk blob == HEAD blob, git diff HEAD 0 lines, git status --porcelain empty). The subject resolves from source (same-package relative import), so no dist rebuild leg applies to it; the dependency closure was built once before the runs.

Base-source leg (the new tests against the pre-fix source at 8094834060, tree-only git restore --source=BASE, trap-restored): 7 failed / 9 passed — all seven new pins red (residue 1 ×2: shadowedEngines came back undefined, the archive write landed; residue 2 ×2: guard.immediateDdlundefined, the note still printed "a plan writes nothing" over a dropped table; ONE RULE ×2; the embedder control asserting shadowedEngines), the seven pre-existing pins and the two fixture controls green; restore proven the same way (on-disk blob == HEAD blob, git diff HEAD 0 lines, porcelain empty).

Verification

See the report comment on #14126 for the exit-code table (test run, cli typecheck, the 35-family dispatch-gates union, eslint narrowing evidence), each read from the gate's own verdict line with the exit code captured before any pipe.

Out of scope / not done

  • ⛔ No regex classification of SQL; hooks are not neutralised by phase; packages/objectql/** and packages/services/** are untouched (the in-lane shape did not need them).
  • The DDL still EXECUTES on a declaration boot — deliberately: this card makes it reported, not refused.
  • origin/main was not merged into this branch in the worktree (base 8094834060; origin/main = 00ff228fe0, 53 commits ahead, at the time of this PR). That delta touches neither registerDriver, nor this file, its tests or the changeset. It does move .github/workflows/lint.yml (adds check:swallow-census-controls, a self-test of measure-durability-swallow-family.mjs's control corpora, not a scan of this diff), release.yml, and scripts/pm/dispatch-gates.mjs itself — so the local gate derivation is from the base tree. CI on the merge ref and the queue's rebuilt generation are the authority for the joint tree.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…d-and-count immediate DDL
Engine-held drivers: shadow `registerDriver` on the engine instance the kernel
publishes (`objectql` / `data`), arm each driver instance in place as it is
registered and forward the SAME instance; reach already-held drivers through
the engine's public accessors; restore on `disarm()`.
Immediate DDL: `dropTable()` / `rotateShards()` get `execute()`'s treatment —
forwarded, counted per driver/method/object, warned once per driver, named in
the notes.
One outcome-line rule: "a plan writes nothing" prints only when nothing was
forwarded and nothing refused the override.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 22 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 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378packageMentionDocs.

Which tree this was computed on

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

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

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL - #14505

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries
Sep 2, 2026
Merged

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL#14505
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14126

What

os migrate plan / os migrate apply boot host plugins for their declarations behind a write guard (#13332 / PR #14053) whose own census named two paths on which a declaration boot could still write while the notes printed "a plan writes nothing". Both are closed here, under ONE outcome-line rule, inside packages/cli/src/utils/schema-migration-plugins.ts only — packages/objectql/** and packages/services/** are untouched.

Residue 1 — engine-held drivers (the in-lane shape the dispatch ruled)

  • The guard now shadows registerDriver on the engine INSTANCE the kernel publishes (objectql / data — one object, shadowed once, keyed by instance), with the same in-place own-property technique armDriver already uses; disarm() deletes the own property so the instance resolves through its prototype again.
  • Each driver instance is armed BEFORE the SAME instance is forwarded to the engine's own method — never a wrapper in its place, never a second registration under a held name (the engine keeps the first instance and discards the second; identity decides).
  • Drivers the engine already holds when the guard arms are reached through its public accessors: getDefaultDriverName() + getDriverByName(), and registry.getAllObjects() + getDriverForObject() (both scans; idempotent).
  • A refusal on such a driver is reported via engine.NAME; the driver.* scan runs first, so the default keeps the driver.* label it always had.

The engine-instance shadow measurement (the FORK CLAUSE did not fire).ObjectQL.registerDriver is a prototype method (packages/objectql/src/engine.ts); the instance is neither frozen nor sealed — Object.isFrozen(engine) === false, Object.isExtensible(engine) === true, and registerDriver resolving through the prototype are asserted on the real class in the pin. Every runtime caller reaches it by a call-time property lookup on the published instance: DatasourceConnectionService.connect() via this.cfg.engine()ctx.getService('data'); AppPlugin's drivers.register via ctx.getService('objectql'); memory-driver.ts via ctx.engine.ql.registerDriver(this). No bound or copied registerDriver exists in runtime code — the only .registerDriver = in the repo is a runtime test patching the prototype. In the CLI boot, DefaultDatasourcePlugin.init() (which dependencies-orders itself after ObjectQLPlugin.init(), where objectql/data are published) registers the default THROUGH the engine and then republishes it as driver.NAME, and the guard's optionalDependencies orders its init() after that — so when the guard arms, the engine holds exactly the default, already armed under driver.*; no base-stack plugin registers a second driver. Every later registration — host init()s (Phase 1, after the guard), AppPlugin.start()connectAll (Phase 2) — arrives through the shadow.

What remains open, stated in the header census: an engine the kernel never publishes as a service, and a non-default driver registered BEFORE the guard armed that no registered object resolves to at either scan. Neither is reachable from the CLI without an engine change, and neither occurs in this repo's boot.

Residue 2 — immediate DDL (dropTable / rotateShards)

  • DRIVER_IMMEDIATE_DDL_METHODS = ['dropTable', 'rotateShards']: IDataDriver.dropTable() (a REQUIRED contract member, packages/spec/src/contracts/data-driver.ts) and driver-sql's rotateShards(objectDef, nowMs) (packages/drivers/driver-sql/src/sql-driver.ts); both run assertSchemaMutable, not the deferral.
  • Exactly the treatment execute() has: FORWARDED (⛔ not refused — refusing DDL an operator's own hook asked for is a behaviour change beyond this card), counted per driver/method/object (rotateShards takes the object DEFINITION, so its name is read off it), warned once per driver on stderr, named in the notes — and the claim is withheld.

The outcome-line rule, decided once for every remaining path

disarm() appends " — a plan writes nothing" to the refusal line only when rawExecutions.size === 0 && immediateDdl.size === 0 && unguardable.size === 0 && unshadowable.size === 0. Each of those four prints its own line naming what was forwarded (raw execute(), immediate DDL) or what refused the override (a frozen driver member, an engine whose registerDriver could not be shadowed) and says the run does NOT claim to have written nothing. A quiet boot — nothing refused, nothing forwarded, nothing unarmable — still returns null, so the notes of an embedder with no data plane, or of read/log-only hooks, are byte-identical to before.

Tests

packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts (extended; the new block boots the REAL ObjectQL behind stand-ins for ObjectQLPlugin and DefaultDatasourcePlugin) and packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts (one case added, real SqlDriver over a real sqlite file through bootSchemaStack):

  1. Residue 1 — POSITIVE CONTROL (the defect as shipped): without the guard, a hook writing to an object bound to an engine-registered second datasource LANDS.
  2. Residue 1 — THE FIX: the engine is shadowed once (shadowedEngines === ['objectql']), engine.getDriverByName('reporting') is the very instance the host registered, the write is refused and reported via engine.reporting, log-only hooks still ran, the claim HELD, and disarm() restores ObjectQL.prototype.registerDriver and the driver's own methods.
  3. Residue 1 — a driver the engine already held before the guard armed (registered from the datasource plugin, bound to archive_row) is armed through getDriverForObject(); its write is refused via engine.archive.
  4. Residue 1 — the driver.* default path unchanged: writes through driver.recording and through engine.getDriverForObject() to the same instance are refused under ONE label, driver.recording, count 2.
  5. Residue 2 — dropTable() ×2 and rotateShards() ×1 are forwarded (the recording driver ran them, their own return values came back), counted [dropTable on sys_old_table x2, rotateShards on sys_log x1], warned exactly once on stderr for the driver, named in the note, and the claim is withheld while the in-run create() control is still refused.
  6. Residue 2 — a forwarded DDL call with no refusal at all produces a note (before: null, indistinguishable from a quiet boot).
  7. ONE RULE — a frozen driver (Object.freeze, prototype methods still run, the write LANDS) is named in Could NOT guard … and withholds the claim although the other write was refused.
  8. ONE RULE — a frozen engine (registerDriver un-shadowable) is named in Could NOT shadow objectql.registerDriver and withholds the claim.
  9. POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing armed, nothing reported, disarm()null, hooks untouched.
  10. Integration — a hook calling dropTable('defer_widget') on the real sqlite driver during a composed declaration boot: the table is really gone afterwards (forwarded), composition.writeGuard.immediateDdl counts it, the notes name it, and no note claims "a plan writes nothing".

The seven pre-existing #13332 / #14053 pins are unchanged and green — including THE FIX's "Refused 3 write(s) … a plan writes nothing" and the execute() pin, which are the positive controls for the unchanged shapes.

Ablation (on the committed tree, trap-restored): the one line in scan() that calls shadowEngine replaced by a marker — mutation proven on disk (anchor count 1 → 0, marker count 1, blob 2f92252f…d42d9e53…); result: 3 failed / 13 passed — exactly cases 2, 3 and 8 above went red (shadowedEngines came back [], the archive write landed, no Could NOT shadow line), every residue-2 and control case stayed green; restore proven (git checkout HEAD -- PATH: on-disk blob == HEAD blob, git diff HEAD 0 lines, git status --porcelain empty). The subject resolves from source (same-package relative import), so no dist rebuild leg applies to it; the dependency closure was built once before the runs.

Base-source leg (the new tests against the pre-fix source at 8094834060, tree-only git restore --source=BASE, trap-restored): 7 failed / 9 passed — all seven new pins red (residue 1 ×2: shadowedEngines came back undefined, the archive write landed; residue 2 ×2: guard.immediateDdlundefined, the note still printed "a plan writes nothing" over a dropped table; ONE RULE ×2; the embedder control asserting shadowedEngines), the seven pre-existing pins and the two fixture controls green; restore proven the same way (on-disk blob == HEAD blob, git diff HEAD 0 lines, porcelain empty).

Verification

See the report comment on #14126 for the exit-code table (test run, cli typecheck, the 35-family dispatch-gates union, eslint narrowing evidence), each read from the gate's own verdict line with the exit code captured before any pipe.

Out of scope / not done

  • ⛔ No regex classification of SQL; hooks are not neutralised by phase; packages/objectql/** and packages/services/** are untouched (the in-lane shape did not need them).
  • The DDL still EXECUTES on a declaration boot — deliberately: this card makes it reported, not refused.
  • origin/main was not merged into this branch in the worktree (base 8094834060; origin/main = 00ff228fe0, 53 commits ahead, at the time of this PR). That delta touches neither registerDriver, nor this file, its tests or the changeset. It does move .github/workflows/lint.yml (adds check:swallow-census-controls, a self-test of measure-durability-swallow-family.mjs's control corpora, not a scan of this diff), release.yml, and scripts/pm/dispatch-gates.mjs itself — so the local gate derivation is from the base tree. CI on the merge ref and the queue's rebuilt generation are the authority for the joint tree.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…d-and-count immediate DDL
Engine-held drivers: shadow `registerDriver` on the engine instance the kernel
publishes (`objectql` / `data`), arm each driver instance in place as it is
registered and forward the SAME instance; reach already-held drivers through
the engine's public accessors; restore on `disarm()`.
Immediate DDL: `dropTable()` / `rotateShards()` get `execute()`'s treatment —
forwarded, counted per driver/method/object, warned once per driver, named in
the notes.
One outcome-line rule: "a plan writes nothing" prints only when nothing was
forwarded and nothing refused the override.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 22 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 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378packageMentionDocs.

Which tree this was computed on

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

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

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL - #14505

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries
Sep 2, 2026
Merged

fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL#14505
os-trump merged 1 commit into
mainfrom
claude/issue-14126-declaration-boot-guard-boundaries

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14126

What

os migrate plan / os migrate apply boot host plugins for their declarations behind a write guard (#13332 / PR #14053) whose own census named two paths on which a declaration boot could still write while the notes printed "a plan writes nothing". Both are closed here, under ONE outcome-line rule, inside packages/cli/src/utils/schema-migration-plugins.ts only — packages/objectql/** and packages/services/** are untouched.

Residue 1 — engine-held drivers (the in-lane shape the dispatch ruled)

  • The guard now shadows registerDriver on the engine INSTANCE the kernel publishes (objectql / data — one object, shadowed once, keyed by instance), with the same in-place own-property technique armDriver already uses; disarm() deletes the own property so the instance resolves through its prototype again.
  • Each driver instance is armed BEFORE the SAME instance is forwarded to the engine's own method — never a wrapper in its place, never a second registration under a held name (the engine keeps the first instance and discards the second; identity decides).
  • Drivers the engine already holds when the guard arms are reached through its public accessors: getDefaultDriverName() + getDriverByName(), and registry.getAllObjects() + getDriverForObject() (both scans; idempotent).
  • A refusal on such a driver is reported via engine.NAME; the driver.* scan runs first, so the default keeps the driver.* label it always had.

The engine-instance shadow measurement (the FORK CLAUSE did not fire).ObjectQL.registerDriver is a prototype method (packages/objectql/src/engine.ts); the instance is neither frozen nor sealed — Object.isFrozen(engine) === false, Object.isExtensible(engine) === true, and registerDriver resolving through the prototype are asserted on the real class in the pin. Every runtime caller reaches it by a call-time property lookup on the published instance: DatasourceConnectionService.connect() via this.cfg.engine()ctx.getService('data'); AppPlugin's drivers.register via ctx.getService('objectql'); memory-driver.ts via ctx.engine.ql.registerDriver(this). No bound or copied registerDriver exists in runtime code — the only .registerDriver = in the repo is a runtime test patching the prototype. In the CLI boot, DefaultDatasourcePlugin.init() (which dependencies-orders itself after ObjectQLPlugin.init(), where objectql/data are published) registers the default THROUGH the engine and then republishes it as driver.NAME, and the guard's optionalDependencies orders its init() after that — so when the guard arms, the engine holds exactly the default, already armed under driver.*; no base-stack plugin registers a second driver. Every later registration — host init()s (Phase 1, after the guard), AppPlugin.start()connectAll (Phase 2) — arrives through the shadow.

What remains open, stated in the header census: an engine the kernel never publishes as a service, and a non-default driver registered BEFORE the guard armed that no registered object resolves to at either scan. Neither is reachable from the CLI without an engine change, and neither occurs in this repo's boot.

Residue 2 — immediate DDL (dropTable / rotateShards)

  • DRIVER_IMMEDIATE_DDL_METHODS = ['dropTable', 'rotateShards']: IDataDriver.dropTable() (a REQUIRED contract member, packages/spec/src/contracts/data-driver.ts) and driver-sql's rotateShards(objectDef, nowMs) (packages/drivers/driver-sql/src/sql-driver.ts); both run assertSchemaMutable, not the deferral.
  • Exactly the treatment execute() has: FORWARDED (⛔ not refused — refusing DDL an operator's own hook asked for is a behaviour change beyond this card), counted per driver/method/object (rotateShards takes the object DEFINITION, so its name is read off it), warned once per driver on stderr, named in the notes — and the claim is withheld.

The outcome-line rule, decided once for every remaining path

disarm() appends " — a plan writes nothing" to the refusal line only when rawExecutions.size === 0 && immediateDdl.size === 0 && unguardable.size === 0 && unshadowable.size === 0. Each of those four prints its own line naming what was forwarded (raw execute(), immediate DDL) or what refused the override (a frozen driver member, an engine whose registerDriver could not be shadowed) and says the run does NOT claim to have written nothing. A quiet boot — nothing refused, nothing forwarded, nothing unarmable — still returns null, so the notes of an embedder with no data plane, or of read/log-only hooks, are byte-identical to before.

Tests

packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts (extended; the new block boots the REAL ObjectQL behind stand-ins for ObjectQLPlugin and DefaultDatasourcePlugin) and packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts (one case added, real SqlDriver over a real sqlite file through bootSchemaStack):

  1. Residue 1 — POSITIVE CONTROL (the defect as shipped): without the guard, a hook writing to an object bound to an engine-registered second datasource LANDS.
  2. Residue 1 — THE FIX: the engine is shadowed once (shadowedEngines === ['objectql']), engine.getDriverByName('reporting') is the very instance the host registered, the write is refused and reported via engine.reporting, log-only hooks still ran, the claim HELD, and disarm() restores ObjectQL.prototype.registerDriver and the driver's own methods.
  3. Residue 1 — a driver the engine already held before the guard armed (registered from the datasource plugin, bound to archive_row) is armed through getDriverForObject(); its write is refused via engine.archive.
  4. Residue 1 — the driver.* default path unchanged: writes through driver.recording and through engine.getDriverForObject() to the same instance are refused under ONE label, driver.recording, count 2.
  5. Residue 2 — dropTable() ×2 and rotateShards() ×1 are forwarded (the recording driver ran them, their own return values came back), counted [dropTable on sys_old_table x2, rotateShards on sys_log x1], warned exactly once on stderr for the driver, named in the note, and the claim is withheld while the in-run create() control is still refused.
  6. Residue 2 — a forwarded DDL call with no refusal at all produces a note (before: null, indistinguishable from a quiet boot).
  7. ONE RULE — a frozen driver (Object.freeze, prototype methods still run, the write LANDS) is named in Could NOT guard … and withholds the claim although the other write was refused.
  8. ONE RULE — a frozen engine (registerDriver un-shadowable) is named in Could NOT shadow objectql.registerDriver and withholds the claim.
  9. POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing armed, nothing reported, disarm()null, hooks untouched.
  10. Integration — a hook calling dropTable('defer_widget') on the real sqlite driver during a composed declaration boot: the table is really gone afterwards (forwarded), composition.writeGuard.immediateDdl counts it, the notes name it, and no note claims "a plan writes nothing".

The seven pre-existing #13332 / #14053 pins are unchanged and green — including THE FIX's "Refused 3 write(s) … a plan writes nothing" and the execute() pin, which are the positive controls for the unchanged shapes.

Ablation (on the committed tree, trap-restored): the one line in scan() that calls shadowEngine replaced by a marker — mutation proven on disk (anchor count 1 → 0, marker count 1, blob 2f92252f…d42d9e53…); result: 3 failed / 13 passed — exactly cases 2, 3 and 8 above went red (shadowedEngines came back [], the archive write landed, no Could NOT shadow line), every residue-2 and control case stayed green; restore proven (git checkout HEAD -- PATH: on-disk blob == HEAD blob, git diff HEAD 0 lines, git status --porcelain empty). The subject resolves from source (same-package relative import), so no dist rebuild leg applies to it; the dependency closure was built once before the runs.

Base-source leg (the new tests against the pre-fix source at 8094834060, tree-only git restore --source=BASE, trap-restored): 7 failed / 9 passed — all seven new pins red (residue 1 ×2: shadowedEngines came back undefined, the archive write landed; residue 2 ×2: guard.immediateDdlundefined, the note still printed "a plan writes nothing" over a dropped table; ONE RULE ×2; the embedder control asserting shadowedEngines), the seven pre-existing pins and the two fixture controls green; restore proven the same way (on-disk blob == HEAD blob, git diff HEAD 0 lines, porcelain empty).

Verification

See the report comment on #14126 for the exit-code table (test run, cli typecheck, the 35-family dispatch-gates union, eslint narrowing evidence), each read from the gate's own verdict line with the exit code captured before any pipe.

Out of scope / not done

  • ⛔ No regex classification of SQL; hooks are not neutralised by phase; packages/objectql/** and packages/services/** are untouched (the in-lane shape did not need them).
  • The DDL still EXECUTES on a declaration boot — deliberately: this card makes it reported, not refused.
  • origin/main was not merged into this branch in the worktree (base 8094834060; origin/main = 00ff228fe0, 53 commits ahead, at the time of this PR). That delta touches neither registerDriver, nor this file, its tests or the changeset. It does move .github/workflows/lint.yml (adds check:swallow-census-controls, a self-test of measure-durability-swallow-family.mjs's control corpora, not a scan of this diff), release.yml, and scripts/pm/dispatch-gates.mjs itself — so the local gate derivation is from the base tree. CI on the merge ref and the queue's rebuilt generation are the authority for the joint tree.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

…d-and-count immediate DDL
Engine-held drivers: shadow `registerDriver` on the engine instance the kernel
publishes (`objectql` / `data`), arm each driver instance in place as it is
registered and forward the SAME instance; reach already-held drivers through
the engine's public accessors; restore on `disarm()`.
Immediate DDL: `dropTable()` / `rotateShards()` get `execute()`'s treatment —
forwarded, counted per driver/method/object, warned once per driver, named in
the notes.
One outcome-line rule: "a plan writes nothing" prints only when nothing was
forwarded and nothing refused the override.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 22 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 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378packageMentionDocs.

Which tree this was computed on

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@os-trump@claude