Skip to content

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone - #14053

Merged
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression
Sep 1, 2026
Merged

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone#14053
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13332

Design decision (b) — suppress writes at the driver seam for the declaration boot, so read/log hooks still run — as decided by the domain:cli PM seat in the claim comment. It was implementable as stated; the named fallback (report a seam census showing no single driver seam exists) was not taken.

Contract-review response (2026-09-01): the at-tier review's four required items are addressed at 840f058ecb — R1 (the contract's execute() escape hatch is now reported, and no run with a forwarded raw command claims it wrote nothing), R2 (the dropTable/deferral claim corrected — it is a genuinely open boundary, stated), R3 (the engine-held non-default-datasource driver residue named in the census and changeset), R4 (attributions corrected: ObjectQLPlugin.start, IDataDriver). Details inline below and in the review-response comment.

The defect

composeForDeclarations documented the plan path's guarantee in its own words — "(init runs, start does not — a plan writes nothing)" — and implemented it as a Proxy whose only override is start. packages/core/src/kernel.ts then fires three phases unconditionally after the suppressed start pass:

:402 Phase 3 trigger('kernel:ready')
:404 Phase 3.5 trigger('kernel:bootstrapped')
:416 Phase 4 trigger('kernel:listening')

A writing hook registered from init() survives the suppression on all three. The guarantee was therefore a property of plugins that happen to seed from start() — the shape of the one plugin that had been measured — and not a property of the plan path. packages/core/src/kernel.ts is untouched by this PR: the unconditional firing is the condition, not the fix site.

The seam, and why this one

The driver instance, guarded for the length of the kernel bootstrap.

  • Not the driver.* service entry.ObjectQLPlugin.start() walks the kernel's driver.* services (packages/objectql/src/plugin.ts — the discovery loop lives in start, not init) and hands each to the engine, which keys its registry by driver.name and discards a second instance under a name it already holds (packages/objectql/src/engine.ts, registerDriver). A wrapper registered in place of the service would be refused by the engine, and every objectql-mediated write would go straight to the raw driver. The instance is shared and the engine's write path is a call-time property lookup on it, so guarding the object itself covers both the plugin that resolves driver.* directly and the engine that writes through it.
  • Not a list of phase names. This card went from one phase to three before a line was written; a fourth would silently re-open the hole. The guard is phase-agnostic — one choke point, covered on the day a phase ships.
  • Refused members are read off the contract, not off a survey. The row-write surface of IDataDriver (packages/spec/src/contracts/data-driver.ts): create, update, upsert, delete, bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany. Adding a write to that interface is a spec diff every driver has to implement, so the list goes stale loudly rather than silently.
  • The contract's raw-execution escape hatch is REPORTED, not refused.execute() is a required member of IDataDriver (data-driver.ts:108, under the contract's own "Raw Execution (Escape Hatch)" heading) — on every driver, not a driver-sql extension. It is not refused, for a stated reason: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (a SELECT can quote the word INSERT in a literal; a CTE can write), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). What must never happen is the silent half: a boot-window execute() is forwarded, counted per driver, warned once per driver on stderr, and named in the composition notes — and on such a run no note claims the plan wrote nothing. The "a plan writes nothing" sentence is an outcome, printed by disarm() only on runs where it held.
  • A refusal does not throw.context.trigger() dispatches boot hooks propagating (packages/core/src/hook-dispatch.ts), so a throwing refusal would abort the bootstrap — turning "your plugin wrote during a dry run" into "you cannot get a plan at all", on the command whose whole job is to be read before a production apply. A refused call returns a contract-shaped value (create/upsert echo the caller's own payload rather than inventing an id; delete returns the contract's not-found false; updateMany/deleteMany return 0), and the run says so out loud: one warning on stderr per driver/method/object triple, plus a line in the composition notes the plan prints and --json carries.

The guard is armed by a plugin composed first in buildSchemaMigrationPlugins, so its init() is ordered ahead of every host plugin's (resolvePluginOrder is a DFS in registration order), and it re-scans in Phase 2 to pick up a driver registered by a later init(). bootSchemaStack disarms it the moment the bootstrap returns — everything after that is work the command was asked for, so os migrate apply's confirmed DDL flush and the coverage pass are untouched.

Seam census — what is NOT covered, stated rather than hidden

Every one of these is named in the module header too, so a future reader can tell a deliberate boundary from an oversight:

  • execute() — reported, never silent (see above): forwarded during the boot window, counted per driver, warned on stderr, named in the notes, and the "writes nothing" claim dropped for that run. getKnex() (a driver-sql extension, genuinely off-contract) is not intercepted; it was not the path any measured instance of this defect took.
  • DDL — and its members split.deferSchemaDdl holds back the initObjects/syncSchema path, which apply flushes on purpose once the operator confirms — guarding that would refuse the one write these commands exist to make. dropTable (and driver-sql's rotateShards) are NOT held back by that deferral: they run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately, so a hook calling driver.dropTable(...) on a managed datasource during a declaration boot executes, today as before this guard. A genuinely open boundary, stated.
  • Engine-held drivers for non-default datasources. The guard's scan covers the driver.* services the kernel publishes, and the only such registration repo-wide is the default datasource's (packages/runtime/src/default-datasource-plugin.ts). DatasourceConnectionService.connect() hands every other datasource's driver straight to engine.registerDriver, never through driver.* — so a host stack that connects a second datasource during a composed boot holds an engine-side driver this guard cannot see, and objectql-mediated writes to objects bound to it would land. Likely coverable by also arming instances at the engine.registerDriver seam while the guard is armed; proposed as a follow-up rather than widened into this PR — scope growth on a p1 is the PM's call.
  • writes a plugin makes outside the database entirely (filesystem, network).
  • work a hook defers past the end of the bootstrap; the guard covers the boot window.

Verification

The decisive test, with its positive controlpackages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts, booting a real ObjectKernel with a recording driver whose write methods live on the prototype, exactly like a real driver's:

  • positive control: the fixture plugin, composed the way a served boot composes it, writes on all three phases (create:sys_permission_set from start(), then create:sys_ai_model on ready, bootstrapped and listening);
  • the defect: composeForDeclarations alone removes only the start() seed — all three init()-registered hooks still write. Pinned, not described;
  • the fix: zero writes reach the driver, and the log-only hooks still ran — the recorded order is log-only:kernel:ready, write:kernel:ready, …, i.e. every handler executed and only the write was refused;
  • the escape hatch (R1): a guarded boot in which one hook issues both a contract create() (refused — the in-run control) and a raw execute("INSERT …"): the raw command is forwarded (the real driver ran it and its return value came back), counted (rawExecutions = 1 via that driver), and reported — and the disarm note drops the flat claim: expect(note).not.toContain('a plan writes nothing'). The refusal-only case pins the converse: with no raw command forwarded, the claim held and is printed;
  • plus: the whole row-write contract is refused (not just create()), a refused call hands back a contract-shaped value instead of throwing, and disarm() removes the shadowing own properties (the execute forwarder included) rather than overwriting them.

End to end against a real SQL driverpackages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, on a sqlite database whose tables already exist:

  • positive control: the same plugin on a boot with no host composition lands 3 rows;
  • the fix: a boot through bootSchemaStack with the writer coming from objectstack.config.tsand one handed straight to the kernel adds 0 rows (measured as a delta against the control's 3), every log-only hook ran, and composition.notes carries Refused 6 write(s) during the declaration boot — a plan writes nothing (no raw command that run — the claim held);
  • the review's own control shape (R1): in one guarded boot, create() refused (0 rows) while execute("INSERT …")landed 1 row — forwarded on purpose, and no longer silent: the notes carry Refused 4 write(s) during the declaration boot: (the colon marks the dropped claim), Raw execute() was called 1 time(s) during the declaration boot, and no note in the run claims the plan wrote nothing.

Ablations — direction stated in writing before each run.

Original guard ablation (measured at 6a8a182b77): drop the guard from the composed plugin list ⇒ predicted RED on the integration fix case and the two composition-ordering assertions, guard unit file GREEN; observed exactly that (expected 9 to be 3 — six rows the operator never asked for). Mutate/restore/measure in one process under trap … EXIT INT TERM, blob-hash-verified.

R1 reporting ablation (measured at 840f058ecb): remove the execute arm (DRIVER_RAW_EXECUTION_METHODS.slice(0, 0); anchor occurs exactly once) ⇒ predicted RED on the unit escape-hatch case, the disarm restore case (it asserts the execute shadow exists), and the integration R1 case (the unqualified claim returns); predicted GREEN elsewhere, the raw row still landing. Observed exactly that:

worktree before: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
occurrences BEFORE — original: 1, mutant: 0
worktree after : 11c2699deedbe358087e99e3dc91a95d8306e639
occurrences AFTER — original: 0, mutant: 1
Tests 3 failed | 14 passed (17) ← exactly the three predicted cases
worktree restored: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
git diff HEAD: empty · git status --porcelain: empty

Gates — union derived on the merged tree at 840f058ecb with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, reconciled with comm -23/comm -13 in both directions, exact string comparison: named 35, ran 35, unreconciled 0. Plus pnpm lint (repo-wide, eslint . --no-inline-config, exit 0 — no narrowing) and pnpm check:nul-bytes (exit 0), neither of which the derivation names.

34 of the 35 are green. node scripts/check-test-completeness.mjs is NOT MEASURED (exit 3, PREREQUISITE NOT MET) — it grades a saved turbo run test log, does not run tests, and its own text instructs a local family run to record it as NOT MEASURED. Three others first reported PREREQUISITE NOT MET for missing build output and are green after building the closures they named: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt (exit codes captured before any pipe, per gate log).

Tests at 840f058ecb: the guard unit + composition files 36/36; the #13332 integration block 3/3; the whole @objectstack/cli package 224 files / 2551 passed + 15 skipped, with two unrelated files (test/init-created-files-summary.e2e.test.ts, src/commands/datasource/envelope-unwrap.test.ts) timing out their beforeAll hooks under load during the full parallel run and passing 15/15 when re-run in isolation; pnpm --filter @objectstack/cli typecheck exit 0.

Contract review requested

Changing what a declaration boot permits is likely a contract change, and this PR does not decide that. The at-tier review (2026-09-01) ruled clause ② no on both limbs — conformance, not a contract change; no measured public-surface widening — and its four required items are addressed above.

What changed, and for whom. For os migrate plan and os migrate apply: during the kernel bootstrap, a row write issued through any driver.* instance the kernel publishes is now refused and reported instead of executed, and a raw execute() call through such an instance is forwarded but reported — with the run's notes declining to claim the plan wrote nothing. A host whose plugins wrote during that window will see contract writes stop landing and raw commands named; a host that did neither sees no change whatsoever: no disarm note is emitted when nothing was refused and no raw command went through, and the artifact-less, config-less run is untouched (that path returns before the guard is composed). Nothing outside these two commands is affected — os serve, os dev and os start do not boot through bootSchemaStack.

The changeset is minor on @objectstack/cli.

Generated by Claude Code

@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/automation/webhooks.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/kernel/http-protocol.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/knowledge.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

  • content/docs/releases/implementation-status.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v16.mdx(via updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v17.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

What this run could not see
  • 6 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 — 23 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 c75962ac3e0af69ae20f658618c4d3ab58192cc8packageMentionDocs.

Which tree this was computed on

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

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

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

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

REQUEST CHANGES

At-tier contract review under CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs on origin/main @ dda969cd71: export const CONTRACT_REVIEW_TIER = 'claude-fable-5' — this reviewer is that tier). Everything below was re-derived or re-measured on a detached worktree at head 6a8a182b77, not taken from the PR's text.

The ruling this PR routed here (both limbs, judged separately)

1. Accept/reject change — NO, this is conformance, and the PM's Clause-②: no stands. The guarantee was already shipped in three registers before this PR: the module's own printed sentence ("a plan writes nothing"), the operator docs (content/docs/deployment/cli.mdx:671 — "os migrate plan | Warns and continues — a plan writes nothing either way"), and plan.ts's own comments (:53, :141). Nothing is newly accepted or rejected at any input boundary — every host config and plugin that composed before still composes; what changed is that an effect documented not to happen now does not happen. On the "nobody could have depended on it" tension: the evidence runs the other way and it is measured, not asserted — the one known downstream host that wrote during this window (cloud, ensure-default-ai-model-plugin) treated those writes as a defect in itself (cloud#1744) and moved them out (cloud PR #1749). The affected population's own revealed dependence was on the documented guarantee, not the accidental behaviour. A host depending on writes landing during a plan/apply boot was depending on a documented untruth. No gate breach by the PM (the opus dispatch was in-band); routing the question here was still correct, because the delta is real and downstream.

2. Public-surface widening — NO, measured with a control. Built @objectstack/cli and its dependency closure twice (pnpm exec turbo run build --filter='@objectstack/cli...') at head 6a8a182b77 and base ada3834add. The package's exports map exposes exactly two type surfaces: .dist/index.d.ts and ./consoledist/utils/console.d.ts. Both are byte-identical across the two builds (cmp). The only PR-attributable .d.ts delta is dist/utils/schema-migration-plugins.d.ts (~100 changed lines: createDeclarationBootWriteGuard, DeclarationBootWriteGuard, RefusedDeclarationWrite, SchemaMigrationComposition.writeGuard?), which is not reachable through the exports map. Positive control: 5 .js files differ, including schema-migrate.js and schema-migration-plugins.js — the instrument sees this PR's change, so the entry-point zero is a real zero. (The extract-hook-body/lower-callables/lint dist diffs are from main commits the head merge pulled in — git diff ada3834a...6a8a182b -- packages/cli/src/utils/extract-hook-body.ts is empty.)

So on the contract question: clause ② does not apply — the guarantee was already the contract. The REQUEST CHANGES is not about the ruling; it is about claims the PR rests on that did not survive verification.

Verified TRUE (independently)

  • Seam claim holds.packages/objectql/src/engine.tsregisterDriver (~:5172): if (existing !== driver) → warn and discard the supplied instance — a service-level wrapper handed to the engine under a held name would indeed be dropped, and the engine already holds the raw instance from DatasourceConnectionService.connect() (packages/services/service-datasource/src/datasource-connection-service.ts:618). The engine's write path is a call-time property lookup on that shared instance (this.getDriver(object) then await driver.create(...), engine.ts :4536/:6659), so the own-property shadow intercepts objectql-mediated writes. Wrapping the instance is the only placement that intercepts. One misattribution: the driver.* discovery loop is in ObjectQLPlugin.start (packages/objectql/src/plugin.ts:469, loop at :628), not init() as the PR body and the new docblocks say — the engine's own docblock has it right. Substance unaffected; text should be corrected.
  • Non-throwing refusal premise holds.packages/core/src/hook-dispatch.ts header: boot path (kernel:ready/kernel:bootstrapped/kernel:listening) ⇒ dispatchHookPropagating — a throwing handler aborts the bootstrap, so a throwing refusal would turn "your plugin wrote during a dry run" into "no plan at all". A create() refusal echoing the caller's payload is success-shaped to the calling plugin — but the party the guarantee protects is the operator, who is told twice (stderr + notes), reads pass through honestly (a write-then-read sees no phantom row), and every alternative return either fabricates an identity or aborts the boot. Right call.
  • Scope claim holds.composeHostStack: true appears in exactly two non-test call sites: migrate/plan.ts:154 and migrate/apply.ts:181; the guard is composed only under that flag, and buildSchemaMigrationPlugins returns NOTHING_COMPOSED before createDeclarationBootWriteGuard() on the artifact-less config-less path. No bootSchemaStack caller is serve/dev/start. All three phases fire inside kernel.bootstrap() (:331, phases at :402/:414/:424), so the disarm placed after await runtime.start() covers the whole window and comes off before apply's flush.
  • The measurements are real. Re-ran the guard unit file + composition file at head: 35/35 pass. Re-ran the new integration block (-t '13332'): positive control (3 rows) and fix case (delta 0, log-only hooks ran, note carries Refused 6 write(s)) both pass.

What did not survive verification — the required changes

R1. The refusal-surface claim is false as stated, and I measured the hole. The PR: "Refused members are read off the contract, not off a survey… a driver's raw escape hatches — driver-sql's execute() and getKnex(). Off-contract for a plugin." That is wrong twice. execute(command, parameters?, options?) is a required member of the contract itselfpackages/spec/src/contracts/data-driver.ts:108, under the contract's own heading "Raw Execution (Escape Hatch)" — on every driver, not a driver-sql extension. (Also: the interface is IDataDriver; no IDataSourceDriver symbol exists in packages/spec.) Measured, with an in-run control, against the built head via bootSchemaStack (composeHostStack: true, tables pre-materialized, one host plugin whose kernel:ready hook issues both calls):

RESULT create-probe rows (guarded surface, control): 0 ← refused, reported
RESULT exec-probe rows (raw execute() INSERT): 1 ← LANDED
notes: "…row writes refused at the driver for the whole boot — a plan writes nothing."
"Refused 1 write(s) during the declaration boot … create() on sys_metadata."

The same armed guard, the same driver instance, the same hook: create() refused and reported; execute("INSERT INTO sys_metadata …") landed a row in the plan's target database silently, while the run printed "a plan writes nothing" and a refusal list that looks complete. That is precisely the enumerated-subset defect class this repo has on record: the guard reports green for the member it excluded. Required: during the boot window, an execute() call must not land silently — either refuse it (contract return is Promise<unknown>; if refusal is judged too risky because execute also serves reads and SQL cannot be classified reliably, say that reason) or forward it and report it in the same notes/stderr channel so the printed sentence stays true or is qualified. And the census text (module header, changeset) must stop calling execute off-contract.

R2. "DDL — already held back by deferSchemaDdl" is false for dropTable.setDeferredDdl (sql-driver.ts :10153) sets a flag that only the initObjects/syncSchema path checks (:9393/:9408). dropTable (:8726) runs assertSchemaMutable — which gates schemaMode/dialect (:5162), not deferral — then knex.schema.dropTableIfExists immediately; rotateShards is the same shape. A hook calling driver.dropTable(...) on a managed datasource during a plan boot executes, today and after this PR. The census line (module header + changeset) claims a mechanism that does not exist for those members; correct it, and either cover them or state them as genuinely open.

R3. One unstated residue worth naming in the census: the guard's scan covers "every driver.* instance the kernel publishes", but the engine also receives drivers that are never published as driver.*DatasourceConnectionService.connect() hands them to engine.registerDriver directly (:618), and the only driver.* registration repo-wide is the default driver (packages/runtime/src/default-datasource-plugin.ts:183). A host stack that connects a second datasource during a composed boot holds an engine-side driver the guard cannot see; objectql-mediated writes to objects bound to it would land. Same class as the stated boundaries — but the census's job, by its own words, is to make a deliberate boundary distinguishable from an oversight, so it belongs on the list.

R4. Text accuracy in the same pass:ObjectQLPlugin.start, not init() (docblocks in schema-migration-plugins.ts + changeset); IDataDriver, not IDataSourceDriver.

Changeset grade

minor on @objectstack/cli@17.2.0 is right given the ruling above: a host-visible behavioural narrowing that conforms to documented behaviour is not major, and patch would under-signal a delta a host can observe. The body's "What changed / Who this affects" is the correct shape and names the delta plainly — but it repeats the R1/R2 misstatements ("raw escape hatches (driver-sql's execute() …)", "DDL (already held back by deferSchemaDdl …)") and must be corrected with them.

Summary

Design (b) is the right design, the seam is the right seam and verifiably the only one that intercepts, the tests are real and their controls are the right controls, and the contract question resolves to conformance on both limbs — no accept/reject change, no measured surface widening. What must change before this leaves draft: R1 (cover or report execute(), and correct the "off-contract" claim), R2 (correct the dropTable/deferral claim, cover or state it), R3 (name the engine-held non-default-driver residue), R4 (attribution fixes), with the changeset corrected alongside. The operator-facing sentence this PR exists to make true — "a plan writes nothing" — must not print over a run in which a contract-member write landed silently; that is the same defect this PR was filed to close, one member over.


Generated by Claude Code

…execute() escape hatch, correct the census
R1: execute() is a REQUIRED member of IDataDriver (packages/spec/src/
contracts/data-driver.ts:108, 'Raw Execution (Escape Hatch)'), not a
driver-sql extension, and the guard did not touch it: in one guarded boot
create() was refused and reported while execute("INSERT ...") landed a
row silently under a printed 'a plan writes nothing'. Chosen: REPORT, not
cover — a raw command is unknown by contract ('SQL string, shell command,
or API payload') and SQL text cannot be classified read-vs-write reliably
(a SELECT can quote INSERT in a literal; a CTE can write), while the
framework's own boot work (ensureOverlayIndex's index DDL) runs through
this seam, so a refusal would either break legitimate calls or rest on a
guess. A boot-window execute() is now forwarded, counted per driver,
warned once per driver on stderr, and named in the composition notes —
and no note claims the plan wrote nothing on such a run: the flat claim
is an OUTCOME, printed by disarm() only when it held.
R2: 'DDL already held back by deferSchemaDdl' was false for dropTable /
rotateShards — they run assertSchemaMutable (a schemaMode/dialect gate,
not a deferral check) and execute immediately. The census now states the
split and names them a genuinely open boundary.
R3: drivers the engine holds for a non-default datasource are never
published as driver.* (the only registration is
default-datasource-plugin.ts) and are invisible to the guard's scan —
now stated in the census, module header and changeset.
R4: ObjectQLPlugin.start (plugin.ts:469/:628), not init(), walks the
driver.* services; the interface is IDataDriver, not IDataSourceDriver
(no such symbol exists in spec). Corrected in docblocks, tests and the
changeset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review response — R1–R4 addressed at 840f058ecb (session session_01UngCYXF98BVpYA9hfz6NYk, same branch). Both central facts were re-derived before acting, per the review's own instruction; nothing settled was re-opened.

Re-derivations (before any edit):

  • execute(command: unknown, parameters?, options?): Promise<unknown> confirmed a required member at packages/spec/src/contracts/data-driver.ts:108, under "Raw Execution (Escape Hatch)"; grep -rn IDataSourceDriver packages/spec/src/ exits 1 — no such symbol.
  • Reproduced the hole at 6a8a182b77 with the review's control shape (guarded bootSchemaStack, composeHostStack: true, tables pre-materialized, one hook issuing both calls): create() refused, 0 rows, reported — execute("INSERT INTO sys_metadata …")landed 1 row silently while the notes printed "a plan writes nothing".

R1 — chose REPORT, not cover, for the reason the review anticipated: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (no regex guessing), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). A boot-window execute() is now forwarded, counted per driver (rawExecutions on the guard, --json-visible), warned once per driver on stderr, and named in the composition notes — and the "a plan writes nothing" sentence is now an outcome owned by disarm(): printed when it held, dropped when a raw command went through. The compose-time note no longer pre-claims it. Proof, same control shape at head: create() 0 rows + execute 1 row as before, but notes now read Refused 4 write(s) during the declaration boot: (colon = dropped claim) + Raw execute() was called 1 time(s) during the declaration boot (1 via driver.com.objectstack.driver.sql) and FORWARDED, not refused: … — pinned in both the unit file and a new integration case. Mutation proof (direction written before the run): ablating the execute arm went RED on exactly the 3 predicted cases (14 GREEN), blob-hash mutate/restore verified, trap … EXIT INT TERM, one process.

R2 — the census and changeset no longer claim deferSchemaDdl holds DDL back wholesale. Stated now: the deferral covers the initObjects/syncSchema path (flushed on purpose by apply); dropTable/rotateShards run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately during a declaration boot. Named a genuinely open boundary in the module header, PR body and changeset; not covered here.

R3 — the engine-held non-default-datasource residue is named in the module header census, PR body and changeset: only the default datasource is ever published as driver.* (default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other datasources' drivers straight to engine.registerDriver, invisible to the scan. Likely coverable by arming instances at the registerDriver seam while the guard is armed — proposed as a follow-up, not widened into this PR; scope call is the PM's.

R4 —ObjectQLPlugin.start (loop at plugin.ts:628), not init(); IDataDriver, not IDataSourceDriver — corrected in the docblocks, the unit-test fixture comment, the PR body and the changeset. No occurrence remains in packages/cli/src or .changeset/.

Gates at 840f058ecb (branch merged with current origin/main first — the derivation flagged its gate-family files stale): union via dispatch-gates.mjs --commands, reconciled with comm both directions, exact strings: named 35, ran 35, unreconciled 0. 34 green; check-test-completeness NOT MEASURED (exit 3, PREREQUISITE NOT MET, per its own text). check:dual-build-cjs-loads / check:i18n-coverage / check:type-check-debt green after building the closures they named. Plus unnamed: pnpm lint exit 0, pnpm check:nul-bytes exit 0. CLI package: 224 files, 2551 passed + 15 skipped (two unrelated files hit beforeAll timeouts under full-suite load and pass 15/15 in isolation); typecheck exit 0.

PR remains draft; labels untouched — needs:contract-review stays until you clear it.

Generated by Claude Code


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

APPROVE

Follow-up at-tier contract review under CONTRACT_REVIEW_TIER (claude-fable-5), re-verifying R1–R4 at the new head 840f058ecb. Nothing below is carried forward from the 6a8a182b77 readings — the tree moved (merge of origin/main), so every fact was re-derived on a fresh detached worktree, with the same probe re-run against a fresh build of the head (dist currency confirmed by content grep before trusting it: the new guard strings are present in dist/utils/schema-migration-plugins.js).

1. Report over cover — the right call, on premises that held

All three stated premises re-derived, plus one the fix did not claim:

  • execute(command: unknown, …) is the contract's own opaque member — "SQL string, shell command, or API payload" (packages/spec/src/contracts/data-driver.ts, "Raw Execution (Escape Hatch)"). There is no sound classifier over that input; any bounded cover either refuses allexecute() (breaking legitimate raw reads) or classifies SQL text (a guess, and unreliable in both directions).
  • The framework-traffic premise is real: packages/metadata-protocol/src/protocol.ts:5210 (ensureOverlayIndex) issues its index DDL via (driver as any).execute(sql) — a wholesale refusal would break the framework's own seam users or force an unprincipled caller allowlist.
  • The premise the commit didn't state but which seals it: execute() has no contract-shaped refusal value. Its return is unknown; a fabricated return silently corrupts raw-read callers — strictly worse than the row-write case, where honest no-op shapes exist. So a non-throwing refusal is not even constructible here.

I asked for cover-or-report in R1 and offered report as an acceptable shape; report is not just acceptable — for this member it is the only shape that neither guesses nor breaks. No bounded cover exists without one of those two defects.

2. The disarm()-owned outcome — closes it, does not relocate it (with one stated residue)

Measured behaviourally, both directions, at the new head:

  • Raw traffic present (my original probe, re-run against the built head): create() refused (0 rows, in-run control), execute("INSERT …") forwarded (1 row lands — now the documented behaviour), stderr carries the per-driver warning, and the notes read Refused 1 write(s) during the declaration boot: create() on sys_metadata … — flat claim dropped — followed by Raw execute() was called 1 time(s) … whether this boot wrote is NOT verified, and this run does NOT claim to have written nothing. No note in the run contains "a plan writes nothing". The compose-time note now states mechanism, not outcome ("with the contract's row writes refused at the driver for the whole boot") — the pre-claim is gone.
  • Raw traffic absent: the integration fix case pins Refused 6 write(s) during the declaration boot — a plan writes nothing (3/3 pass), and the unit disarm case pins the claim printed only when it held (36/36 pass).

So the sentence is no longer an assertion printed over an unverified run; it is a measurement over everything the guard can observe. The honest residue, for the record: the claim can still print on a run where a write escaped through a census-stated open boundary — dropTable/rotateShards (verified again: assertSchemaMutable gates schemaMode/dialect, not deferral), a getKnex() handle, or an engine-held second-datasource driver. For getKnex() handles and second drivers that is irreducible without the follow-up seam. For dropTable/rotateShards it is not: those members are unambiguous writes with no classification problem, and the same forward-and-count treatment execute() got would extend the outcome's honesty to them cheaply. Non-blocking — R2 offered "state as open" and that offer stands — but it belongs on the follow-up's scope.

3. R3 residue — stated accurately, and deferral is acceptable

The census/changeset wording matches what I measured, item for item: only the default datasource is ever published as driver.* (re-grepped repo-wide at 840f058ecb: the single registration is packages/runtime/src/default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other drivers straight to engine.registerDriver (now datasource-connection-service.ts:635 after the main merge — same mechanism); consequence stated correctly ("objectql-mediated writes to objects bound to it would land"). The proposed fix seam (arm at engine.registerDriver while the guard is armed) is the right one. Leaving it unimplemented is acceptable for merge: the shape is an edge beyond every measured instance of this defect, and it is stated where a reader of the mechanism will look. It should exist as a tracked issue before this merges, not only as a sentence in a PR body — filing it is the PM's, and the dropTable/rotateShards counting above should ride the same card.

4. R4 zero — re-derived, holds, with controls

  • grep -rn "IDataSourceDriver" packages/cli/src .changeset/ → exit 1 (zero). Repo-wide over all packages/**/*.ts, exit code taken from grep itself (not a pipe tail): 0 matches, exit 1. Positive control: IDataDriver present (4× in the guard module, 2× in the changeset) — the zero is not a tool failure.
  • grep -rn "ObjectQLPlugin\.init\|ObjectQLPlugin's init" packages/cli/src .changeset/ → exit 1 (zero). Control: ObjectQLPlugin.start present 3× in the module, and I re-confirmed the fact itself at the new head (packages/objectql/src/plugin.tsstart = async at :469, discovery loop at :628).

Also re-verified rather than assumed after the merge: the seam facts stand (engine registerDriver discard branch, engine write path as call-time lookup); disarm() restores the execute forwarder too (own descriptor present while armed, gone after, prototype identity preserved — pinned at test :405/:415); and the public-surface zero still holds structurally — the PR's 6-file surface touches neither packages/cli/package.json nor src/index.ts/utils/console.ts (git diff origin/main...840f058ecb on those paths: empty), so the new ForwardedRawExecution/rawExecutions symbols live in the same module the exports map does not reach.

Ruling

The clause-② ruling from the first review stands unchanged at this head: conformance, not a contract accept/reject change; no public-surface widening. All four required items are addressed and verified. needs:contract-review clears — my explicit word, for both carriers (label removal is the PM's write, not mine). The PR stays draft per the PM's parking; this approval is contract-review clearance, not a merge instruction. One expectation attached, non-blocking: the R3 follow-up (engine-seam arming, plus dropTable/rotateShards counting) becomes a tracked issue before merge.


Generated by Claude Code

Merged via the queue into main with commit bf3bbf1Sep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-13332-declaration-boot-write-suppression branch September 1, 2026 06:47
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-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone - #14053

Merged
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression
Sep 1, 2026
Merged

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone#14053
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13332

Design decision (b) — suppress writes at the driver seam for the declaration boot, so read/log hooks still run — as decided by the domain:cli PM seat in the claim comment. It was implementable as stated; the named fallback (report a seam census showing no single driver seam exists) was not taken.

Contract-review response (2026-09-01): the at-tier review's four required items are addressed at 840f058ecb — R1 (the contract's execute() escape hatch is now reported, and no run with a forwarded raw command claims it wrote nothing), R2 (the dropTable/deferral claim corrected — it is a genuinely open boundary, stated), R3 (the engine-held non-default-datasource driver residue named in the census and changeset), R4 (attributions corrected: ObjectQLPlugin.start, IDataDriver). Details inline below and in the review-response comment.

The defect

composeForDeclarations documented the plan path's guarantee in its own words — "(init runs, start does not — a plan writes nothing)" — and implemented it as a Proxy whose only override is start. packages/core/src/kernel.ts then fires three phases unconditionally after the suppressed start pass:

:402 Phase 3 trigger('kernel:ready')
:404 Phase 3.5 trigger('kernel:bootstrapped')
:416 Phase 4 trigger('kernel:listening')

A writing hook registered from init() survives the suppression on all three. The guarantee was therefore a property of plugins that happen to seed from start() — the shape of the one plugin that had been measured — and not a property of the plan path. packages/core/src/kernel.ts is untouched by this PR: the unconditional firing is the condition, not the fix site.

The seam, and why this one

The driver instance, guarded for the length of the kernel bootstrap.

  • Not the driver.* service entry.ObjectQLPlugin.start() walks the kernel's driver.* services (packages/objectql/src/plugin.ts — the discovery loop lives in start, not init) and hands each to the engine, which keys its registry by driver.name and discards a second instance under a name it already holds (packages/objectql/src/engine.ts, registerDriver). A wrapper registered in place of the service would be refused by the engine, and every objectql-mediated write would go straight to the raw driver. The instance is shared and the engine's write path is a call-time property lookup on it, so guarding the object itself covers both the plugin that resolves driver.* directly and the engine that writes through it.
  • Not a list of phase names. This card went from one phase to three before a line was written; a fourth would silently re-open the hole. The guard is phase-agnostic — one choke point, covered on the day a phase ships.
  • Refused members are read off the contract, not off a survey. The row-write surface of IDataDriver (packages/spec/src/contracts/data-driver.ts): create, update, upsert, delete, bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany. Adding a write to that interface is a spec diff every driver has to implement, so the list goes stale loudly rather than silently.
  • The contract's raw-execution escape hatch is REPORTED, not refused.execute() is a required member of IDataDriver (data-driver.ts:108, under the contract's own "Raw Execution (Escape Hatch)" heading) — on every driver, not a driver-sql extension. It is not refused, for a stated reason: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (a SELECT can quote the word INSERT in a literal; a CTE can write), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). What must never happen is the silent half: a boot-window execute() is forwarded, counted per driver, warned once per driver on stderr, and named in the composition notes — and on such a run no note claims the plan wrote nothing. The "a plan writes nothing" sentence is an outcome, printed by disarm() only on runs where it held.
  • A refusal does not throw.context.trigger() dispatches boot hooks propagating (packages/core/src/hook-dispatch.ts), so a throwing refusal would abort the bootstrap — turning "your plugin wrote during a dry run" into "you cannot get a plan at all", on the command whose whole job is to be read before a production apply. A refused call returns a contract-shaped value (create/upsert echo the caller's own payload rather than inventing an id; delete returns the contract's not-found false; updateMany/deleteMany return 0), and the run says so out loud: one warning on stderr per driver/method/object triple, plus a line in the composition notes the plan prints and --json carries.

The guard is armed by a plugin composed first in buildSchemaMigrationPlugins, so its init() is ordered ahead of every host plugin's (resolvePluginOrder is a DFS in registration order), and it re-scans in Phase 2 to pick up a driver registered by a later init(). bootSchemaStack disarms it the moment the bootstrap returns — everything after that is work the command was asked for, so os migrate apply's confirmed DDL flush and the coverage pass are untouched.

Seam census — what is NOT covered, stated rather than hidden

Every one of these is named in the module header too, so a future reader can tell a deliberate boundary from an oversight:

  • execute() — reported, never silent (see above): forwarded during the boot window, counted per driver, warned on stderr, named in the notes, and the "writes nothing" claim dropped for that run. getKnex() (a driver-sql extension, genuinely off-contract) is not intercepted; it was not the path any measured instance of this defect took.
  • DDL — and its members split.deferSchemaDdl holds back the initObjects/syncSchema path, which apply flushes on purpose once the operator confirms — guarding that would refuse the one write these commands exist to make. dropTable (and driver-sql's rotateShards) are NOT held back by that deferral: they run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately, so a hook calling driver.dropTable(...) on a managed datasource during a declaration boot executes, today as before this guard. A genuinely open boundary, stated.
  • Engine-held drivers for non-default datasources. The guard's scan covers the driver.* services the kernel publishes, and the only such registration repo-wide is the default datasource's (packages/runtime/src/default-datasource-plugin.ts). DatasourceConnectionService.connect() hands every other datasource's driver straight to engine.registerDriver, never through driver.* — so a host stack that connects a second datasource during a composed boot holds an engine-side driver this guard cannot see, and objectql-mediated writes to objects bound to it would land. Likely coverable by also arming instances at the engine.registerDriver seam while the guard is armed; proposed as a follow-up rather than widened into this PR — scope growth on a p1 is the PM's call.
  • writes a plugin makes outside the database entirely (filesystem, network).
  • work a hook defers past the end of the bootstrap; the guard covers the boot window.

Verification

The decisive test, with its positive controlpackages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts, booting a real ObjectKernel with a recording driver whose write methods live on the prototype, exactly like a real driver's:

  • positive control: the fixture plugin, composed the way a served boot composes it, writes on all three phases (create:sys_permission_set from start(), then create:sys_ai_model on ready, bootstrapped and listening);
  • the defect: composeForDeclarations alone removes only the start() seed — all three init()-registered hooks still write. Pinned, not described;
  • the fix: zero writes reach the driver, and the log-only hooks still ran — the recorded order is log-only:kernel:ready, write:kernel:ready, …, i.e. every handler executed and only the write was refused;
  • the escape hatch (R1): a guarded boot in which one hook issues both a contract create() (refused — the in-run control) and a raw execute("INSERT …"): the raw command is forwarded (the real driver ran it and its return value came back), counted (rawExecutions = 1 via that driver), and reported — and the disarm note drops the flat claim: expect(note).not.toContain('a plan writes nothing'). The refusal-only case pins the converse: with no raw command forwarded, the claim held and is printed;
  • plus: the whole row-write contract is refused (not just create()), a refused call hands back a contract-shaped value instead of throwing, and disarm() removes the shadowing own properties (the execute forwarder included) rather than overwriting them.

End to end against a real SQL driverpackages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, on a sqlite database whose tables already exist:

  • positive control: the same plugin on a boot with no host composition lands 3 rows;
  • the fix: a boot through bootSchemaStack with the writer coming from objectstack.config.tsand one handed straight to the kernel adds 0 rows (measured as a delta against the control's 3), every log-only hook ran, and composition.notes carries Refused 6 write(s) during the declaration boot — a plan writes nothing (no raw command that run — the claim held);
  • the review's own control shape (R1): in one guarded boot, create() refused (0 rows) while execute("INSERT …")landed 1 row — forwarded on purpose, and no longer silent: the notes carry Refused 4 write(s) during the declaration boot: (the colon marks the dropped claim), Raw execute() was called 1 time(s) during the declaration boot, and no note in the run claims the plan wrote nothing.

Ablations — direction stated in writing before each run.

Original guard ablation (measured at 6a8a182b77): drop the guard from the composed plugin list ⇒ predicted RED on the integration fix case and the two composition-ordering assertions, guard unit file GREEN; observed exactly that (expected 9 to be 3 — six rows the operator never asked for). Mutate/restore/measure in one process under trap … EXIT INT TERM, blob-hash-verified.

R1 reporting ablation (measured at 840f058ecb): remove the execute arm (DRIVER_RAW_EXECUTION_METHODS.slice(0, 0); anchor occurs exactly once) ⇒ predicted RED on the unit escape-hatch case, the disarm restore case (it asserts the execute shadow exists), and the integration R1 case (the unqualified claim returns); predicted GREEN elsewhere, the raw row still landing. Observed exactly that:

worktree before: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
occurrences BEFORE — original: 1, mutant: 0
worktree after : 11c2699deedbe358087e99e3dc91a95d8306e639
occurrences AFTER — original: 0, mutant: 1
Tests 3 failed | 14 passed (17) ← exactly the three predicted cases
worktree restored: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
git diff HEAD: empty · git status --porcelain: empty

Gates — union derived on the merged tree at 840f058ecb with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, reconciled with comm -23/comm -13 in both directions, exact string comparison: named 35, ran 35, unreconciled 0. Plus pnpm lint (repo-wide, eslint . --no-inline-config, exit 0 — no narrowing) and pnpm check:nul-bytes (exit 0), neither of which the derivation names.

34 of the 35 are green. node scripts/check-test-completeness.mjs is NOT MEASURED (exit 3, PREREQUISITE NOT MET) — it grades a saved turbo run test log, does not run tests, and its own text instructs a local family run to record it as NOT MEASURED. Three others first reported PREREQUISITE NOT MET for missing build output and are green after building the closures they named: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt (exit codes captured before any pipe, per gate log).

Tests at 840f058ecb: the guard unit + composition files 36/36; the #13332 integration block 3/3; the whole @objectstack/cli package 224 files / 2551 passed + 15 skipped, with two unrelated files (test/init-created-files-summary.e2e.test.ts, src/commands/datasource/envelope-unwrap.test.ts) timing out their beforeAll hooks under load during the full parallel run and passing 15/15 when re-run in isolation; pnpm --filter @objectstack/cli typecheck exit 0.

Contract review requested

Changing what a declaration boot permits is likely a contract change, and this PR does not decide that. The at-tier review (2026-09-01) ruled clause ② no on both limbs — conformance, not a contract change; no measured public-surface widening — and its four required items are addressed above.

What changed, and for whom. For os migrate plan and os migrate apply: during the kernel bootstrap, a row write issued through any driver.* instance the kernel publishes is now refused and reported instead of executed, and a raw execute() call through such an instance is forwarded but reported — with the run's notes declining to claim the plan wrote nothing. A host whose plugins wrote during that window will see contract writes stop landing and raw commands named; a host that did neither sees no change whatsoever: no disarm note is emitted when nothing was refused and no raw command went through, and the artifact-less, config-less run is untouched (that path returns before the guard is composed). Nothing outside these two commands is affected — os serve, os dev and os start do not boot through bootSchemaStack.

The changeset is minor on @objectstack/cli.

Generated by Claude Code

@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/automation/webhooks.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/kernel/http-protocol.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/knowledge.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

  • content/docs/releases/implementation-status.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v16.mdx(via updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v17.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

What this run could not see
  • 6 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 — 23 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 c75962ac3e0af69ae20f658618c4d3ab58192cc8packageMentionDocs.

Which tree this was computed on

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

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

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

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

REQUEST CHANGES

At-tier contract review under CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs on origin/main @ dda969cd71: export const CONTRACT_REVIEW_TIER = 'claude-fable-5' — this reviewer is that tier). Everything below was re-derived or re-measured on a detached worktree at head 6a8a182b77, not taken from the PR's text.

The ruling this PR routed here (both limbs, judged separately)

1. Accept/reject change — NO, this is conformance, and the PM's Clause-②: no stands. The guarantee was already shipped in three registers before this PR: the module's own printed sentence ("a plan writes nothing"), the operator docs (content/docs/deployment/cli.mdx:671 — "os migrate plan | Warns and continues — a plan writes nothing either way"), and plan.ts's own comments (:53, :141). Nothing is newly accepted or rejected at any input boundary — every host config and plugin that composed before still composes; what changed is that an effect documented not to happen now does not happen. On the "nobody could have depended on it" tension: the evidence runs the other way and it is measured, not asserted — the one known downstream host that wrote during this window (cloud, ensure-default-ai-model-plugin) treated those writes as a defect in itself (cloud#1744) and moved them out (cloud PR #1749). The affected population's own revealed dependence was on the documented guarantee, not the accidental behaviour. A host depending on writes landing during a plan/apply boot was depending on a documented untruth. No gate breach by the PM (the opus dispatch was in-band); routing the question here was still correct, because the delta is real and downstream.

2. Public-surface widening — NO, measured with a control. Built @objectstack/cli and its dependency closure twice (pnpm exec turbo run build --filter='@objectstack/cli...') at head 6a8a182b77 and base ada3834add. The package's exports map exposes exactly two type surfaces: .dist/index.d.ts and ./consoledist/utils/console.d.ts. Both are byte-identical across the two builds (cmp). The only PR-attributable .d.ts delta is dist/utils/schema-migration-plugins.d.ts (~100 changed lines: createDeclarationBootWriteGuard, DeclarationBootWriteGuard, RefusedDeclarationWrite, SchemaMigrationComposition.writeGuard?), which is not reachable through the exports map. Positive control: 5 .js files differ, including schema-migrate.js and schema-migration-plugins.js — the instrument sees this PR's change, so the entry-point zero is a real zero. (The extract-hook-body/lower-callables/lint dist diffs are from main commits the head merge pulled in — git diff ada3834a...6a8a182b -- packages/cli/src/utils/extract-hook-body.ts is empty.)

So on the contract question: clause ② does not apply — the guarantee was already the contract. The REQUEST CHANGES is not about the ruling; it is about claims the PR rests on that did not survive verification.

Verified TRUE (independently)

  • Seam claim holds.packages/objectql/src/engine.tsregisterDriver (~:5172): if (existing !== driver) → warn and discard the supplied instance — a service-level wrapper handed to the engine under a held name would indeed be dropped, and the engine already holds the raw instance from DatasourceConnectionService.connect() (packages/services/service-datasource/src/datasource-connection-service.ts:618). The engine's write path is a call-time property lookup on that shared instance (this.getDriver(object) then await driver.create(...), engine.ts :4536/:6659), so the own-property shadow intercepts objectql-mediated writes. Wrapping the instance is the only placement that intercepts. One misattribution: the driver.* discovery loop is in ObjectQLPlugin.start (packages/objectql/src/plugin.ts:469, loop at :628), not init() as the PR body and the new docblocks say — the engine's own docblock has it right. Substance unaffected; text should be corrected.
  • Non-throwing refusal premise holds.packages/core/src/hook-dispatch.ts header: boot path (kernel:ready/kernel:bootstrapped/kernel:listening) ⇒ dispatchHookPropagating — a throwing handler aborts the bootstrap, so a throwing refusal would turn "your plugin wrote during a dry run" into "no plan at all". A create() refusal echoing the caller's payload is success-shaped to the calling plugin — but the party the guarantee protects is the operator, who is told twice (stderr + notes), reads pass through honestly (a write-then-read sees no phantom row), and every alternative return either fabricates an identity or aborts the boot. Right call.
  • Scope claim holds.composeHostStack: true appears in exactly two non-test call sites: migrate/plan.ts:154 and migrate/apply.ts:181; the guard is composed only under that flag, and buildSchemaMigrationPlugins returns NOTHING_COMPOSED before createDeclarationBootWriteGuard() on the artifact-less config-less path. No bootSchemaStack caller is serve/dev/start. All three phases fire inside kernel.bootstrap() (:331, phases at :402/:414/:424), so the disarm placed after await runtime.start() covers the whole window and comes off before apply's flush.
  • The measurements are real. Re-ran the guard unit file + composition file at head: 35/35 pass. Re-ran the new integration block (-t '13332'): positive control (3 rows) and fix case (delta 0, log-only hooks ran, note carries Refused 6 write(s)) both pass.

What did not survive verification — the required changes

R1. The refusal-surface claim is false as stated, and I measured the hole. The PR: "Refused members are read off the contract, not off a survey… a driver's raw escape hatches — driver-sql's execute() and getKnex(). Off-contract for a plugin." That is wrong twice. execute(command, parameters?, options?) is a required member of the contract itselfpackages/spec/src/contracts/data-driver.ts:108, under the contract's own heading "Raw Execution (Escape Hatch)" — on every driver, not a driver-sql extension. (Also: the interface is IDataDriver; no IDataSourceDriver symbol exists in packages/spec.) Measured, with an in-run control, against the built head via bootSchemaStack (composeHostStack: true, tables pre-materialized, one host plugin whose kernel:ready hook issues both calls):

RESULT create-probe rows (guarded surface, control): 0 ← refused, reported
RESULT exec-probe rows (raw execute() INSERT): 1 ← LANDED
notes: "…row writes refused at the driver for the whole boot — a plan writes nothing."
"Refused 1 write(s) during the declaration boot … create() on sys_metadata."

The same armed guard, the same driver instance, the same hook: create() refused and reported; execute("INSERT INTO sys_metadata …") landed a row in the plan's target database silently, while the run printed "a plan writes nothing" and a refusal list that looks complete. That is precisely the enumerated-subset defect class this repo has on record: the guard reports green for the member it excluded. Required: during the boot window, an execute() call must not land silently — either refuse it (contract return is Promise<unknown>; if refusal is judged too risky because execute also serves reads and SQL cannot be classified reliably, say that reason) or forward it and report it in the same notes/stderr channel so the printed sentence stays true or is qualified. And the census text (module header, changeset) must stop calling execute off-contract.

R2. "DDL — already held back by deferSchemaDdl" is false for dropTable.setDeferredDdl (sql-driver.ts :10153) sets a flag that only the initObjects/syncSchema path checks (:9393/:9408). dropTable (:8726) runs assertSchemaMutable — which gates schemaMode/dialect (:5162), not deferral — then knex.schema.dropTableIfExists immediately; rotateShards is the same shape. A hook calling driver.dropTable(...) on a managed datasource during a plan boot executes, today and after this PR. The census line (module header + changeset) claims a mechanism that does not exist for those members; correct it, and either cover them or state them as genuinely open.

R3. One unstated residue worth naming in the census: the guard's scan covers "every driver.* instance the kernel publishes", but the engine also receives drivers that are never published as driver.*DatasourceConnectionService.connect() hands them to engine.registerDriver directly (:618), and the only driver.* registration repo-wide is the default driver (packages/runtime/src/default-datasource-plugin.ts:183). A host stack that connects a second datasource during a composed boot holds an engine-side driver the guard cannot see; objectql-mediated writes to objects bound to it would land. Same class as the stated boundaries — but the census's job, by its own words, is to make a deliberate boundary distinguishable from an oversight, so it belongs on the list.

R4. Text accuracy in the same pass:ObjectQLPlugin.start, not init() (docblocks in schema-migration-plugins.ts + changeset); IDataDriver, not IDataSourceDriver.

Changeset grade

minor on @objectstack/cli@17.2.0 is right given the ruling above: a host-visible behavioural narrowing that conforms to documented behaviour is not major, and patch would under-signal a delta a host can observe. The body's "What changed / Who this affects" is the correct shape and names the delta plainly — but it repeats the R1/R2 misstatements ("raw escape hatches (driver-sql's execute() …)", "DDL (already held back by deferSchemaDdl …)") and must be corrected with them.

Summary

Design (b) is the right design, the seam is the right seam and verifiably the only one that intercepts, the tests are real and their controls are the right controls, and the contract question resolves to conformance on both limbs — no accept/reject change, no measured surface widening. What must change before this leaves draft: R1 (cover or report execute(), and correct the "off-contract" claim), R2 (correct the dropTable/deferral claim, cover or state it), R3 (name the engine-held non-default-driver residue), R4 (attribution fixes), with the changeset corrected alongside. The operator-facing sentence this PR exists to make true — "a plan writes nothing" — must not print over a run in which a contract-member write landed silently; that is the same defect this PR was filed to close, one member over.


Generated by Claude Code

…execute() escape hatch, correct the census
R1: execute() is a REQUIRED member of IDataDriver (packages/spec/src/
contracts/data-driver.ts:108, 'Raw Execution (Escape Hatch)'), not a
driver-sql extension, and the guard did not touch it: in one guarded boot
create() was refused and reported while execute("INSERT ...") landed a
row silently under a printed 'a plan writes nothing'. Chosen: REPORT, not
cover — a raw command is unknown by contract ('SQL string, shell command,
or API payload') and SQL text cannot be classified read-vs-write reliably
(a SELECT can quote INSERT in a literal; a CTE can write), while the
framework's own boot work (ensureOverlayIndex's index DDL) runs through
this seam, so a refusal would either break legitimate calls or rest on a
guess. A boot-window execute() is now forwarded, counted per driver,
warned once per driver on stderr, and named in the composition notes —
and no note claims the plan wrote nothing on such a run: the flat claim
is an OUTCOME, printed by disarm() only when it held.
R2: 'DDL already held back by deferSchemaDdl' was false for dropTable /
rotateShards — they run assertSchemaMutable (a schemaMode/dialect gate,
not a deferral check) and execute immediately. The census now states the
split and names them a genuinely open boundary.
R3: drivers the engine holds for a non-default datasource are never
published as driver.* (the only registration is
default-datasource-plugin.ts) and are invisible to the guard's scan —
now stated in the census, module header and changeset.
R4: ObjectQLPlugin.start (plugin.ts:469/:628), not init(), walks the
driver.* services; the interface is IDataDriver, not IDataSourceDriver
(no such symbol exists in spec). Corrected in docblocks, tests and the
changeset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review response — R1–R4 addressed at 840f058ecb (session session_01UngCYXF98BVpYA9hfz6NYk, same branch). Both central facts were re-derived before acting, per the review's own instruction; nothing settled was re-opened.

Re-derivations (before any edit):

  • execute(command: unknown, parameters?, options?): Promise<unknown> confirmed a required member at packages/spec/src/contracts/data-driver.ts:108, under "Raw Execution (Escape Hatch)"; grep -rn IDataSourceDriver packages/spec/src/ exits 1 — no such symbol.
  • Reproduced the hole at 6a8a182b77 with the review's control shape (guarded bootSchemaStack, composeHostStack: true, tables pre-materialized, one hook issuing both calls): create() refused, 0 rows, reported — execute("INSERT INTO sys_metadata …")landed 1 row silently while the notes printed "a plan writes nothing".

R1 — chose REPORT, not cover, for the reason the review anticipated: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (no regex guessing), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). A boot-window execute() is now forwarded, counted per driver (rawExecutions on the guard, --json-visible), warned once per driver on stderr, and named in the composition notes — and the "a plan writes nothing" sentence is now an outcome owned by disarm(): printed when it held, dropped when a raw command went through. The compose-time note no longer pre-claims it. Proof, same control shape at head: create() 0 rows + execute 1 row as before, but notes now read Refused 4 write(s) during the declaration boot: (colon = dropped claim) + Raw execute() was called 1 time(s) during the declaration boot (1 via driver.com.objectstack.driver.sql) and FORWARDED, not refused: … — pinned in both the unit file and a new integration case. Mutation proof (direction written before the run): ablating the execute arm went RED on exactly the 3 predicted cases (14 GREEN), blob-hash mutate/restore verified, trap … EXIT INT TERM, one process.

R2 — the census and changeset no longer claim deferSchemaDdl holds DDL back wholesale. Stated now: the deferral covers the initObjects/syncSchema path (flushed on purpose by apply); dropTable/rotateShards run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately during a declaration boot. Named a genuinely open boundary in the module header, PR body and changeset; not covered here.

R3 — the engine-held non-default-datasource residue is named in the module header census, PR body and changeset: only the default datasource is ever published as driver.* (default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other datasources' drivers straight to engine.registerDriver, invisible to the scan. Likely coverable by arming instances at the registerDriver seam while the guard is armed — proposed as a follow-up, not widened into this PR; scope call is the PM's.

R4 —ObjectQLPlugin.start (loop at plugin.ts:628), not init(); IDataDriver, not IDataSourceDriver — corrected in the docblocks, the unit-test fixture comment, the PR body and the changeset. No occurrence remains in packages/cli/src or .changeset/.

Gates at 840f058ecb (branch merged with current origin/main first — the derivation flagged its gate-family files stale): union via dispatch-gates.mjs --commands, reconciled with comm both directions, exact strings: named 35, ran 35, unreconciled 0. 34 green; check-test-completeness NOT MEASURED (exit 3, PREREQUISITE NOT MET, per its own text). check:dual-build-cjs-loads / check:i18n-coverage / check:type-check-debt green after building the closures they named. Plus unnamed: pnpm lint exit 0, pnpm check:nul-bytes exit 0. CLI package: 224 files, 2551 passed + 15 skipped (two unrelated files hit beforeAll timeouts under full-suite load and pass 15/15 in isolation); typecheck exit 0.

PR remains draft; labels untouched — needs:contract-review stays until you clear it.

Generated by Claude Code


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

APPROVE

Follow-up at-tier contract review under CONTRACT_REVIEW_TIER (claude-fable-5), re-verifying R1–R4 at the new head 840f058ecb. Nothing below is carried forward from the 6a8a182b77 readings — the tree moved (merge of origin/main), so every fact was re-derived on a fresh detached worktree, with the same probe re-run against a fresh build of the head (dist currency confirmed by content grep before trusting it: the new guard strings are present in dist/utils/schema-migration-plugins.js).

1. Report over cover — the right call, on premises that held

All three stated premises re-derived, plus one the fix did not claim:

  • execute(command: unknown, …) is the contract's own opaque member — "SQL string, shell command, or API payload" (packages/spec/src/contracts/data-driver.ts, "Raw Execution (Escape Hatch)"). There is no sound classifier over that input; any bounded cover either refuses allexecute() (breaking legitimate raw reads) or classifies SQL text (a guess, and unreliable in both directions).
  • The framework-traffic premise is real: packages/metadata-protocol/src/protocol.ts:5210 (ensureOverlayIndex) issues its index DDL via (driver as any).execute(sql) — a wholesale refusal would break the framework's own seam users or force an unprincipled caller allowlist.
  • The premise the commit didn't state but which seals it: execute() has no contract-shaped refusal value. Its return is unknown; a fabricated return silently corrupts raw-read callers — strictly worse than the row-write case, where honest no-op shapes exist. So a non-throwing refusal is not even constructible here.

I asked for cover-or-report in R1 and offered report as an acceptable shape; report is not just acceptable — for this member it is the only shape that neither guesses nor breaks. No bounded cover exists without one of those two defects.

2. The disarm()-owned outcome — closes it, does not relocate it (with one stated residue)

Measured behaviourally, both directions, at the new head:

  • Raw traffic present (my original probe, re-run against the built head): create() refused (0 rows, in-run control), execute("INSERT …") forwarded (1 row lands — now the documented behaviour), stderr carries the per-driver warning, and the notes read Refused 1 write(s) during the declaration boot: create() on sys_metadata … — flat claim dropped — followed by Raw execute() was called 1 time(s) … whether this boot wrote is NOT verified, and this run does NOT claim to have written nothing. No note in the run contains "a plan writes nothing". The compose-time note now states mechanism, not outcome ("with the contract's row writes refused at the driver for the whole boot") — the pre-claim is gone.
  • Raw traffic absent: the integration fix case pins Refused 6 write(s) during the declaration boot — a plan writes nothing (3/3 pass), and the unit disarm case pins the claim printed only when it held (36/36 pass).

So the sentence is no longer an assertion printed over an unverified run; it is a measurement over everything the guard can observe. The honest residue, for the record: the claim can still print on a run where a write escaped through a census-stated open boundary — dropTable/rotateShards (verified again: assertSchemaMutable gates schemaMode/dialect, not deferral), a getKnex() handle, or an engine-held second-datasource driver. For getKnex() handles and second drivers that is irreducible without the follow-up seam. For dropTable/rotateShards it is not: those members are unambiguous writes with no classification problem, and the same forward-and-count treatment execute() got would extend the outcome's honesty to them cheaply. Non-blocking — R2 offered "state as open" and that offer stands — but it belongs on the follow-up's scope.

3. R3 residue — stated accurately, and deferral is acceptable

The census/changeset wording matches what I measured, item for item: only the default datasource is ever published as driver.* (re-grepped repo-wide at 840f058ecb: the single registration is packages/runtime/src/default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other drivers straight to engine.registerDriver (now datasource-connection-service.ts:635 after the main merge — same mechanism); consequence stated correctly ("objectql-mediated writes to objects bound to it would land"). The proposed fix seam (arm at engine.registerDriver while the guard is armed) is the right one. Leaving it unimplemented is acceptable for merge: the shape is an edge beyond every measured instance of this defect, and it is stated where a reader of the mechanism will look. It should exist as a tracked issue before this merges, not only as a sentence in a PR body — filing it is the PM's, and the dropTable/rotateShards counting above should ride the same card.

4. R4 zero — re-derived, holds, with controls

  • grep -rn "IDataSourceDriver" packages/cli/src .changeset/ → exit 1 (zero). Repo-wide over all packages/**/*.ts, exit code taken from grep itself (not a pipe tail): 0 matches, exit 1. Positive control: IDataDriver present (4× in the guard module, 2× in the changeset) — the zero is not a tool failure.
  • grep -rn "ObjectQLPlugin\.init\|ObjectQLPlugin's init" packages/cli/src .changeset/ → exit 1 (zero). Control: ObjectQLPlugin.start present 3× in the module, and I re-confirmed the fact itself at the new head (packages/objectql/src/plugin.tsstart = async at :469, discovery loop at :628).

Also re-verified rather than assumed after the merge: the seam facts stand (engine registerDriver discard branch, engine write path as call-time lookup); disarm() restores the execute forwarder too (own descriptor present while armed, gone after, prototype identity preserved — pinned at test :405/:415); and the public-surface zero still holds structurally — the PR's 6-file surface touches neither packages/cli/package.json nor src/index.ts/utils/console.ts (git diff origin/main...840f058ecb on those paths: empty), so the new ForwardedRawExecution/rawExecutions symbols live in the same module the exports map does not reach.

Ruling

The clause-② ruling from the first review stands unchanged at this head: conformance, not a contract accept/reject change; no public-surface widening. All four required items are addressed and verified. needs:contract-review clears — my explicit word, for both carriers (label removal is the PM's write, not mine). The PR stays draft per the PM's parking; this approval is contract-review clearance, not a merge instruction. One expectation attached, non-blocking: the R3 follow-up (engine-seam arming, plus dropTable/rotateShards counting) becomes a tracked issue before merge.


Generated by Claude Code

Merged via the queue into main with commit bf3bbf1Sep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-13332-declaration-boot-write-suppression branch September 1, 2026 06:47
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-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone - #14053

Merged
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression
Sep 1, 2026
Merged

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone#14053
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13332

Design decision (b) — suppress writes at the driver seam for the declaration boot, so read/log hooks still run — as decided by the domain:cli PM seat in the claim comment. It was implementable as stated; the named fallback (report a seam census showing no single driver seam exists) was not taken.

Contract-review response (2026-09-01): the at-tier review's four required items are addressed at 840f058ecb — R1 (the contract's execute() escape hatch is now reported, and no run with a forwarded raw command claims it wrote nothing), R2 (the dropTable/deferral claim corrected — it is a genuinely open boundary, stated), R3 (the engine-held non-default-datasource driver residue named in the census and changeset), R4 (attributions corrected: ObjectQLPlugin.start, IDataDriver). Details inline below and in the review-response comment.

The defect

composeForDeclarations documented the plan path's guarantee in its own words — "(init runs, start does not — a plan writes nothing)" — and implemented it as a Proxy whose only override is start. packages/core/src/kernel.ts then fires three phases unconditionally after the suppressed start pass:

:402 Phase 3 trigger('kernel:ready')
:404 Phase 3.5 trigger('kernel:bootstrapped')
:416 Phase 4 trigger('kernel:listening')

A writing hook registered from init() survives the suppression on all three. The guarantee was therefore a property of plugins that happen to seed from start() — the shape of the one plugin that had been measured — and not a property of the plan path. packages/core/src/kernel.ts is untouched by this PR: the unconditional firing is the condition, not the fix site.

The seam, and why this one

The driver instance, guarded for the length of the kernel bootstrap.

  • Not the driver.* service entry.ObjectQLPlugin.start() walks the kernel's driver.* services (packages/objectql/src/plugin.ts — the discovery loop lives in start, not init) and hands each to the engine, which keys its registry by driver.name and discards a second instance under a name it already holds (packages/objectql/src/engine.ts, registerDriver). A wrapper registered in place of the service would be refused by the engine, and every objectql-mediated write would go straight to the raw driver. The instance is shared and the engine's write path is a call-time property lookup on it, so guarding the object itself covers both the plugin that resolves driver.* directly and the engine that writes through it.
  • Not a list of phase names. This card went from one phase to three before a line was written; a fourth would silently re-open the hole. The guard is phase-agnostic — one choke point, covered on the day a phase ships.
  • Refused members are read off the contract, not off a survey. The row-write surface of IDataDriver (packages/spec/src/contracts/data-driver.ts): create, update, upsert, delete, bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany. Adding a write to that interface is a spec diff every driver has to implement, so the list goes stale loudly rather than silently.
  • The contract's raw-execution escape hatch is REPORTED, not refused.execute() is a required member of IDataDriver (data-driver.ts:108, under the contract's own "Raw Execution (Escape Hatch)" heading) — on every driver, not a driver-sql extension. It is not refused, for a stated reason: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (a SELECT can quote the word INSERT in a literal; a CTE can write), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). What must never happen is the silent half: a boot-window execute() is forwarded, counted per driver, warned once per driver on stderr, and named in the composition notes — and on such a run no note claims the plan wrote nothing. The "a plan writes nothing" sentence is an outcome, printed by disarm() only on runs where it held.
  • A refusal does not throw.context.trigger() dispatches boot hooks propagating (packages/core/src/hook-dispatch.ts), so a throwing refusal would abort the bootstrap — turning "your plugin wrote during a dry run" into "you cannot get a plan at all", on the command whose whole job is to be read before a production apply. A refused call returns a contract-shaped value (create/upsert echo the caller's own payload rather than inventing an id; delete returns the contract's not-found false; updateMany/deleteMany return 0), and the run says so out loud: one warning on stderr per driver/method/object triple, plus a line in the composition notes the plan prints and --json carries.

The guard is armed by a plugin composed first in buildSchemaMigrationPlugins, so its init() is ordered ahead of every host plugin's (resolvePluginOrder is a DFS in registration order), and it re-scans in Phase 2 to pick up a driver registered by a later init(). bootSchemaStack disarms it the moment the bootstrap returns — everything after that is work the command was asked for, so os migrate apply's confirmed DDL flush and the coverage pass are untouched.

Seam census — what is NOT covered, stated rather than hidden

Every one of these is named in the module header too, so a future reader can tell a deliberate boundary from an oversight:

  • execute() — reported, never silent (see above): forwarded during the boot window, counted per driver, warned on stderr, named in the notes, and the "writes nothing" claim dropped for that run. getKnex() (a driver-sql extension, genuinely off-contract) is not intercepted; it was not the path any measured instance of this defect took.
  • DDL — and its members split.deferSchemaDdl holds back the initObjects/syncSchema path, which apply flushes on purpose once the operator confirms — guarding that would refuse the one write these commands exist to make. dropTable (and driver-sql's rotateShards) are NOT held back by that deferral: they run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately, so a hook calling driver.dropTable(...) on a managed datasource during a declaration boot executes, today as before this guard. A genuinely open boundary, stated.
  • Engine-held drivers for non-default datasources. The guard's scan covers the driver.* services the kernel publishes, and the only such registration repo-wide is the default datasource's (packages/runtime/src/default-datasource-plugin.ts). DatasourceConnectionService.connect() hands every other datasource's driver straight to engine.registerDriver, never through driver.* — so a host stack that connects a second datasource during a composed boot holds an engine-side driver this guard cannot see, and objectql-mediated writes to objects bound to it would land. Likely coverable by also arming instances at the engine.registerDriver seam while the guard is armed; proposed as a follow-up rather than widened into this PR — scope growth on a p1 is the PM's call.
  • writes a plugin makes outside the database entirely (filesystem, network).
  • work a hook defers past the end of the bootstrap; the guard covers the boot window.

Verification

The decisive test, with its positive controlpackages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts, booting a real ObjectKernel with a recording driver whose write methods live on the prototype, exactly like a real driver's:

  • positive control: the fixture plugin, composed the way a served boot composes it, writes on all three phases (create:sys_permission_set from start(), then create:sys_ai_model on ready, bootstrapped and listening);
  • the defect: composeForDeclarations alone removes only the start() seed — all three init()-registered hooks still write. Pinned, not described;
  • the fix: zero writes reach the driver, and the log-only hooks still ran — the recorded order is log-only:kernel:ready, write:kernel:ready, …, i.e. every handler executed and only the write was refused;
  • the escape hatch (R1): a guarded boot in which one hook issues both a contract create() (refused — the in-run control) and a raw execute("INSERT …"): the raw command is forwarded (the real driver ran it and its return value came back), counted (rawExecutions = 1 via that driver), and reported — and the disarm note drops the flat claim: expect(note).not.toContain('a plan writes nothing'). The refusal-only case pins the converse: with no raw command forwarded, the claim held and is printed;
  • plus: the whole row-write contract is refused (not just create()), a refused call hands back a contract-shaped value instead of throwing, and disarm() removes the shadowing own properties (the execute forwarder included) rather than overwriting them.

End to end against a real SQL driverpackages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, on a sqlite database whose tables already exist:

  • positive control: the same plugin on a boot with no host composition lands 3 rows;
  • the fix: a boot through bootSchemaStack with the writer coming from objectstack.config.tsand one handed straight to the kernel adds 0 rows (measured as a delta against the control's 3), every log-only hook ran, and composition.notes carries Refused 6 write(s) during the declaration boot — a plan writes nothing (no raw command that run — the claim held);
  • the review's own control shape (R1): in one guarded boot, create() refused (0 rows) while execute("INSERT …")landed 1 row — forwarded on purpose, and no longer silent: the notes carry Refused 4 write(s) during the declaration boot: (the colon marks the dropped claim), Raw execute() was called 1 time(s) during the declaration boot, and no note in the run claims the plan wrote nothing.

Ablations — direction stated in writing before each run.

Original guard ablation (measured at 6a8a182b77): drop the guard from the composed plugin list ⇒ predicted RED on the integration fix case and the two composition-ordering assertions, guard unit file GREEN; observed exactly that (expected 9 to be 3 — six rows the operator never asked for). Mutate/restore/measure in one process under trap … EXIT INT TERM, blob-hash-verified.

R1 reporting ablation (measured at 840f058ecb): remove the execute arm (DRIVER_RAW_EXECUTION_METHODS.slice(0, 0); anchor occurs exactly once) ⇒ predicted RED on the unit escape-hatch case, the disarm restore case (it asserts the execute shadow exists), and the integration R1 case (the unqualified claim returns); predicted GREEN elsewhere, the raw row still landing. Observed exactly that:

worktree before: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
occurrences BEFORE — original: 1, mutant: 0
worktree after : 11c2699deedbe358087e99e3dc91a95d8306e639
occurrences AFTER — original: 0, mutant: 1
Tests 3 failed | 14 passed (17) ← exactly the three predicted cases
worktree restored: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
git diff HEAD: empty · git status --porcelain: empty

Gates — union derived on the merged tree at 840f058ecb with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, reconciled with comm -23/comm -13 in both directions, exact string comparison: named 35, ran 35, unreconciled 0. Plus pnpm lint (repo-wide, eslint . --no-inline-config, exit 0 — no narrowing) and pnpm check:nul-bytes (exit 0), neither of which the derivation names.

34 of the 35 are green. node scripts/check-test-completeness.mjs is NOT MEASURED (exit 3, PREREQUISITE NOT MET) — it grades a saved turbo run test log, does not run tests, and its own text instructs a local family run to record it as NOT MEASURED. Three others first reported PREREQUISITE NOT MET for missing build output and are green after building the closures they named: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt (exit codes captured before any pipe, per gate log).

Tests at 840f058ecb: the guard unit + composition files 36/36; the #13332 integration block 3/3; the whole @objectstack/cli package 224 files / 2551 passed + 15 skipped, with two unrelated files (test/init-created-files-summary.e2e.test.ts, src/commands/datasource/envelope-unwrap.test.ts) timing out their beforeAll hooks under load during the full parallel run and passing 15/15 when re-run in isolation; pnpm --filter @objectstack/cli typecheck exit 0.

Contract review requested

Changing what a declaration boot permits is likely a contract change, and this PR does not decide that. The at-tier review (2026-09-01) ruled clause ② no on both limbs — conformance, not a contract change; no measured public-surface widening — and its four required items are addressed above.

What changed, and for whom. For os migrate plan and os migrate apply: during the kernel bootstrap, a row write issued through any driver.* instance the kernel publishes is now refused and reported instead of executed, and a raw execute() call through such an instance is forwarded but reported — with the run's notes declining to claim the plan wrote nothing. A host whose plugins wrote during that window will see contract writes stop landing and raw commands named; a host that did neither sees no change whatsoever: no disarm note is emitted when nothing was refused and no raw command went through, and the artifact-less, config-less run is untouched (that path returns before the guard is composed). Nothing outside these two commands is affected — os serve, os dev and os start do not boot through bootSchemaStack.

The changeset is minor on @objectstack/cli.

Generated by Claude Code

@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/automation/webhooks.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/kernel/http-protocol.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/knowledge.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

  • content/docs/releases/implementation-status.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v16.mdx(via updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v17.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

What this run could not see
  • 6 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 — 23 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 c75962ac3e0af69ae20f658618c4d3ab58192cc8packageMentionDocs.

Which tree this was computed on

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

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

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

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

REQUEST CHANGES

At-tier contract review under CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs on origin/main @ dda969cd71: export const CONTRACT_REVIEW_TIER = 'claude-fable-5' — this reviewer is that tier). Everything below was re-derived or re-measured on a detached worktree at head 6a8a182b77, not taken from the PR's text.

The ruling this PR routed here (both limbs, judged separately)

1. Accept/reject change — NO, this is conformance, and the PM's Clause-②: no stands. The guarantee was already shipped in three registers before this PR: the module's own printed sentence ("a plan writes nothing"), the operator docs (content/docs/deployment/cli.mdx:671 — "os migrate plan | Warns and continues — a plan writes nothing either way"), and plan.ts's own comments (:53, :141). Nothing is newly accepted or rejected at any input boundary — every host config and plugin that composed before still composes; what changed is that an effect documented not to happen now does not happen. On the "nobody could have depended on it" tension: the evidence runs the other way and it is measured, not asserted — the one known downstream host that wrote during this window (cloud, ensure-default-ai-model-plugin) treated those writes as a defect in itself (cloud#1744) and moved them out (cloud PR #1749). The affected population's own revealed dependence was on the documented guarantee, not the accidental behaviour. A host depending on writes landing during a plan/apply boot was depending on a documented untruth. No gate breach by the PM (the opus dispatch was in-band); routing the question here was still correct, because the delta is real and downstream.

2. Public-surface widening — NO, measured with a control. Built @objectstack/cli and its dependency closure twice (pnpm exec turbo run build --filter='@objectstack/cli...') at head 6a8a182b77 and base ada3834add. The package's exports map exposes exactly two type surfaces: .dist/index.d.ts and ./consoledist/utils/console.d.ts. Both are byte-identical across the two builds (cmp). The only PR-attributable .d.ts delta is dist/utils/schema-migration-plugins.d.ts (~100 changed lines: createDeclarationBootWriteGuard, DeclarationBootWriteGuard, RefusedDeclarationWrite, SchemaMigrationComposition.writeGuard?), which is not reachable through the exports map. Positive control: 5 .js files differ, including schema-migrate.js and schema-migration-plugins.js — the instrument sees this PR's change, so the entry-point zero is a real zero. (The extract-hook-body/lower-callables/lint dist diffs are from main commits the head merge pulled in — git diff ada3834a...6a8a182b -- packages/cli/src/utils/extract-hook-body.ts is empty.)

So on the contract question: clause ② does not apply — the guarantee was already the contract. The REQUEST CHANGES is not about the ruling; it is about claims the PR rests on that did not survive verification.

Verified TRUE (independently)

  • Seam claim holds.packages/objectql/src/engine.tsregisterDriver (~:5172): if (existing !== driver) → warn and discard the supplied instance — a service-level wrapper handed to the engine under a held name would indeed be dropped, and the engine already holds the raw instance from DatasourceConnectionService.connect() (packages/services/service-datasource/src/datasource-connection-service.ts:618). The engine's write path is a call-time property lookup on that shared instance (this.getDriver(object) then await driver.create(...), engine.ts :4536/:6659), so the own-property shadow intercepts objectql-mediated writes. Wrapping the instance is the only placement that intercepts. One misattribution: the driver.* discovery loop is in ObjectQLPlugin.start (packages/objectql/src/plugin.ts:469, loop at :628), not init() as the PR body and the new docblocks say — the engine's own docblock has it right. Substance unaffected; text should be corrected.
  • Non-throwing refusal premise holds.packages/core/src/hook-dispatch.ts header: boot path (kernel:ready/kernel:bootstrapped/kernel:listening) ⇒ dispatchHookPropagating — a throwing handler aborts the bootstrap, so a throwing refusal would turn "your plugin wrote during a dry run" into "no plan at all". A create() refusal echoing the caller's payload is success-shaped to the calling plugin — but the party the guarantee protects is the operator, who is told twice (stderr + notes), reads pass through honestly (a write-then-read sees no phantom row), and every alternative return either fabricates an identity or aborts the boot. Right call.
  • Scope claim holds.composeHostStack: true appears in exactly two non-test call sites: migrate/plan.ts:154 and migrate/apply.ts:181; the guard is composed only under that flag, and buildSchemaMigrationPlugins returns NOTHING_COMPOSED before createDeclarationBootWriteGuard() on the artifact-less config-less path. No bootSchemaStack caller is serve/dev/start. All three phases fire inside kernel.bootstrap() (:331, phases at :402/:414/:424), so the disarm placed after await runtime.start() covers the whole window and comes off before apply's flush.
  • The measurements are real. Re-ran the guard unit file + composition file at head: 35/35 pass. Re-ran the new integration block (-t '13332'): positive control (3 rows) and fix case (delta 0, log-only hooks ran, note carries Refused 6 write(s)) both pass.

What did not survive verification — the required changes

R1. The refusal-surface claim is false as stated, and I measured the hole. The PR: "Refused members are read off the contract, not off a survey… a driver's raw escape hatches — driver-sql's execute() and getKnex(). Off-contract for a plugin." That is wrong twice. execute(command, parameters?, options?) is a required member of the contract itselfpackages/spec/src/contracts/data-driver.ts:108, under the contract's own heading "Raw Execution (Escape Hatch)" — on every driver, not a driver-sql extension. (Also: the interface is IDataDriver; no IDataSourceDriver symbol exists in packages/spec.) Measured, with an in-run control, against the built head via bootSchemaStack (composeHostStack: true, tables pre-materialized, one host plugin whose kernel:ready hook issues both calls):

RESULT create-probe rows (guarded surface, control): 0 ← refused, reported
RESULT exec-probe rows (raw execute() INSERT): 1 ← LANDED
notes: "…row writes refused at the driver for the whole boot — a plan writes nothing."
"Refused 1 write(s) during the declaration boot … create() on sys_metadata."

The same armed guard, the same driver instance, the same hook: create() refused and reported; execute("INSERT INTO sys_metadata …") landed a row in the plan's target database silently, while the run printed "a plan writes nothing" and a refusal list that looks complete. That is precisely the enumerated-subset defect class this repo has on record: the guard reports green for the member it excluded. Required: during the boot window, an execute() call must not land silently — either refuse it (contract return is Promise<unknown>; if refusal is judged too risky because execute also serves reads and SQL cannot be classified reliably, say that reason) or forward it and report it in the same notes/stderr channel so the printed sentence stays true or is qualified. And the census text (module header, changeset) must stop calling execute off-contract.

R2. "DDL — already held back by deferSchemaDdl" is false for dropTable.setDeferredDdl (sql-driver.ts :10153) sets a flag that only the initObjects/syncSchema path checks (:9393/:9408). dropTable (:8726) runs assertSchemaMutable — which gates schemaMode/dialect (:5162), not deferral — then knex.schema.dropTableIfExists immediately; rotateShards is the same shape. A hook calling driver.dropTable(...) on a managed datasource during a plan boot executes, today and after this PR. The census line (module header + changeset) claims a mechanism that does not exist for those members; correct it, and either cover them or state them as genuinely open.

R3. One unstated residue worth naming in the census: the guard's scan covers "every driver.* instance the kernel publishes", but the engine also receives drivers that are never published as driver.*DatasourceConnectionService.connect() hands them to engine.registerDriver directly (:618), and the only driver.* registration repo-wide is the default driver (packages/runtime/src/default-datasource-plugin.ts:183). A host stack that connects a second datasource during a composed boot holds an engine-side driver the guard cannot see; objectql-mediated writes to objects bound to it would land. Same class as the stated boundaries — but the census's job, by its own words, is to make a deliberate boundary distinguishable from an oversight, so it belongs on the list.

R4. Text accuracy in the same pass:ObjectQLPlugin.start, not init() (docblocks in schema-migration-plugins.ts + changeset); IDataDriver, not IDataSourceDriver.

Changeset grade

minor on @objectstack/cli@17.2.0 is right given the ruling above: a host-visible behavioural narrowing that conforms to documented behaviour is not major, and patch would under-signal a delta a host can observe. The body's "What changed / Who this affects" is the correct shape and names the delta plainly — but it repeats the R1/R2 misstatements ("raw escape hatches (driver-sql's execute() …)", "DDL (already held back by deferSchemaDdl …)") and must be corrected with them.

Summary

Design (b) is the right design, the seam is the right seam and verifiably the only one that intercepts, the tests are real and their controls are the right controls, and the contract question resolves to conformance on both limbs — no accept/reject change, no measured surface widening. What must change before this leaves draft: R1 (cover or report execute(), and correct the "off-contract" claim), R2 (correct the dropTable/deferral claim, cover or state it), R3 (name the engine-held non-default-driver residue), R4 (attribution fixes), with the changeset corrected alongside. The operator-facing sentence this PR exists to make true — "a plan writes nothing" — must not print over a run in which a contract-member write landed silently; that is the same defect this PR was filed to close, one member over.


Generated by Claude Code

…execute() escape hatch, correct the census
R1: execute() is a REQUIRED member of IDataDriver (packages/spec/src/
contracts/data-driver.ts:108, 'Raw Execution (Escape Hatch)'), not a
driver-sql extension, and the guard did not touch it: in one guarded boot
create() was refused and reported while execute("INSERT ...") landed a
row silently under a printed 'a plan writes nothing'. Chosen: REPORT, not
cover — a raw command is unknown by contract ('SQL string, shell command,
or API payload') and SQL text cannot be classified read-vs-write reliably
(a SELECT can quote INSERT in a literal; a CTE can write), while the
framework's own boot work (ensureOverlayIndex's index DDL) runs through
this seam, so a refusal would either break legitimate calls or rest on a
guess. A boot-window execute() is now forwarded, counted per driver,
warned once per driver on stderr, and named in the composition notes —
and no note claims the plan wrote nothing on such a run: the flat claim
is an OUTCOME, printed by disarm() only when it held.
R2: 'DDL already held back by deferSchemaDdl' was false for dropTable /
rotateShards — they run assertSchemaMutable (a schemaMode/dialect gate,
not a deferral check) and execute immediately. The census now states the
split and names them a genuinely open boundary.
R3: drivers the engine holds for a non-default datasource are never
published as driver.* (the only registration is
default-datasource-plugin.ts) and are invisible to the guard's scan —
now stated in the census, module header and changeset.
R4: ObjectQLPlugin.start (plugin.ts:469/:628), not init(), walks the
driver.* services; the interface is IDataDriver, not IDataSourceDriver
(no such symbol exists in spec). Corrected in docblocks, tests and the
changeset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review response — R1–R4 addressed at 840f058ecb (session session_01UngCYXF98BVpYA9hfz6NYk, same branch). Both central facts were re-derived before acting, per the review's own instruction; nothing settled was re-opened.

Re-derivations (before any edit):

  • execute(command: unknown, parameters?, options?): Promise<unknown> confirmed a required member at packages/spec/src/contracts/data-driver.ts:108, under "Raw Execution (Escape Hatch)"; grep -rn IDataSourceDriver packages/spec/src/ exits 1 — no such symbol.
  • Reproduced the hole at 6a8a182b77 with the review's control shape (guarded bootSchemaStack, composeHostStack: true, tables pre-materialized, one hook issuing both calls): create() refused, 0 rows, reported — execute("INSERT INTO sys_metadata …")landed 1 row silently while the notes printed "a plan writes nothing".

R1 — chose REPORT, not cover, for the reason the review anticipated: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (no regex guessing), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). A boot-window execute() is now forwarded, counted per driver (rawExecutions on the guard, --json-visible), warned once per driver on stderr, and named in the composition notes — and the "a plan writes nothing" sentence is now an outcome owned by disarm(): printed when it held, dropped when a raw command went through. The compose-time note no longer pre-claims it. Proof, same control shape at head: create() 0 rows + execute 1 row as before, but notes now read Refused 4 write(s) during the declaration boot: (colon = dropped claim) + Raw execute() was called 1 time(s) during the declaration boot (1 via driver.com.objectstack.driver.sql) and FORWARDED, not refused: … — pinned in both the unit file and a new integration case. Mutation proof (direction written before the run): ablating the execute arm went RED on exactly the 3 predicted cases (14 GREEN), blob-hash mutate/restore verified, trap … EXIT INT TERM, one process.

R2 — the census and changeset no longer claim deferSchemaDdl holds DDL back wholesale. Stated now: the deferral covers the initObjects/syncSchema path (flushed on purpose by apply); dropTable/rotateShards run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately during a declaration boot. Named a genuinely open boundary in the module header, PR body and changeset; not covered here.

R3 — the engine-held non-default-datasource residue is named in the module header census, PR body and changeset: only the default datasource is ever published as driver.* (default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other datasources' drivers straight to engine.registerDriver, invisible to the scan. Likely coverable by arming instances at the registerDriver seam while the guard is armed — proposed as a follow-up, not widened into this PR; scope call is the PM's.

R4 —ObjectQLPlugin.start (loop at plugin.ts:628), not init(); IDataDriver, not IDataSourceDriver — corrected in the docblocks, the unit-test fixture comment, the PR body and the changeset. No occurrence remains in packages/cli/src or .changeset/.

Gates at 840f058ecb (branch merged with current origin/main first — the derivation flagged its gate-family files stale): union via dispatch-gates.mjs --commands, reconciled with comm both directions, exact strings: named 35, ran 35, unreconciled 0. 34 green; check-test-completeness NOT MEASURED (exit 3, PREREQUISITE NOT MET, per its own text). check:dual-build-cjs-loads / check:i18n-coverage / check:type-check-debt green after building the closures they named. Plus unnamed: pnpm lint exit 0, pnpm check:nul-bytes exit 0. CLI package: 224 files, 2551 passed + 15 skipped (two unrelated files hit beforeAll timeouts under full-suite load and pass 15/15 in isolation); typecheck exit 0.

PR remains draft; labels untouched — needs:contract-review stays until you clear it.

Generated by Claude Code


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

APPROVE

Follow-up at-tier contract review under CONTRACT_REVIEW_TIER (claude-fable-5), re-verifying R1–R4 at the new head 840f058ecb. Nothing below is carried forward from the 6a8a182b77 readings — the tree moved (merge of origin/main), so every fact was re-derived on a fresh detached worktree, with the same probe re-run against a fresh build of the head (dist currency confirmed by content grep before trusting it: the new guard strings are present in dist/utils/schema-migration-plugins.js).

1. Report over cover — the right call, on premises that held

All three stated premises re-derived, plus one the fix did not claim:

  • execute(command: unknown, …) is the contract's own opaque member — "SQL string, shell command, or API payload" (packages/spec/src/contracts/data-driver.ts, "Raw Execution (Escape Hatch)"). There is no sound classifier over that input; any bounded cover either refuses allexecute() (breaking legitimate raw reads) or classifies SQL text (a guess, and unreliable in both directions).
  • The framework-traffic premise is real: packages/metadata-protocol/src/protocol.ts:5210 (ensureOverlayIndex) issues its index DDL via (driver as any).execute(sql) — a wholesale refusal would break the framework's own seam users or force an unprincipled caller allowlist.
  • The premise the commit didn't state but which seals it: execute() has no contract-shaped refusal value. Its return is unknown; a fabricated return silently corrupts raw-read callers — strictly worse than the row-write case, where honest no-op shapes exist. So a non-throwing refusal is not even constructible here.

I asked for cover-or-report in R1 and offered report as an acceptable shape; report is not just acceptable — for this member it is the only shape that neither guesses nor breaks. No bounded cover exists without one of those two defects.

2. The disarm()-owned outcome — closes it, does not relocate it (with one stated residue)

Measured behaviourally, both directions, at the new head:

  • Raw traffic present (my original probe, re-run against the built head): create() refused (0 rows, in-run control), execute("INSERT …") forwarded (1 row lands — now the documented behaviour), stderr carries the per-driver warning, and the notes read Refused 1 write(s) during the declaration boot: create() on sys_metadata … — flat claim dropped — followed by Raw execute() was called 1 time(s) … whether this boot wrote is NOT verified, and this run does NOT claim to have written nothing. No note in the run contains "a plan writes nothing". The compose-time note now states mechanism, not outcome ("with the contract's row writes refused at the driver for the whole boot") — the pre-claim is gone.
  • Raw traffic absent: the integration fix case pins Refused 6 write(s) during the declaration boot — a plan writes nothing (3/3 pass), and the unit disarm case pins the claim printed only when it held (36/36 pass).

So the sentence is no longer an assertion printed over an unverified run; it is a measurement over everything the guard can observe. The honest residue, for the record: the claim can still print on a run where a write escaped through a census-stated open boundary — dropTable/rotateShards (verified again: assertSchemaMutable gates schemaMode/dialect, not deferral), a getKnex() handle, or an engine-held second-datasource driver. For getKnex() handles and second drivers that is irreducible without the follow-up seam. For dropTable/rotateShards it is not: those members are unambiguous writes with no classification problem, and the same forward-and-count treatment execute() got would extend the outcome's honesty to them cheaply. Non-blocking — R2 offered "state as open" and that offer stands — but it belongs on the follow-up's scope.

3. R3 residue — stated accurately, and deferral is acceptable

The census/changeset wording matches what I measured, item for item: only the default datasource is ever published as driver.* (re-grepped repo-wide at 840f058ecb: the single registration is packages/runtime/src/default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other drivers straight to engine.registerDriver (now datasource-connection-service.ts:635 after the main merge — same mechanism); consequence stated correctly ("objectql-mediated writes to objects bound to it would land"). The proposed fix seam (arm at engine.registerDriver while the guard is armed) is the right one. Leaving it unimplemented is acceptable for merge: the shape is an edge beyond every measured instance of this defect, and it is stated where a reader of the mechanism will look. It should exist as a tracked issue before this merges, not only as a sentence in a PR body — filing it is the PM's, and the dropTable/rotateShards counting above should ride the same card.

4. R4 zero — re-derived, holds, with controls

  • grep -rn "IDataSourceDriver" packages/cli/src .changeset/ → exit 1 (zero). Repo-wide over all packages/**/*.ts, exit code taken from grep itself (not a pipe tail): 0 matches, exit 1. Positive control: IDataDriver present (4× in the guard module, 2× in the changeset) — the zero is not a tool failure.
  • grep -rn "ObjectQLPlugin\.init\|ObjectQLPlugin's init" packages/cli/src .changeset/ → exit 1 (zero). Control: ObjectQLPlugin.start present 3× in the module, and I re-confirmed the fact itself at the new head (packages/objectql/src/plugin.tsstart = async at :469, discovery loop at :628).

Also re-verified rather than assumed after the merge: the seam facts stand (engine registerDriver discard branch, engine write path as call-time lookup); disarm() restores the execute forwarder too (own descriptor present while armed, gone after, prototype identity preserved — pinned at test :405/:415); and the public-surface zero still holds structurally — the PR's 6-file surface touches neither packages/cli/package.json nor src/index.ts/utils/console.ts (git diff origin/main...840f058ecb on those paths: empty), so the new ForwardedRawExecution/rawExecutions symbols live in the same module the exports map does not reach.

Ruling

The clause-② ruling from the first review stands unchanged at this head: conformance, not a contract accept/reject change; no public-surface widening. All four required items are addressed and verified. needs:contract-review clears — my explicit word, for both carriers (label removal is the PM's write, not mine). The PR stays draft per the PM's parking; this approval is contract-review clearance, not a merge instruction. One expectation attached, non-blocking: the R3 follow-up (engine-seam arming, plus dropTable/rotateShards counting) becomes a tracked issue before merge.


Generated by Claude Code

Merged via the queue into main with commit bf3bbf1Sep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-13332-declaration-boot-write-suppression branch September 1, 2026 06:47
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-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone - #14053

Merged
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression
Sep 1, 2026
Merged

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone#14053
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13332

Design decision (b) — suppress writes at the driver seam for the declaration boot, so read/log hooks still run — as decided by the domain:cli PM seat in the claim comment. It was implementable as stated; the named fallback (report a seam census showing no single driver seam exists) was not taken.

Contract-review response (2026-09-01): the at-tier review's four required items are addressed at 840f058ecb — R1 (the contract's execute() escape hatch is now reported, and no run with a forwarded raw command claims it wrote nothing), R2 (the dropTable/deferral claim corrected — it is a genuinely open boundary, stated), R3 (the engine-held non-default-datasource driver residue named in the census and changeset), R4 (attributions corrected: ObjectQLPlugin.start, IDataDriver). Details inline below and in the review-response comment.

The defect

composeForDeclarations documented the plan path's guarantee in its own words — "(init runs, start does not — a plan writes nothing)" — and implemented it as a Proxy whose only override is start. packages/core/src/kernel.ts then fires three phases unconditionally after the suppressed start pass:

:402 Phase 3 trigger('kernel:ready')
:404 Phase 3.5 trigger('kernel:bootstrapped')
:416 Phase 4 trigger('kernel:listening')

A writing hook registered from init() survives the suppression on all three. The guarantee was therefore a property of plugins that happen to seed from start() — the shape of the one plugin that had been measured — and not a property of the plan path. packages/core/src/kernel.ts is untouched by this PR: the unconditional firing is the condition, not the fix site.

The seam, and why this one

The driver instance, guarded for the length of the kernel bootstrap.

  • Not the driver.* service entry.ObjectQLPlugin.start() walks the kernel's driver.* services (packages/objectql/src/plugin.ts — the discovery loop lives in start, not init) and hands each to the engine, which keys its registry by driver.name and discards a second instance under a name it already holds (packages/objectql/src/engine.ts, registerDriver). A wrapper registered in place of the service would be refused by the engine, and every objectql-mediated write would go straight to the raw driver. The instance is shared and the engine's write path is a call-time property lookup on it, so guarding the object itself covers both the plugin that resolves driver.* directly and the engine that writes through it.
  • Not a list of phase names. This card went from one phase to three before a line was written; a fourth would silently re-open the hole. The guard is phase-agnostic — one choke point, covered on the day a phase ships.
  • Refused members are read off the contract, not off a survey. The row-write surface of IDataDriver (packages/spec/src/contracts/data-driver.ts): create, update, upsert, delete, bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany. Adding a write to that interface is a spec diff every driver has to implement, so the list goes stale loudly rather than silently.
  • The contract's raw-execution escape hatch is REPORTED, not refused.execute() is a required member of IDataDriver (data-driver.ts:108, under the contract's own "Raw Execution (Escape Hatch)" heading) — on every driver, not a driver-sql extension. It is not refused, for a stated reason: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (a SELECT can quote the word INSERT in a literal; a CTE can write), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). What must never happen is the silent half: a boot-window execute() is forwarded, counted per driver, warned once per driver on stderr, and named in the composition notes — and on such a run no note claims the plan wrote nothing. The "a plan writes nothing" sentence is an outcome, printed by disarm() only on runs where it held.
  • A refusal does not throw.context.trigger() dispatches boot hooks propagating (packages/core/src/hook-dispatch.ts), so a throwing refusal would abort the bootstrap — turning "your plugin wrote during a dry run" into "you cannot get a plan at all", on the command whose whole job is to be read before a production apply. A refused call returns a contract-shaped value (create/upsert echo the caller's own payload rather than inventing an id; delete returns the contract's not-found false; updateMany/deleteMany return 0), and the run says so out loud: one warning on stderr per driver/method/object triple, plus a line in the composition notes the plan prints and --json carries.

The guard is armed by a plugin composed first in buildSchemaMigrationPlugins, so its init() is ordered ahead of every host plugin's (resolvePluginOrder is a DFS in registration order), and it re-scans in Phase 2 to pick up a driver registered by a later init(). bootSchemaStack disarms it the moment the bootstrap returns — everything after that is work the command was asked for, so os migrate apply's confirmed DDL flush and the coverage pass are untouched.

Seam census — what is NOT covered, stated rather than hidden

Every one of these is named in the module header too, so a future reader can tell a deliberate boundary from an oversight:

  • execute() — reported, never silent (see above): forwarded during the boot window, counted per driver, warned on stderr, named in the notes, and the "writes nothing" claim dropped for that run. getKnex() (a driver-sql extension, genuinely off-contract) is not intercepted; it was not the path any measured instance of this defect took.
  • DDL — and its members split.deferSchemaDdl holds back the initObjects/syncSchema path, which apply flushes on purpose once the operator confirms — guarding that would refuse the one write these commands exist to make. dropTable (and driver-sql's rotateShards) are NOT held back by that deferral: they run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately, so a hook calling driver.dropTable(...) on a managed datasource during a declaration boot executes, today as before this guard. A genuinely open boundary, stated.
  • Engine-held drivers for non-default datasources. The guard's scan covers the driver.* services the kernel publishes, and the only such registration repo-wide is the default datasource's (packages/runtime/src/default-datasource-plugin.ts). DatasourceConnectionService.connect() hands every other datasource's driver straight to engine.registerDriver, never through driver.* — so a host stack that connects a second datasource during a composed boot holds an engine-side driver this guard cannot see, and objectql-mediated writes to objects bound to it would land. Likely coverable by also arming instances at the engine.registerDriver seam while the guard is armed; proposed as a follow-up rather than widened into this PR — scope growth on a p1 is the PM's call.
  • writes a plugin makes outside the database entirely (filesystem, network).
  • work a hook defers past the end of the bootstrap; the guard covers the boot window.

Verification

The decisive test, with its positive controlpackages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts, booting a real ObjectKernel with a recording driver whose write methods live on the prototype, exactly like a real driver's:

  • positive control: the fixture plugin, composed the way a served boot composes it, writes on all three phases (create:sys_permission_set from start(), then create:sys_ai_model on ready, bootstrapped and listening);
  • the defect: composeForDeclarations alone removes only the start() seed — all three init()-registered hooks still write. Pinned, not described;
  • the fix: zero writes reach the driver, and the log-only hooks still ran — the recorded order is log-only:kernel:ready, write:kernel:ready, …, i.e. every handler executed and only the write was refused;
  • the escape hatch (R1): a guarded boot in which one hook issues both a contract create() (refused — the in-run control) and a raw execute("INSERT …"): the raw command is forwarded (the real driver ran it and its return value came back), counted (rawExecutions = 1 via that driver), and reported — and the disarm note drops the flat claim: expect(note).not.toContain('a plan writes nothing'). The refusal-only case pins the converse: with no raw command forwarded, the claim held and is printed;
  • plus: the whole row-write contract is refused (not just create()), a refused call hands back a contract-shaped value instead of throwing, and disarm() removes the shadowing own properties (the execute forwarder included) rather than overwriting them.

End to end against a real SQL driverpackages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, on a sqlite database whose tables already exist:

  • positive control: the same plugin on a boot with no host composition lands 3 rows;
  • the fix: a boot through bootSchemaStack with the writer coming from objectstack.config.tsand one handed straight to the kernel adds 0 rows (measured as a delta against the control's 3), every log-only hook ran, and composition.notes carries Refused 6 write(s) during the declaration boot — a plan writes nothing (no raw command that run — the claim held);
  • the review's own control shape (R1): in one guarded boot, create() refused (0 rows) while execute("INSERT …")landed 1 row — forwarded on purpose, and no longer silent: the notes carry Refused 4 write(s) during the declaration boot: (the colon marks the dropped claim), Raw execute() was called 1 time(s) during the declaration boot, and no note in the run claims the plan wrote nothing.

Ablations — direction stated in writing before each run.

Original guard ablation (measured at 6a8a182b77): drop the guard from the composed plugin list ⇒ predicted RED on the integration fix case and the two composition-ordering assertions, guard unit file GREEN; observed exactly that (expected 9 to be 3 — six rows the operator never asked for). Mutate/restore/measure in one process under trap … EXIT INT TERM, blob-hash-verified.

R1 reporting ablation (measured at 840f058ecb): remove the execute arm (DRIVER_RAW_EXECUTION_METHODS.slice(0, 0); anchor occurs exactly once) ⇒ predicted RED on the unit escape-hatch case, the disarm restore case (it asserts the execute shadow exists), and the integration R1 case (the unqualified claim returns); predicted GREEN elsewhere, the raw row still landing. Observed exactly that:

worktree before: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
occurrences BEFORE — original: 1, mutant: 0
worktree after : 11c2699deedbe358087e99e3dc91a95d8306e639
occurrences AFTER — original: 0, mutant: 1
Tests 3 failed | 14 passed (17) ← exactly the three predicted cases
worktree restored: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
git diff HEAD: empty · git status --porcelain: empty

Gates — union derived on the merged tree at 840f058ecb with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, reconciled with comm -23/comm -13 in both directions, exact string comparison: named 35, ran 35, unreconciled 0. Plus pnpm lint (repo-wide, eslint . --no-inline-config, exit 0 — no narrowing) and pnpm check:nul-bytes (exit 0), neither of which the derivation names.

34 of the 35 are green. node scripts/check-test-completeness.mjs is NOT MEASURED (exit 3, PREREQUISITE NOT MET) — it grades a saved turbo run test log, does not run tests, and its own text instructs a local family run to record it as NOT MEASURED. Three others first reported PREREQUISITE NOT MET for missing build output and are green after building the closures they named: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt (exit codes captured before any pipe, per gate log).

Tests at 840f058ecb: the guard unit + composition files 36/36; the #13332 integration block 3/3; the whole @objectstack/cli package 224 files / 2551 passed + 15 skipped, with two unrelated files (test/init-created-files-summary.e2e.test.ts, src/commands/datasource/envelope-unwrap.test.ts) timing out their beforeAll hooks under load during the full parallel run and passing 15/15 when re-run in isolation; pnpm --filter @objectstack/cli typecheck exit 0.

Contract review requested

Changing what a declaration boot permits is likely a contract change, and this PR does not decide that. The at-tier review (2026-09-01) ruled clause ② no on both limbs — conformance, not a contract change; no measured public-surface widening — and its four required items are addressed above.

What changed, and for whom. For os migrate plan and os migrate apply: during the kernel bootstrap, a row write issued through any driver.* instance the kernel publishes is now refused and reported instead of executed, and a raw execute() call through such an instance is forwarded but reported — with the run's notes declining to claim the plan wrote nothing. A host whose plugins wrote during that window will see contract writes stop landing and raw commands named; a host that did neither sees no change whatsoever: no disarm note is emitted when nothing was refused and no raw command went through, and the artifact-less, config-less run is untouched (that path returns before the guard is composed). Nothing outside these two commands is affected — os serve, os dev and os start do not boot through bootSchemaStack.

The changeset is minor on @objectstack/cli.

Generated by Claude Code

@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/automation/webhooks.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/kernel/http-protocol.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/knowledge.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

  • content/docs/releases/implementation-status.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v16.mdx(via updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v17.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

What this run could not see
  • 6 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 — 23 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 c75962ac3e0af69ae20f658618c4d3ab58192cc8packageMentionDocs.

Which tree this was computed on

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

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

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

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

REQUEST CHANGES

At-tier contract review under CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs on origin/main @ dda969cd71: export const CONTRACT_REVIEW_TIER = 'claude-fable-5' — this reviewer is that tier). Everything below was re-derived or re-measured on a detached worktree at head 6a8a182b77, not taken from the PR's text.

The ruling this PR routed here (both limbs, judged separately)

1. Accept/reject change — NO, this is conformance, and the PM's Clause-②: no stands. The guarantee was already shipped in three registers before this PR: the module's own printed sentence ("a plan writes nothing"), the operator docs (content/docs/deployment/cli.mdx:671 — "os migrate plan | Warns and continues — a plan writes nothing either way"), and plan.ts's own comments (:53, :141). Nothing is newly accepted or rejected at any input boundary — every host config and plugin that composed before still composes; what changed is that an effect documented not to happen now does not happen. On the "nobody could have depended on it" tension: the evidence runs the other way and it is measured, not asserted — the one known downstream host that wrote during this window (cloud, ensure-default-ai-model-plugin) treated those writes as a defect in itself (cloud#1744) and moved them out (cloud PR #1749). The affected population's own revealed dependence was on the documented guarantee, not the accidental behaviour. A host depending on writes landing during a plan/apply boot was depending on a documented untruth. No gate breach by the PM (the opus dispatch was in-band); routing the question here was still correct, because the delta is real and downstream.

2. Public-surface widening — NO, measured with a control. Built @objectstack/cli and its dependency closure twice (pnpm exec turbo run build --filter='@objectstack/cli...') at head 6a8a182b77 and base ada3834add. The package's exports map exposes exactly two type surfaces: .dist/index.d.ts and ./consoledist/utils/console.d.ts. Both are byte-identical across the two builds (cmp). The only PR-attributable .d.ts delta is dist/utils/schema-migration-plugins.d.ts (~100 changed lines: createDeclarationBootWriteGuard, DeclarationBootWriteGuard, RefusedDeclarationWrite, SchemaMigrationComposition.writeGuard?), which is not reachable through the exports map. Positive control: 5 .js files differ, including schema-migrate.js and schema-migration-plugins.js — the instrument sees this PR's change, so the entry-point zero is a real zero. (The extract-hook-body/lower-callables/lint dist diffs are from main commits the head merge pulled in — git diff ada3834a...6a8a182b -- packages/cli/src/utils/extract-hook-body.ts is empty.)

So on the contract question: clause ② does not apply — the guarantee was already the contract. The REQUEST CHANGES is not about the ruling; it is about claims the PR rests on that did not survive verification.

Verified TRUE (independently)

  • Seam claim holds.packages/objectql/src/engine.tsregisterDriver (~:5172): if (existing !== driver) → warn and discard the supplied instance — a service-level wrapper handed to the engine under a held name would indeed be dropped, and the engine already holds the raw instance from DatasourceConnectionService.connect() (packages/services/service-datasource/src/datasource-connection-service.ts:618). The engine's write path is a call-time property lookup on that shared instance (this.getDriver(object) then await driver.create(...), engine.ts :4536/:6659), so the own-property shadow intercepts objectql-mediated writes. Wrapping the instance is the only placement that intercepts. One misattribution: the driver.* discovery loop is in ObjectQLPlugin.start (packages/objectql/src/plugin.ts:469, loop at :628), not init() as the PR body and the new docblocks say — the engine's own docblock has it right. Substance unaffected; text should be corrected.
  • Non-throwing refusal premise holds.packages/core/src/hook-dispatch.ts header: boot path (kernel:ready/kernel:bootstrapped/kernel:listening) ⇒ dispatchHookPropagating — a throwing handler aborts the bootstrap, so a throwing refusal would turn "your plugin wrote during a dry run" into "no plan at all". A create() refusal echoing the caller's payload is success-shaped to the calling plugin — but the party the guarantee protects is the operator, who is told twice (stderr + notes), reads pass through honestly (a write-then-read sees no phantom row), and every alternative return either fabricates an identity or aborts the boot. Right call.
  • Scope claim holds.composeHostStack: true appears in exactly two non-test call sites: migrate/plan.ts:154 and migrate/apply.ts:181; the guard is composed only under that flag, and buildSchemaMigrationPlugins returns NOTHING_COMPOSED before createDeclarationBootWriteGuard() on the artifact-less config-less path. No bootSchemaStack caller is serve/dev/start. All three phases fire inside kernel.bootstrap() (:331, phases at :402/:414/:424), so the disarm placed after await runtime.start() covers the whole window and comes off before apply's flush.
  • The measurements are real. Re-ran the guard unit file + composition file at head: 35/35 pass. Re-ran the new integration block (-t '13332'): positive control (3 rows) and fix case (delta 0, log-only hooks ran, note carries Refused 6 write(s)) both pass.

What did not survive verification — the required changes

R1. The refusal-surface claim is false as stated, and I measured the hole. The PR: "Refused members are read off the contract, not off a survey… a driver's raw escape hatches — driver-sql's execute() and getKnex(). Off-contract for a plugin." That is wrong twice. execute(command, parameters?, options?) is a required member of the contract itselfpackages/spec/src/contracts/data-driver.ts:108, under the contract's own heading "Raw Execution (Escape Hatch)" — on every driver, not a driver-sql extension. (Also: the interface is IDataDriver; no IDataSourceDriver symbol exists in packages/spec.) Measured, with an in-run control, against the built head via bootSchemaStack (composeHostStack: true, tables pre-materialized, one host plugin whose kernel:ready hook issues both calls):

RESULT create-probe rows (guarded surface, control): 0 ← refused, reported
RESULT exec-probe rows (raw execute() INSERT): 1 ← LANDED
notes: "…row writes refused at the driver for the whole boot — a plan writes nothing."
"Refused 1 write(s) during the declaration boot … create() on sys_metadata."

The same armed guard, the same driver instance, the same hook: create() refused and reported; execute("INSERT INTO sys_metadata …") landed a row in the plan's target database silently, while the run printed "a plan writes nothing" and a refusal list that looks complete. That is precisely the enumerated-subset defect class this repo has on record: the guard reports green for the member it excluded. Required: during the boot window, an execute() call must not land silently — either refuse it (contract return is Promise<unknown>; if refusal is judged too risky because execute also serves reads and SQL cannot be classified reliably, say that reason) or forward it and report it in the same notes/stderr channel so the printed sentence stays true or is qualified. And the census text (module header, changeset) must stop calling execute off-contract.

R2. "DDL — already held back by deferSchemaDdl" is false for dropTable.setDeferredDdl (sql-driver.ts :10153) sets a flag that only the initObjects/syncSchema path checks (:9393/:9408). dropTable (:8726) runs assertSchemaMutable — which gates schemaMode/dialect (:5162), not deferral — then knex.schema.dropTableIfExists immediately; rotateShards is the same shape. A hook calling driver.dropTable(...) on a managed datasource during a plan boot executes, today and after this PR. The census line (module header + changeset) claims a mechanism that does not exist for those members; correct it, and either cover them or state them as genuinely open.

R3. One unstated residue worth naming in the census: the guard's scan covers "every driver.* instance the kernel publishes", but the engine also receives drivers that are never published as driver.*DatasourceConnectionService.connect() hands them to engine.registerDriver directly (:618), and the only driver.* registration repo-wide is the default driver (packages/runtime/src/default-datasource-plugin.ts:183). A host stack that connects a second datasource during a composed boot holds an engine-side driver the guard cannot see; objectql-mediated writes to objects bound to it would land. Same class as the stated boundaries — but the census's job, by its own words, is to make a deliberate boundary distinguishable from an oversight, so it belongs on the list.

R4. Text accuracy in the same pass:ObjectQLPlugin.start, not init() (docblocks in schema-migration-plugins.ts + changeset); IDataDriver, not IDataSourceDriver.

Changeset grade

minor on @objectstack/cli@17.2.0 is right given the ruling above: a host-visible behavioural narrowing that conforms to documented behaviour is not major, and patch would under-signal a delta a host can observe. The body's "What changed / Who this affects" is the correct shape and names the delta plainly — but it repeats the R1/R2 misstatements ("raw escape hatches (driver-sql's execute() …)", "DDL (already held back by deferSchemaDdl …)") and must be corrected with them.

Summary

Design (b) is the right design, the seam is the right seam and verifiably the only one that intercepts, the tests are real and their controls are the right controls, and the contract question resolves to conformance on both limbs — no accept/reject change, no measured surface widening. What must change before this leaves draft: R1 (cover or report execute(), and correct the "off-contract" claim), R2 (correct the dropTable/deferral claim, cover or state it), R3 (name the engine-held non-default-driver residue), R4 (attribution fixes), with the changeset corrected alongside. The operator-facing sentence this PR exists to make true — "a plan writes nothing" — must not print over a run in which a contract-member write landed silently; that is the same defect this PR was filed to close, one member over.


Generated by Claude Code

…execute() escape hatch, correct the census
R1: execute() is a REQUIRED member of IDataDriver (packages/spec/src/
contracts/data-driver.ts:108, 'Raw Execution (Escape Hatch)'), not a
driver-sql extension, and the guard did not touch it: in one guarded boot
create() was refused and reported while execute("INSERT ...") landed a
row silently under a printed 'a plan writes nothing'. Chosen: REPORT, not
cover — a raw command is unknown by contract ('SQL string, shell command,
or API payload') and SQL text cannot be classified read-vs-write reliably
(a SELECT can quote INSERT in a literal; a CTE can write), while the
framework's own boot work (ensureOverlayIndex's index DDL) runs through
this seam, so a refusal would either break legitimate calls or rest on a
guess. A boot-window execute() is now forwarded, counted per driver,
warned once per driver on stderr, and named in the composition notes —
and no note claims the plan wrote nothing on such a run: the flat claim
is an OUTCOME, printed by disarm() only when it held.
R2: 'DDL already held back by deferSchemaDdl' was false for dropTable /
rotateShards — they run assertSchemaMutable (a schemaMode/dialect gate,
not a deferral check) and execute immediately. The census now states the
split and names them a genuinely open boundary.
R3: drivers the engine holds for a non-default datasource are never
published as driver.* (the only registration is
default-datasource-plugin.ts) and are invisible to the guard's scan —
now stated in the census, module header and changeset.
R4: ObjectQLPlugin.start (plugin.ts:469/:628), not init(), walks the
driver.* services; the interface is IDataDriver, not IDataSourceDriver
(no such symbol exists in spec). Corrected in docblocks, tests and the
changeset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review response — R1–R4 addressed at 840f058ecb (session session_01UngCYXF98BVpYA9hfz6NYk, same branch). Both central facts were re-derived before acting, per the review's own instruction; nothing settled was re-opened.

Re-derivations (before any edit):

  • execute(command: unknown, parameters?, options?): Promise<unknown> confirmed a required member at packages/spec/src/contracts/data-driver.ts:108, under "Raw Execution (Escape Hatch)"; grep -rn IDataSourceDriver packages/spec/src/ exits 1 — no such symbol.
  • Reproduced the hole at 6a8a182b77 with the review's control shape (guarded bootSchemaStack, composeHostStack: true, tables pre-materialized, one hook issuing both calls): create() refused, 0 rows, reported — execute("INSERT INTO sys_metadata …")landed 1 row silently while the notes printed "a plan writes nothing".

R1 — chose REPORT, not cover, for the reason the review anticipated: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (no regex guessing), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). A boot-window execute() is now forwarded, counted per driver (rawExecutions on the guard, --json-visible), warned once per driver on stderr, and named in the composition notes — and the "a plan writes nothing" sentence is now an outcome owned by disarm(): printed when it held, dropped when a raw command went through. The compose-time note no longer pre-claims it. Proof, same control shape at head: create() 0 rows + execute 1 row as before, but notes now read Refused 4 write(s) during the declaration boot: (colon = dropped claim) + Raw execute() was called 1 time(s) during the declaration boot (1 via driver.com.objectstack.driver.sql) and FORWARDED, not refused: … — pinned in both the unit file and a new integration case. Mutation proof (direction written before the run): ablating the execute arm went RED on exactly the 3 predicted cases (14 GREEN), blob-hash mutate/restore verified, trap … EXIT INT TERM, one process.

R2 — the census and changeset no longer claim deferSchemaDdl holds DDL back wholesale. Stated now: the deferral covers the initObjects/syncSchema path (flushed on purpose by apply); dropTable/rotateShards run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately during a declaration boot. Named a genuinely open boundary in the module header, PR body and changeset; not covered here.

R3 — the engine-held non-default-datasource residue is named in the module header census, PR body and changeset: only the default datasource is ever published as driver.* (default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other datasources' drivers straight to engine.registerDriver, invisible to the scan. Likely coverable by arming instances at the registerDriver seam while the guard is armed — proposed as a follow-up, not widened into this PR; scope call is the PM's.

R4 —ObjectQLPlugin.start (loop at plugin.ts:628), not init(); IDataDriver, not IDataSourceDriver — corrected in the docblocks, the unit-test fixture comment, the PR body and the changeset. No occurrence remains in packages/cli/src or .changeset/.

Gates at 840f058ecb (branch merged with current origin/main first — the derivation flagged its gate-family files stale): union via dispatch-gates.mjs --commands, reconciled with comm both directions, exact strings: named 35, ran 35, unreconciled 0. 34 green; check-test-completeness NOT MEASURED (exit 3, PREREQUISITE NOT MET, per its own text). check:dual-build-cjs-loads / check:i18n-coverage / check:type-check-debt green after building the closures they named. Plus unnamed: pnpm lint exit 0, pnpm check:nul-bytes exit 0. CLI package: 224 files, 2551 passed + 15 skipped (two unrelated files hit beforeAll timeouts under full-suite load and pass 15/15 in isolation); typecheck exit 0.

PR remains draft; labels untouched — needs:contract-review stays until you clear it.

Generated by Claude Code


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

APPROVE

Follow-up at-tier contract review under CONTRACT_REVIEW_TIER (claude-fable-5), re-verifying R1–R4 at the new head 840f058ecb. Nothing below is carried forward from the 6a8a182b77 readings — the tree moved (merge of origin/main), so every fact was re-derived on a fresh detached worktree, with the same probe re-run against a fresh build of the head (dist currency confirmed by content grep before trusting it: the new guard strings are present in dist/utils/schema-migration-plugins.js).

1. Report over cover — the right call, on premises that held

All three stated premises re-derived, plus one the fix did not claim:

  • execute(command: unknown, …) is the contract's own opaque member — "SQL string, shell command, or API payload" (packages/spec/src/contracts/data-driver.ts, "Raw Execution (Escape Hatch)"). There is no sound classifier over that input; any bounded cover either refuses allexecute() (breaking legitimate raw reads) or classifies SQL text (a guess, and unreliable in both directions).
  • The framework-traffic premise is real: packages/metadata-protocol/src/protocol.ts:5210 (ensureOverlayIndex) issues its index DDL via (driver as any).execute(sql) — a wholesale refusal would break the framework's own seam users or force an unprincipled caller allowlist.
  • The premise the commit didn't state but which seals it: execute() has no contract-shaped refusal value. Its return is unknown; a fabricated return silently corrupts raw-read callers — strictly worse than the row-write case, where honest no-op shapes exist. So a non-throwing refusal is not even constructible here.

I asked for cover-or-report in R1 and offered report as an acceptable shape; report is not just acceptable — for this member it is the only shape that neither guesses nor breaks. No bounded cover exists without one of those two defects.

2. The disarm()-owned outcome — closes it, does not relocate it (with one stated residue)

Measured behaviourally, both directions, at the new head:

  • Raw traffic present (my original probe, re-run against the built head): create() refused (0 rows, in-run control), execute("INSERT …") forwarded (1 row lands — now the documented behaviour), stderr carries the per-driver warning, and the notes read Refused 1 write(s) during the declaration boot: create() on sys_metadata … — flat claim dropped — followed by Raw execute() was called 1 time(s) … whether this boot wrote is NOT verified, and this run does NOT claim to have written nothing. No note in the run contains "a plan writes nothing". The compose-time note now states mechanism, not outcome ("with the contract's row writes refused at the driver for the whole boot") — the pre-claim is gone.
  • Raw traffic absent: the integration fix case pins Refused 6 write(s) during the declaration boot — a plan writes nothing (3/3 pass), and the unit disarm case pins the claim printed only when it held (36/36 pass).

So the sentence is no longer an assertion printed over an unverified run; it is a measurement over everything the guard can observe. The honest residue, for the record: the claim can still print on a run where a write escaped through a census-stated open boundary — dropTable/rotateShards (verified again: assertSchemaMutable gates schemaMode/dialect, not deferral), a getKnex() handle, or an engine-held second-datasource driver. For getKnex() handles and second drivers that is irreducible without the follow-up seam. For dropTable/rotateShards it is not: those members are unambiguous writes with no classification problem, and the same forward-and-count treatment execute() got would extend the outcome's honesty to them cheaply. Non-blocking — R2 offered "state as open" and that offer stands — but it belongs on the follow-up's scope.

3. R3 residue — stated accurately, and deferral is acceptable

The census/changeset wording matches what I measured, item for item: only the default datasource is ever published as driver.* (re-grepped repo-wide at 840f058ecb: the single registration is packages/runtime/src/default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other drivers straight to engine.registerDriver (now datasource-connection-service.ts:635 after the main merge — same mechanism); consequence stated correctly ("objectql-mediated writes to objects bound to it would land"). The proposed fix seam (arm at engine.registerDriver while the guard is armed) is the right one. Leaving it unimplemented is acceptable for merge: the shape is an edge beyond every measured instance of this defect, and it is stated where a reader of the mechanism will look. It should exist as a tracked issue before this merges, not only as a sentence in a PR body — filing it is the PM's, and the dropTable/rotateShards counting above should ride the same card.

4. R4 zero — re-derived, holds, with controls

  • grep -rn "IDataSourceDriver" packages/cli/src .changeset/ → exit 1 (zero). Repo-wide over all packages/**/*.ts, exit code taken from grep itself (not a pipe tail): 0 matches, exit 1. Positive control: IDataDriver present (4× in the guard module, 2× in the changeset) — the zero is not a tool failure.
  • grep -rn "ObjectQLPlugin\.init\|ObjectQLPlugin's init" packages/cli/src .changeset/ → exit 1 (zero). Control: ObjectQLPlugin.start present 3× in the module, and I re-confirmed the fact itself at the new head (packages/objectql/src/plugin.tsstart = async at :469, discovery loop at :628).

Also re-verified rather than assumed after the merge: the seam facts stand (engine registerDriver discard branch, engine write path as call-time lookup); disarm() restores the execute forwarder too (own descriptor present while armed, gone after, prototype identity preserved — pinned at test :405/:415); and the public-surface zero still holds structurally — the PR's 6-file surface touches neither packages/cli/package.json nor src/index.ts/utils/console.ts (git diff origin/main...840f058ecb on those paths: empty), so the new ForwardedRawExecution/rawExecutions symbols live in the same module the exports map does not reach.

Ruling

The clause-② ruling from the first review stands unchanged at this head: conformance, not a contract accept/reject change; no public-surface widening. All four required items are addressed and verified. needs:contract-review clears — my explicit word, for both carriers (label removal is the PM's write, not mine). The PR stays draft per the PM's parking; this approval is contract-review clearance, not a merge instruction. One expectation attached, non-blocking: the R3 follow-up (engine-seam arming, plus dropTable/rotateShards counting) becomes a tracked issue before merge.


Generated by Claude Code

Merged via the queue into main with commit bf3bbf1Sep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-13332-declaration-boot-write-suppression branch September 1, 2026 06:47
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-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone - #14053

Merged
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression
Sep 1, 2026
Merged

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone#14053
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13332

Design decision (b) — suppress writes at the driver seam for the declaration boot, so read/log hooks still run — as decided by the domain:cli PM seat in the claim comment. It was implementable as stated; the named fallback (report a seam census showing no single driver seam exists) was not taken.

Contract-review response (2026-09-01): the at-tier review's four required items are addressed at 840f058ecb — R1 (the contract's execute() escape hatch is now reported, and no run with a forwarded raw command claims it wrote nothing), R2 (the dropTable/deferral claim corrected — it is a genuinely open boundary, stated), R3 (the engine-held non-default-datasource driver residue named in the census and changeset), R4 (attributions corrected: ObjectQLPlugin.start, IDataDriver). Details inline below and in the review-response comment.

The defect

composeForDeclarations documented the plan path's guarantee in its own words — "(init runs, start does not — a plan writes nothing)" — and implemented it as a Proxy whose only override is start. packages/core/src/kernel.ts then fires three phases unconditionally after the suppressed start pass:

:402 Phase 3 trigger('kernel:ready')
:404 Phase 3.5 trigger('kernel:bootstrapped')
:416 Phase 4 trigger('kernel:listening')

A writing hook registered from init() survives the suppression on all three. The guarantee was therefore a property of plugins that happen to seed from start() — the shape of the one plugin that had been measured — and not a property of the plan path. packages/core/src/kernel.ts is untouched by this PR: the unconditional firing is the condition, not the fix site.

The seam, and why this one

The driver instance, guarded for the length of the kernel bootstrap.

  • Not the driver.* service entry.ObjectQLPlugin.start() walks the kernel's driver.* services (packages/objectql/src/plugin.ts — the discovery loop lives in start, not init) and hands each to the engine, which keys its registry by driver.name and discards a second instance under a name it already holds (packages/objectql/src/engine.ts, registerDriver). A wrapper registered in place of the service would be refused by the engine, and every objectql-mediated write would go straight to the raw driver. The instance is shared and the engine's write path is a call-time property lookup on it, so guarding the object itself covers both the plugin that resolves driver.* directly and the engine that writes through it.
  • Not a list of phase names. This card went from one phase to three before a line was written; a fourth would silently re-open the hole. The guard is phase-agnostic — one choke point, covered on the day a phase ships.
  • Refused members are read off the contract, not off a survey. The row-write surface of IDataDriver (packages/spec/src/contracts/data-driver.ts): create, update, upsert, delete, bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany. Adding a write to that interface is a spec diff every driver has to implement, so the list goes stale loudly rather than silently.
  • The contract's raw-execution escape hatch is REPORTED, not refused.execute() is a required member of IDataDriver (data-driver.ts:108, under the contract's own "Raw Execution (Escape Hatch)" heading) — on every driver, not a driver-sql extension. It is not refused, for a stated reason: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (a SELECT can quote the word INSERT in a literal; a CTE can write), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). What must never happen is the silent half: a boot-window execute() is forwarded, counted per driver, warned once per driver on stderr, and named in the composition notes — and on such a run no note claims the plan wrote nothing. The "a plan writes nothing" sentence is an outcome, printed by disarm() only on runs where it held.
  • A refusal does not throw.context.trigger() dispatches boot hooks propagating (packages/core/src/hook-dispatch.ts), so a throwing refusal would abort the bootstrap — turning "your plugin wrote during a dry run" into "you cannot get a plan at all", on the command whose whole job is to be read before a production apply. A refused call returns a contract-shaped value (create/upsert echo the caller's own payload rather than inventing an id; delete returns the contract's not-found false; updateMany/deleteMany return 0), and the run says so out loud: one warning on stderr per driver/method/object triple, plus a line in the composition notes the plan prints and --json carries.

The guard is armed by a plugin composed first in buildSchemaMigrationPlugins, so its init() is ordered ahead of every host plugin's (resolvePluginOrder is a DFS in registration order), and it re-scans in Phase 2 to pick up a driver registered by a later init(). bootSchemaStack disarms it the moment the bootstrap returns — everything after that is work the command was asked for, so os migrate apply's confirmed DDL flush and the coverage pass are untouched.

Seam census — what is NOT covered, stated rather than hidden

Every one of these is named in the module header too, so a future reader can tell a deliberate boundary from an oversight:

  • execute() — reported, never silent (see above): forwarded during the boot window, counted per driver, warned on stderr, named in the notes, and the "writes nothing" claim dropped for that run. getKnex() (a driver-sql extension, genuinely off-contract) is not intercepted; it was not the path any measured instance of this defect took.
  • DDL — and its members split.deferSchemaDdl holds back the initObjects/syncSchema path, which apply flushes on purpose once the operator confirms — guarding that would refuse the one write these commands exist to make. dropTable (and driver-sql's rotateShards) are NOT held back by that deferral: they run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately, so a hook calling driver.dropTable(...) on a managed datasource during a declaration boot executes, today as before this guard. A genuinely open boundary, stated.
  • Engine-held drivers for non-default datasources. The guard's scan covers the driver.* services the kernel publishes, and the only such registration repo-wide is the default datasource's (packages/runtime/src/default-datasource-plugin.ts). DatasourceConnectionService.connect() hands every other datasource's driver straight to engine.registerDriver, never through driver.* — so a host stack that connects a second datasource during a composed boot holds an engine-side driver this guard cannot see, and objectql-mediated writes to objects bound to it would land. Likely coverable by also arming instances at the engine.registerDriver seam while the guard is armed; proposed as a follow-up rather than widened into this PR — scope growth on a p1 is the PM's call.
  • writes a plugin makes outside the database entirely (filesystem, network).
  • work a hook defers past the end of the bootstrap; the guard covers the boot window.

Verification

The decisive test, with its positive controlpackages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts, booting a real ObjectKernel with a recording driver whose write methods live on the prototype, exactly like a real driver's:

  • positive control: the fixture plugin, composed the way a served boot composes it, writes on all three phases (create:sys_permission_set from start(), then create:sys_ai_model on ready, bootstrapped and listening);
  • the defect: composeForDeclarations alone removes only the start() seed — all three init()-registered hooks still write. Pinned, not described;
  • the fix: zero writes reach the driver, and the log-only hooks still ran — the recorded order is log-only:kernel:ready, write:kernel:ready, …, i.e. every handler executed and only the write was refused;
  • the escape hatch (R1): a guarded boot in which one hook issues both a contract create() (refused — the in-run control) and a raw execute("INSERT …"): the raw command is forwarded (the real driver ran it and its return value came back), counted (rawExecutions = 1 via that driver), and reported — and the disarm note drops the flat claim: expect(note).not.toContain('a plan writes nothing'). The refusal-only case pins the converse: with no raw command forwarded, the claim held and is printed;
  • plus: the whole row-write contract is refused (not just create()), a refused call hands back a contract-shaped value instead of throwing, and disarm() removes the shadowing own properties (the execute forwarder included) rather than overwriting them.

End to end against a real SQL driverpackages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, on a sqlite database whose tables already exist:

  • positive control: the same plugin on a boot with no host composition lands 3 rows;
  • the fix: a boot through bootSchemaStack with the writer coming from objectstack.config.tsand one handed straight to the kernel adds 0 rows (measured as a delta against the control's 3), every log-only hook ran, and composition.notes carries Refused 6 write(s) during the declaration boot — a plan writes nothing (no raw command that run — the claim held);
  • the review's own control shape (R1): in one guarded boot, create() refused (0 rows) while execute("INSERT …")landed 1 row — forwarded on purpose, and no longer silent: the notes carry Refused 4 write(s) during the declaration boot: (the colon marks the dropped claim), Raw execute() was called 1 time(s) during the declaration boot, and no note in the run claims the plan wrote nothing.

Ablations — direction stated in writing before each run.

Original guard ablation (measured at 6a8a182b77): drop the guard from the composed plugin list ⇒ predicted RED on the integration fix case and the two composition-ordering assertions, guard unit file GREEN; observed exactly that (expected 9 to be 3 — six rows the operator never asked for). Mutate/restore/measure in one process under trap … EXIT INT TERM, blob-hash-verified.

R1 reporting ablation (measured at 840f058ecb): remove the execute arm (DRIVER_RAW_EXECUTION_METHODS.slice(0, 0); anchor occurs exactly once) ⇒ predicted RED on the unit escape-hatch case, the disarm restore case (it asserts the execute shadow exists), and the integration R1 case (the unqualified claim returns); predicted GREEN elsewhere, the raw row still landing. Observed exactly that:

worktree before: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
occurrences BEFORE — original: 1, mutant: 0
worktree after : 11c2699deedbe358087e99e3dc91a95d8306e639
occurrences AFTER — original: 0, mutant: 1
Tests 3 failed | 14 passed (17) ← exactly the three predicted cases
worktree restored: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
git diff HEAD: empty · git status --porcelain: empty

Gates — union derived on the merged tree at 840f058ecb with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, reconciled with comm -23/comm -13 in both directions, exact string comparison: named 35, ran 35, unreconciled 0. Plus pnpm lint (repo-wide, eslint . --no-inline-config, exit 0 — no narrowing) and pnpm check:nul-bytes (exit 0), neither of which the derivation names.

34 of the 35 are green. node scripts/check-test-completeness.mjs is NOT MEASURED (exit 3, PREREQUISITE NOT MET) — it grades a saved turbo run test log, does not run tests, and its own text instructs a local family run to record it as NOT MEASURED. Three others first reported PREREQUISITE NOT MET for missing build output and are green after building the closures they named: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt (exit codes captured before any pipe, per gate log).

Tests at 840f058ecb: the guard unit + composition files 36/36; the #13332 integration block 3/3; the whole @objectstack/cli package 224 files / 2551 passed + 15 skipped, with two unrelated files (test/init-created-files-summary.e2e.test.ts, src/commands/datasource/envelope-unwrap.test.ts) timing out their beforeAll hooks under load during the full parallel run and passing 15/15 when re-run in isolation; pnpm --filter @objectstack/cli typecheck exit 0.

Contract review requested

Changing what a declaration boot permits is likely a contract change, and this PR does not decide that. The at-tier review (2026-09-01) ruled clause ② no on both limbs — conformance, not a contract change; no measured public-surface widening — and its four required items are addressed above.

What changed, and for whom. For os migrate plan and os migrate apply: during the kernel bootstrap, a row write issued through any driver.* instance the kernel publishes is now refused and reported instead of executed, and a raw execute() call through such an instance is forwarded but reported — with the run's notes declining to claim the plan wrote nothing. A host whose plugins wrote during that window will see contract writes stop landing and raw commands named; a host that did neither sees no change whatsoever: no disarm note is emitted when nothing was refused and no raw command went through, and the artifact-less, config-less run is untouched (that path returns before the guard is composed). Nothing outside these two commands is affected — os serve, os dev and os start do not boot through bootSchemaStack.

The changeset is minor on @objectstack/cli.

Generated by Claude Code

@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/automation/webhooks.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/kernel/http-protocol.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/knowledge.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

  • content/docs/releases/implementation-status.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v16.mdx(via updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v17.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

What this run could not see
  • 6 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 — 23 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 c75962ac3e0af69ae20f658618c4d3ab58192cc8packageMentionDocs.

Which tree this was computed on

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

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

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

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

REQUEST CHANGES

At-tier contract review under CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs on origin/main @ dda969cd71: export const CONTRACT_REVIEW_TIER = 'claude-fable-5' — this reviewer is that tier). Everything below was re-derived or re-measured on a detached worktree at head 6a8a182b77, not taken from the PR's text.

The ruling this PR routed here (both limbs, judged separately)

1. Accept/reject change — NO, this is conformance, and the PM's Clause-②: no stands. The guarantee was already shipped in three registers before this PR: the module's own printed sentence ("a plan writes nothing"), the operator docs (content/docs/deployment/cli.mdx:671 — "os migrate plan | Warns and continues — a plan writes nothing either way"), and plan.ts's own comments (:53, :141). Nothing is newly accepted or rejected at any input boundary — every host config and plugin that composed before still composes; what changed is that an effect documented not to happen now does not happen. On the "nobody could have depended on it" tension: the evidence runs the other way and it is measured, not asserted — the one known downstream host that wrote during this window (cloud, ensure-default-ai-model-plugin) treated those writes as a defect in itself (cloud#1744) and moved them out (cloud PR #1749). The affected population's own revealed dependence was on the documented guarantee, not the accidental behaviour. A host depending on writes landing during a plan/apply boot was depending on a documented untruth. No gate breach by the PM (the opus dispatch was in-band); routing the question here was still correct, because the delta is real and downstream.

2. Public-surface widening — NO, measured with a control. Built @objectstack/cli and its dependency closure twice (pnpm exec turbo run build --filter='@objectstack/cli...') at head 6a8a182b77 and base ada3834add. The package's exports map exposes exactly two type surfaces: .dist/index.d.ts and ./consoledist/utils/console.d.ts. Both are byte-identical across the two builds (cmp). The only PR-attributable .d.ts delta is dist/utils/schema-migration-plugins.d.ts (~100 changed lines: createDeclarationBootWriteGuard, DeclarationBootWriteGuard, RefusedDeclarationWrite, SchemaMigrationComposition.writeGuard?), which is not reachable through the exports map. Positive control: 5 .js files differ, including schema-migrate.js and schema-migration-plugins.js — the instrument sees this PR's change, so the entry-point zero is a real zero. (The extract-hook-body/lower-callables/lint dist diffs are from main commits the head merge pulled in — git diff ada3834a...6a8a182b -- packages/cli/src/utils/extract-hook-body.ts is empty.)

So on the contract question: clause ② does not apply — the guarantee was already the contract. The REQUEST CHANGES is not about the ruling; it is about claims the PR rests on that did not survive verification.

Verified TRUE (independently)

  • Seam claim holds.packages/objectql/src/engine.tsregisterDriver (~:5172): if (existing !== driver) → warn and discard the supplied instance — a service-level wrapper handed to the engine under a held name would indeed be dropped, and the engine already holds the raw instance from DatasourceConnectionService.connect() (packages/services/service-datasource/src/datasource-connection-service.ts:618). The engine's write path is a call-time property lookup on that shared instance (this.getDriver(object) then await driver.create(...), engine.ts :4536/:6659), so the own-property shadow intercepts objectql-mediated writes. Wrapping the instance is the only placement that intercepts. One misattribution: the driver.* discovery loop is in ObjectQLPlugin.start (packages/objectql/src/plugin.ts:469, loop at :628), not init() as the PR body and the new docblocks say — the engine's own docblock has it right. Substance unaffected; text should be corrected.
  • Non-throwing refusal premise holds.packages/core/src/hook-dispatch.ts header: boot path (kernel:ready/kernel:bootstrapped/kernel:listening) ⇒ dispatchHookPropagating — a throwing handler aborts the bootstrap, so a throwing refusal would turn "your plugin wrote during a dry run" into "no plan at all". A create() refusal echoing the caller's payload is success-shaped to the calling plugin — but the party the guarantee protects is the operator, who is told twice (stderr + notes), reads pass through honestly (a write-then-read sees no phantom row), and every alternative return either fabricates an identity or aborts the boot. Right call.
  • Scope claim holds.composeHostStack: true appears in exactly two non-test call sites: migrate/plan.ts:154 and migrate/apply.ts:181; the guard is composed only under that flag, and buildSchemaMigrationPlugins returns NOTHING_COMPOSED before createDeclarationBootWriteGuard() on the artifact-less config-less path. No bootSchemaStack caller is serve/dev/start. All three phases fire inside kernel.bootstrap() (:331, phases at :402/:414/:424), so the disarm placed after await runtime.start() covers the whole window and comes off before apply's flush.
  • The measurements are real. Re-ran the guard unit file + composition file at head: 35/35 pass. Re-ran the new integration block (-t '13332'): positive control (3 rows) and fix case (delta 0, log-only hooks ran, note carries Refused 6 write(s)) both pass.

What did not survive verification — the required changes

R1. The refusal-surface claim is false as stated, and I measured the hole. The PR: "Refused members are read off the contract, not off a survey… a driver's raw escape hatches — driver-sql's execute() and getKnex(). Off-contract for a plugin." That is wrong twice. execute(command, parameters?, options?) is a required member of the contract itselfpackages/spec/src/contracts/data-driver.ts:108, under the contract's own heading "Raw Execution (Escape Hatch)" — on every driver, not a driver-sql extension. (Also: the interface is IDataDriver; no IDataSourceDriver symbol exists in packages/spec.) Measured, with an in-run control, against the built head via bootSchemaStack (composeHostStack: true, tables pre-materialized, one host plugin whose kernel:ready hook issues both calls):

RESULT create-probe rows (guarded surface, control): 0 ← refused, reported
RESULT exec-probe rows (raw execute() INSERT): 1 ← LANDED
notes: "…row writes refused at the driver for the whole boot — a plan writes nothing."
"Refused 1 write(s) during the declaration boot … create() on sys_metadata."

The same armed guard, the same driver instance, the same hook: create() refused and reported; execute("INSERT INTO sys_metadata …") landed a row in the plan's target database silently, while the run printed "a plan writes nothing" and a refusal list that looks complete. That is precisely the enumerated-subset defect class this repo has on record: the guard reports green for the member it excluded. Required: during the boot window, an execute() call must not land silently — either refuse it (contract return is Promise<unknown>; if refusal is judged too risky because execute also serves reads and SQL cannot be classified reliably, say that reason) or forward it and report it in the same notes/stderr channel so the printed sentence stays true or is qualified. And the census text (module header, changeset) must stop calling execute off-contract.

R2. "DDL — already held back by deferSchemaDdl" is false for dropTable.setDeferredDdl (sql-driver.ts :10153) sets a flag that only the initObjects/syncSchema path checks (:9393/:9408). dropTable (:8726) runs assertSchemaMutable — which gates schemaMode/dialect (:5162), not deferral — then knex.schema.dropTableIfExists immediately; rotateShards is the same shape. A hook calling driver.dropTable(...) on a managed datasource during a plan boot executes, today and after this PR. The census line (module header + changeset) claims a mechanism that does not exist for those members; correct it, and either cover them or state them as genuinely open.

R3. One unstated residue worth naming in the census: the guard's scan covers "every driver.* instance the kernel publishes", but the engine also receives drivers that are never published as driver.*DatasourceConnectionService.connect() hands them to engine.registerDriver directly (:618), and the only driver.* registration repo-wide is the default driver (packages/runtime/src/default-datasource-plugin.ts:183). A host stack that connects a second datasource during a composed boot holds an engine-side driver the guard cannot see; objectql-mediated writes to objects bound to it would land. Same class as the stated boundaries — but the census's job, by its own words, is to make a deliberate boundary distinguishable from an oversight, so it belongs on the list.

R4. Text accuracy in the same pass:ObjectQLPlugin.start, not init() (docblocks in schema-migration-plugins.ts + changeset); IDataDriver, not IDataSourceDriver.

Changeset grade

minor on @objectstack/cli@17.2.0 is right given the ruling above: a host-visible behavioural narrowing that conforms to documented behaviour is not major, and patch would under-signal a delta a host can observe. The body's "What changed / Who this affects" is the correct shape and names the delta plainly — but it repeats the R1/R2 misstatements ("raw escape hatches (driver-sql's execute() …)", "DDL (already held back by deferSchemaDdl …)") and must be corrected with them.

Summary

Design (b) is the right design, the seam is the right seam and verifiably the only one that intercepts, the tests are real and their controls are the right controls, and the contract question resolves to conformance on both limbs — no accept/reject change, no measured surface widening. What must change before this leaves draft: R1 (cover or report execute(), and correct the "off-contract" claim), R2 (correct the dropTable/deferral claim, cover or state it), R3 (name the engine-held non-default-driver residue), R4 (attribution fixes), with the changeset corrected alongside. The operator-facing sentence this PR exists to make true — "a plan writes nothing" — must not print over a run in which a contract-member write landed silently; that is the same defect this PR was filed to close, one member over.


Generated by Claude Code

…execute() escape hatch, correct the census
R1: execute() is a REQUIRED member of IDataDriver (packages/spec/src/
contracts/data-driver.ts:108, 'Raw Execution (Escape Hatch)'), not a
driver-sql extension, and the guard did not touch it: in one guarded boot
create() was refused and reported while execute("INSERT ...") landed a
row silently under a printed 'a plan writes nothing'. Chosen: REPORT, not
cover — a raw command is unknown by contract ('SQL string, shell command,
or API payload') and SQL text cannot be classified read-vs-write reliably
(a SELECT can quote INSERT in a literal; a CTE can write), while the
framework's own boot work (ensureOverlayIndex's index DDL) runs through
this seam, so a refusal would either break legitimate calls or rest on a
guess. A boot-window execute() is now forwarded, counted per driver,
warned once per driver on stderr, and named in the composition notes —
and no note claims the plan wrote nothing on such a run: the flat claim
is an OUTCOME, printed by disarm() only when it held.
R2: 'DDL already held back by deferSchemaDdl' was false for dropTable /
rotateShards — they run assertSchemaMutable (a schemaMode/dialect gate,
not a deferral check) and execute immediately. The census now states the
split and names them a genuinely open boundary.
R3: drivers the engine holds for a non-default datasource are never
published as driver.* (the only registration is
default-datasource-plugin.ts) and are invisible to the guard's scan —
now stated in the census, module header and changeset.
R4: ObjectQLPlugin.start (plugin.ts:469/:628), not init(), walks the
driver.* services; the interface is IDataDriver, not IDataSourceDriver
(no such symbol exists in spec). Corrected in docblocks, tests and the
changeset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review response — R1–R4 addressed at 840f058ecb (session session_01UngCYXF98BVpYA9hfz6NYk, same branch). Both central facts were re-derived before acting, per the review's own instruction; nothing settled was re-opened.

Re-derivations (before any edit):

  • execute(command: unknown, parameters?, options?): Promise<unknown> confirmed a required member at packages/spec/src/contracts/data-driver.ts:108, under "Raw Execution (Escape Hatch)"; grep -rn IDataSourceDriver packages/spec/src/ exits 1 — no such symbol.
  • Reproduced the hole at 6a8a182b77 with the review's control shape (guarded bootSchemaStack, composeHostStack: true, tables pre-materialized, one hook issuing both calls): create() refused, 0 rows, reported — execute("INSERT INTO sys_metadata …")landed 1 row silently while the notes printed "a plan writes nothing".

R1 — chose REPORT, not cover, for the reason the review anticipated: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (no regex guessing), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). A boot-window execute() is now forwarded, counted per driver (rawExecutions on the guard, --json-visible), warned once per driver on stderr, and named in the composition notes — and the "a plan writes nothing" sentence is now an outcome owned by disarm(): printed when it held, dropped when a raw command went through. The compose-time note no longer pre-claims it. Proof, same control shape at head: create() 0 rows + execute 1 row as before, but notes now read Refused 4 write(s) during the declaration boot: (colon = dropped claim) + Raw execute() was called 1 time(s) during the declaration boot (1 via driver.com.objectstack.driver.sql) and FORWARDED, not refused: … — pinned in both the unit file and a new integration case. Mutation proof (direction written before the run): ablating the execute arm went RED on exactly the 3 predicted cases (14 GREEN), blob-hash mutate/restore verified, trap … EXIT INT TERM, one process.

R2 — the census and changeset no longer claim deferSchemaDdl holds DDL back wholesale. Stated now: the deferral covers the initObjects/syncSchema path (flushed on purpose by apply); dropTable/rotateShards run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately during a declaration boot. Named a genuinely open boundary in the module header, PR body and changeset; not covered here.

R3 — the engine-held non-default-datasource residue is named in the module header census, PR body and changeset: only the default datasource is ever published as driver.* (default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other datasources' drivers straight to engine.registerDriver, invisible to the scan. Likely coverable by arming instances at the registerDriver seam while the guard is armed — proposed as a follow-up, not widened into this PR; scope call is the PM's.

R4 —ObjectQLPlugin.start (loop at plugin.ts:628), not init(); IDataDriver, not IDataSourceDriver — corrected in the docblocks, the unit-test fixture comment, the PR body and the changeset. No occurrence remains in packages/cli/src or .changeset/.

Gates at 840f058ecb (branch merged with current origin/main first — the derivation flagged its gate-family files stale): union via dispatch-gates.mjs --commands, reconciled with comm both directions, exact strings: named 35, ran 35, unreconciled 0. 34 green; check-test-completeness NOT MEASURED (exit 3, PREREQUISITE NOT MET, per its own text). check:dual-build-cjs-loads / check:i18n-coverage / check:type-check-debt green after building the closures they named. Plus unnamed: pnpm lint exit 0, pnpm check:nul-bytes exit 0. CLI package: 224 files, 2551 passed + 15 skipped (two unrelated files hit beforeAll timeouts under full-suite load and pass 15/15 in isolation); typecheck exit 0.

PR remains draft; labels untouched — needs:contract-review stays until you clear it.

Generated by Claude Code


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

APPROVE

Follow-up at-tier contract review under CONTRACT_REVIEW_TIER (claude-fable-5), re-verifying R1–R4 at the new head 840f058ecb. Nothing below is carried forward from the 6a8a182b77 readings — the tree moved (merge of origin/main), so every fact was re-derived on a fresh detached worktree, with the same probe re-run against a fresh build of the head (dist currency confirmed by content grep before trusting it: the new guard strings are present in dist/utils/schema-migration-plugins.js).

1. Report over cover — the right call, on premises that held

All three stated premises re-derived, plus one the fix did not claim:

  • execute(command: unknown, …) is the contract's own opaque member — "SQL string, shell command, or API payload" (packages/spec/src/contracts/data-driver.ts, "Raw Execution (Escape Hatch)"). There is no sound classifier over that input; any bounded cover either refuses allexecute() (breaking legitimate raw reads) or classifies SQL text (a guess, and unreliable in both directions).
  • The framework-traffic premise is real: packages/metadata-protocol/src/protocol.ts:5210 (ensureOverlayIndex) issues its index DDL via (driver as any).execute(sql) — a wholesale refusal would break the framework's own seam users or force an unprincipled caller allowlist.
  • The premise the commit didn't state but which seals it: execute() has no contract-shaped refusal value. Its return is unknown; a fabricated return silently corrupts raw-read callers — strictly worse than the row-write case, where honest no-op shapes exist. So a non-throwing refusal is not even constructible here.

I asked for cover-or-report in R1 and offered report as an acceptable shape; report is not just acceptable — for this member it is the only shape that neither guesses nor breaks. No bounded cover exists without one of those two defects.

2. The disarm()-owned outcome — closes it, does not relocate it (with one stated residue)

Measured behaviourally, both directions, at the new head:

  • Raw traffic present (my original probe, re-run against the built head): create() refused (0 rows, in-run control), execute("INSERT …") forwarded (1 row lands — now the documented behaviour), stderr carries the per-driver warning, and the notes read Refused 1 write(s) during the declaration boot: create() on sys_metadata … — flat claim dropped — followed by Raw execute() was called 1 time(s) … whether this boot wrote is NOT verified, and this run does NOT claim to have written nothing. No note in the run contains "a plan writes nothing". The compose-time note now states mechanism, not outcome ("with the contract's row writes refused at the driver for the whole boot") — the pre-claim is gone.
  • Raw traffic absent: the integration fix case pins Refused 6 write(s) during the declaration boot — a plan writes nothing (3/3 pass), and the unit disarm case pins the claim printed only when it held (36/36 pass).

So the sentence is no longer an assertion printed over an unverified run; it is a measurement over everything the guard can observe. The honest residue, for the record: the claim can still print on a run where a write escaped through a census-stated open boundary — dropTable/rotateShards (verified again: assertSchemaMutable gates schemaMode/dialect, not deferral), a getKnex() handle, or an engine-held second-datasource driver. For getKnex() handles and second drivers that is irreducible without the follow-up seam. For dropTable/rotateShards it is not: those members are unambiguous writes with no classification problem, and the same forward-and-count treatment execute() got would extend the outcome's honesty to them cheaply. Non-blocking — R2 offered "state as open" and that offer stands — but it belongs on the follow-up's scope.

3. R3 residue — stated accurately, and deferral is acceptable

The census/changeset wording matches what I measured, item for item: only the default datasource is ever published as driver.* (re-grepped repo-wide at 840f058ecb: the single registration is packages/runtime/src/default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other drivers straight to engine.registerDriver (now datasource-connection-service.ts:635 after the main merge — same mechanism); consequence stated correctly ("objectql-mediated writes to objects bound to it would land"). The proposed fix seam (arm at engine.registerDriver while the guard is armed) is the right one. Leaving it unimplemented is acceptable for merge: the shape is an edge beyond every measured instance of this defect, and it is stated where a reader of the mechanism will look. It should exist as a tracked issue before this merges, not only as a sentence in a PR body — filing it is the PM's, and the dropTable/rotateShards counting above should ride the same card.

4. R4 zero — re-derived, holds, with controls

  • grep -rn "IDataSourceDriver" packages/cli/src .changeset/ → exit 1 (zero). Repo-wide over all packages/**/*.ts, exit code taken from grep itself (not a pipe tail): 0 matches, exit 1. Positive control: IDataDriver present (4× in the guard module, 2× in the changeset) — the zero is not a tool failure.
  • grep -rn "ObjectQLPlugin\.init\|ObjectQLPlugin's init" packages/cli/src .changeset/ → exit 1 (zero). Control: ObjectQLPlugin.start present 3× in the module, and I re-confirmed the fact itself at the new head (packages/objectql/src/plugin.tsstart = async at :469, discovery loop at :628).

Also re-verified rather than assumed after the merge: the seam facts stand (engine registerDriver discard branch, engine write path as call-time lookup); disarm() restores the execute forwarder too (own descriptor present while armed, gone after, prototype identity preserved — pinned at test :405/:415); and the public-surface zero still holds structurally — the PR's 6-file surface touches neither packages/cli/package.json nor src/index.ts/utils/console.ts (git diff origin/main...840f058ecb on those paths: empty), so the new ForwardedRawExecution/rawExecutions symbols live in the same module the exports map does not reach.

Ruling

The clause-② ruling from the first review stands unchanged at this head: conformance, not a contract accept/reject change; no public-surface widening. All four required items are addressed and verified. needs:contract-review clears — my explicit word, for both carriers (label removal is the PM's write, not mine). The PR stays draft per the PM's parking; this approval is contract-review clearance, not a merge instruction. One expectation attached, non-blocking: the R3 follow-up (engine-seam arming, plus dropTable/rotateShards counting) becomes a tracked issue before merge.


Generated by Claude Code

Merged via the queue into main with commit bf3bbf1Sep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-13332-declaration-boot-write-suppression branch September 1, 2026 06:47
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-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone - #14053

Merged
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression
Sep 1, 2026
Merged

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone#14053
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13332

Design decision (b) — suppress writes at the driver seam for the declaration boot, so read/log hooks still run — as decided by the domain:cli PM seat in the claim comment. It was implementable as stated; the named fallback (report a seam census showing no single driver seam exists) was not taken.

Contract-review response (2026-09-01): the at-tier review's four required items are addressed at 840f058ecb — R1 (the contract's execute() escape hatch is now reported, and no run with a forwarded raw command claims it wrote nothing), R2 (the dropTable/deferral claim corrected — it is a genuinely open boundary, stated), R3 (the engine-held non-default-datasource driver residue named in the census and changeset), R4 (attributions corrected: ObjectQLPlugin.start, IDataDriver). Details inline below and in the review-response comment.

The defect

composeForDeclarations documented the plan path's guarantee in its own words — "(init runs, start does not — a plan writes nothing)" — and implemented it as a Proxy whose only override is start. packages/core/src/kernel.ts then fires three phases unconditionally after the suppressed start pass:

:402 Phase 3 trigger('kernel:ready')
:404 Phase 3.5 trigger('kernel:bootstrapped')
:416 Phase 4 trigger('kernel:listening')

A writing hook registered from init() survives the suppression on all three. The guarantee was therefore a property of plugins that happen to seed from start() — the shape of the one plugin that had been measured — and not a property of the plan path. packages/core/src/kernel.ts is untouched by this PR: the unconditional firing is the condition, not the fix site.

The seam, and why this one

The driver instance, guarded for the length of the kernel bootstrap.

  • Not the driver.* service entry.ObjectQLPlugin.start() walks the kernel's driver.* services (packages/objectql/src/plugin.ts — the discovery loop lives in start, not init) and hands each to the engine, which keys its registry by driver.name and discards a second instance under a name it already holds (packages/objectql/src/engine.ts, registerDriver). A wrapper registered in place of the service would be refused by the engine, and every objectql-mediated write would go straight to the raw driver. The instance is shared and the engine's write path is a call-time property lookup on it, so guarding the object itself covers both the plugin that resolves driver.* directly and the engine that writes through it.
  • Not a list of phase names. This card went from one phase to three before a line was written; a fourth would silently re-open the hole. The guard is phase-agnostic — one choke point, covered on the day a phase ships.
  • Refused members are read off the contract, not off a survey. The row-write surface of IDataDriver (packages/spec/src/contracts/data-driver.ts): create, update, upsert, delete, bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany. Adding a write to that interface is a spec diff every driver has to implement, so the list goes stale loudly rather than silently.
  • The contract's raw-execution escape hatch is REPORTED, not refused.execute() is a required member of IDataDriver (data-driver.ts:108, under the contract's own "Raw Execution (Escape Hatch)" heading) — on every driver, not a driver-sql extension. It is not refused, for a stated reason: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (a SELECT can quote the word INSERT in a literal; a CTE can write), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). What must never happen is the silent half: a boot-window execute() is forwarded, counted per driver, warned once per driver on stderr, and named in the composition notes — and on such a run no note claims the plan wrote nothing. The "a plan writes nothing" sentence is an outcome, printed by disarm() only on runs where it held.
  • A refusal does not throw.context.trigger() dispatches boot hooks propagating (packages/core/src/hook-dispatch.ts), so a throwing refusal would abort the bootstrap — turning "your plugin wrote during a dry run" into "you cannot get a plan at all", on the command whose whole job is to be read before a production apply. A refused call returns a contract-shaped value (create/upsert echo the caller's own payload rather than inventing an id; delete returns the contract's not-found false; updateMany/deleteMany return 0), and the run says so out loud: one warning on stderr per driver/method/object triple, plus a line in the composition notes the plan prints and --json carries.

The guard is armed by a plugin composed first in buildSchemaMigrationPlugins, so its init() is ordered ahead of every host plugin's (resolvePluginOrder is a DFS in registration order), and it re-scans in Phase 2 to pick up a driver registered by a later init(). bootSchemaStack disarms it the moment the bootstrap returns — everything after that is work the command was asked for, so os migrate apply's confirmed DDL flush and the coverage pass are untouched.

Seam census — what is NOT covered, stated rather than hidden

Every one of these is named in the module header too, so a future reader can tell a deliberate boundary from an oversight:

  • execute() — reported, never silent (see above): forwarded during the boot window, counted per driver, warned on stderr, named in the notes, and the "writes nothing" claim dropped for that run. getKnex() (a driver-sql extension, genuinely off-contract) is not intercepted; it was not the path any measured instance of this defect took.
  • DDL — and its members split.deferSchemaDdl holds back the initObjects/syncSchema path, which apply flushes on purpose once the operator confirms — guarding that would refuse the one write these commands exist to make. dropTable (and driver-sql's rotateShards) are NOT held back by that deferral: they run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately, so a hook calling driver.dropTable(...) on a managed datasource during a declaration boot executes, today as before this guard. A genuinely open boundary, stated.
  • Engine-held drivers for non-default datasources. The guard's scan covers the driver.* services the kernel publishes, and the only such registration repo-wide is the default datasource's (packages/runtime/src/default-datasource-plugin.ts). DatasourceConnectionService.connect() hands every other datasource's driver straight to engine.registerDriver, never through driver.* — so a host stack that connects a second datasource during a composed boot holds an engine-side driver this guard cannot see, and objectql-mediated writes to objects bound to it would land. Likely coverable by also arming instances at the engine.registerDriver seam while the guard is armed; proposed as a follow-up rather than widened into this PR — scope growth on a p1 is the PM's call.
  • writes a plugin makes outside the database entirely (filesystem, network).
  • work a hook defers past the end of the bootstrap; the guard covers the boot window.

Verification

The decisive test, with its positive controlpackages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts, booting a real ObjectKernel with a recording driver whose write methods live on the prototype, exactly like a real driver's:

  • positive control: the fixture plugin, composed the way a served boot composes it, writes on all three phases (create:sys_permission_set from start(), then create:sys_ai_model on ready, bootstrapped and listening);
  • the defect: composeForDeclarations alone removes only the start() seed — all three init()-registered hooks still write. Pinned, not described;
  • the fix: zero writes reach the driver, and the log-only hooks still ran — the recorded order is log-only:kernel:ready, write:kernel:ready, …, i.e. every handler executed and only the write was refused;
  • the escape hatch (R1): a guarded boot in which one hook issues both a contract create() (refused — the in-run control) and a raw execute("INSERT …"): the raw command is forwarded (the real driver ran it and its return value came back), counted (rawExecutions = 1 via that driver), and reported — and the disarm note drops the flat claim: expect(note).not.toContain('a plan writes nothing'). The refusal-only case pins the converse: with no raw command forwarded, the claim held and is printed;
  • plus: the whole row-write contract is refused (not just create()), a refused call hands back a contract-shaped value instead of throwing, and disarm() removes the shadowing own properties (the execute forwarder included) rather than overwriting them.

End to end against a real SQL driverpackages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, on a sqlite database whose tables already exist:

  • positive control: the same plugin on a boot with no host composition lands 3 rows;
  • the fix: a boot through bootSchemaStack with the writer coming from objectstack.config.tsand one handed straight to the kernel adds 0 rows (measured as a delta against the control's 3), every log-only hook ran, and composition.notes carries Refused 6 write(s) during the declaration boot — a plan writes nothing (no raw command that run — the claim held);
  • the review's own control shape (R1): in one guarded boot, create() refused (0 rows) while execute("INSERT …")landed 1 row — forwarded on purpose, and no longer silent: the notes carry Refused 4 write(s) during the declaration boot: (the colon marks the dropped claim), Raw execute() was called 1 time(s) during the declaration boot, and no note in the run claims the plan wrote nothing.

Ablations — direction stated in writing before each run.

Original guard ablation (measured at 6a8a182b77): drop the guard from the composed plugin list ⇒ predicted RED on the integration fix case and the two composition-ordering assertions, guard unit file GREEN; observed exactly that (expected 9 to be 3 — six rows the operator never asked for). Mutate/restore/measure in one process under trap … EXIT INT TERM, blob-hash-verified.

R1 reporting ablation (measured at 840f058ecb): remove the execute arm (DRIVER_RAW_EXECUTION_METHODS.slice(0, 0); anchor occurs exactly once) ⇒ predicted RED on the unit escape-hatch case, the disarm restore case (it asserts the execute shadow exists), and the integration R1 case (the unqualified claim returns); predicted GREEN elsewhere, the raw row still landing. Observed exactly that:

worktree before: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
occurrences BEFORE — original: 1, mutant: 0
worktree after : 11c2699deedbe358087e99e3dc91a95d8306e639
occurrences AFTER — original: 0, mutant: 1
Tests 3 failed | 14 passed (17) ← exactly the three predicted cases
worktree restored: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
git diff HEAD: empty · git status --porcelain: empty

Gates — union derived on the merged tree at 840f058ecb with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, reconciled with comm -23/comm -13 in both directions, exact string comparison: named 35, ran 35, unreconciled 0. Plus pnpm lint (repo-wide, eslint . --no-inline-config, exit 0 — no narrowing) and pnpm check:nul-bytes (exit 0), neither of which the derivation names.

34 of the 35 are green. node scripts/check-test-completeness.mjs is NOT MEASURED (exit 3, PREREQUISITE NOT MET) — it grades a saved turbo run test log, does not run tests, and its own text instructs a local family run to record it as NOT MEASURED. Three others first reported PREREQUISITE NOT MET for missing build output and are green after building the closures they named: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt (exit codes captured before any pipe, per gate log).

Tests at 840f058ecb: the guard unit + composition files 36/36; the #13332 integration block 3/3; the whole @objectstack/cli package 224 files / 2551 passed + 15 skipped, with two unrelated files (test/init-created-files-summary.e2e.test.ts, src/commands/datasource/envelope-unwrap.test.ts) timing out their beforeAll hooks under load during the full parallel run and passing 15/15 when re-run in isolation; pnpm --filter @objectstack/cli typecheck exit 0.

Contract review requested

Changing what a declaration boot permits is likely a contract change, and this PR does not decide that. The at-tier review (2026-09-01) ruled clause ② no on both limbs — conformance, not a contract change; no measured public-surface widening — and its four required items are addressed above.

What changed, and for whom. For os migrate plan and os migrate apply: during the kernel bootstrap, a row write issued through any driver.* instance the kernel publishes is now refused and reported instead of executed, and a raw execute() call through such an instance is forwarded but reported — with the run's notes declining to claim the plan wrote nothing. A host whose plugins wrote during that window will see contract writes stop landing and raw commands named; a host that did neither sees no change whatsoever: no disarm note is emitted when nothing was refused and no raw command went through, and the artifact-less, config-less run is untouched (that path returns before the guard is composed). Nothing outside these two commands is affected — os serve, os dev and os start do not boot through bootSchemaStack.

The changeset is minor on @objectstack/cli.

Generated by Claude Code

@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/automation/webhooks.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/kernel/http-protocol.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/knowledge.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

  • content/docs/releases/implementation-status.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v16.mdx(via updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v17.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

What this run could not see
  • 6 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 — 23 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 c75962ac3e0af69ae20f658618c4d3ab58192cc8packageMentionDocs.

Which tree this was computed on

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

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

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

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

REQUEST CHANGES

At-tier contract review under CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs on origin/main @ dda969cd71: export const CONTRACT_REVIEW_TIER = 'claude-fable-5' — this reviewer is that tier). Everything below was re-derived or re-measured on a detached worktree at head 6a8a182b77, not taken from the PR's text.

The ruling this PR routed here (both limbs, judged separately)

1. Accept/reject change — NO, this is conformance, and the PM's Clause-②: no stands. The guarantee was already shipped in three registers before this PR: the module's own printed sentence ("a plan writes nothing"), the operator docs (content/docs/deployment/cli.mdx:671 — "os migrate plan | Warns and continues — a plan writes nothing either way"), and plan.ts's own comments (:53, :141). Nothing is newly accepted or rejected at any input boundary — every host config and plugin that composed before still composes; what changed is that an effect documented not to happen now does not happen. On the "nobody could have depended on it" tension: the evidence runs the other way and it is measured, not asserted — the one known downstream host that wrote during this window (cloud, ensure-default-ai-model-plugin) treated those writes as a defect in itself (cloud#1744) and moved them out (cloud PR #1749). The affected population's own revealed dependence was on the documented guarantee, not the accidental behaviour. A host depending on writes landing during a plan/apply boot was depending on a documented untruth. No gate breach by the PM (the opus dispatch was in-band); routing the question here was still correct, because the delta is real and downstream.

2. Public-surface widening — NO, measured with a control. Built @objectstack/cli and its dependency closure twice (pnpm exec turbo run build --filter='@objectstack/cli...') at head 6a8a182b77 and base ada3834add. The package's exports map exposes exactly two type surfaces: .dist/index.d.ts and ./consoledist/utils/console.d.ts. Both are byte-identical across the two builds (cmp). The only PR-attributable .d.ts delta is dist/utils/schema-migration-plugins.d.ts (~100 changed lines: createDeclarationBootWriteGuard, DeclarationBootWriteGuard, RefusedDeclarationWrite, SchemaMigrationComposition.writeGuard?), which is not reachable through the exports map. Positive control: 5 .js files differ, including schema-migrate.js and schema-migration-plugins.js — the instrument sees this PR's change, so the entry-point zero is a real zero. (The extract-hook-body/lower-callables/lint dist diffs are from main commits the head merge pulled in — git diff ada3834a...6a8a182b -- packages/cli/src/utils/extract-hook-body.ts is empty.)

So on the contract question: clause ② does not apply — the guarantee was already the contract. The REQUEST CHANGES is not about the ruling; it is about claims the PR rests on that did not survive verification.

Verified TRUE (independently)

  • Seam claim holds.packages/objectql/src/engine.tsregisterDriver (~:5172): if (existing !== driver) → warn and discard the supplied instance — a service-level wrapper handed to the engine under a held name would indeed be dropped, and the engine already holds the raw instance from DatasourceConnectionService.connect() (packages/services/service-datasource/src/datasource-connection-service.ts:618). The engine's write path is a call-time property lookup on that shared instance (this.getDriver(object) then await driver.create(...), engine.ts :4536/:6659), so the own-property shadow intercepts objectql-mediated writes. Wrapping the instance is the only placement that intercepts. One misattribution: the driver.* discovery loop is in ObjectQLPlugin.start (packages/objectql/src/plugin.ts:469, loop at :628), not init() as the PR body and the new docblocks say — the engine's own docblock has it right. Substance unaffected; text should be corrected.
  • Non-throwing refusal premise holds.packages/core/src/hook-dispatch.ts header: boot path (kernel:ready/kernel:bootstrapped/kernel:listening) ⇒ dispatchHookPropagating — a throwing handler aborts the bootstrap, so a throwing refusal would turn "your plugin wrote during a dry run" into "no plan at all". A create() refusal echoing the caller's payload is success-shaped to the calling plugin — but the party the guarantee protects is the operator, who is told twice (stderr + notes), reads pass through honestly (a write-then-read sees no phantom row), and every alternative return either fabricates an identity or aborts the boot. Right call.
  • Scope claim holds.composeHostStack: true appears in exactly two non-test call sites: migrate/plan.ts:154 and migrate/apply.ts:181; the guard is composed only under that flag, and buildSchemaMigrationPlugins returns NOTHING_COMPOSED before createDeclarationBootWriteGuard() on the artifact-less config-less path. No bootSchemaStack caller is serve/dev/start. All three phases fire inside kernel.bootstrap() (:331, phases at :402/:414/:424), so the disarm placed after await runtime.start() covers the whole window and comes off before apply's flush.
  • The measurements are real. Re-ran the guard unit file + composition file at head: 35/35 pass. Re-ran the new integration block (-t '13332'): positive control (3 rows) and fix case (delta 0, log-only hooks ran, note carries Refused 6 write(s)) both pass.

What did not survive verification — the required changes

R1. The refusal-surface claim is false as stated, and I measured the hole. The PR: "Refused members are read off the contract, not off a survey… a driver's raw escape hatches — driver-sql's execute() and getKnex(). Off-contract for a plugin." That is wrong twice. execute(command, parameters?, options?) is a required member of the contract itselfpackages/spec/src/contracts/data-driver.ts:108, under the contract's own heading "Raw Execution (Escape Hatch)" — on every driver, not a driver-sql extension. (Also: the interface is IDataDriver; no IDataSourceDriver symbol exists in packages/spec.) Measured, with an in-run control, against the built head via bootSchemaStack (composeHostStack: true, tables pre-materialized, one host plugin whose kernel:ready hook issues both calls):

RESULT create-probe rows (guarded surface, control): 0 ← refused, reported
RESULT exec-probe rows (raw execute() INSERT): 1 ← LANDED
notes: "…row writes refused at the driver for the whole boot — a plan writes nothing."
"Refused 1 write(s) during the declaration boot … create() on sys_metadata."

The same armed guard, the same driver instance, the same hook: create() refused and reported; execute("INSERT INTO sys_metadata …") landed a row in the plan's target database silently, while the run printed "a plan writes nothing" and a refusal list that looks complete. That is precisely the enumerated-subset defect class this repo has on record: the guard reports green for the member it excluded. Required: during the boot window, an execute() call must not land silently — either refuse it (contract return is Promise<unknown>; if refusal is judged too risky because execute also serves reads and SQL cannot be classified reliably, say that reason) or forward it and report it in the same notes/stderr channel so the printed sentence stays true or is qualified. And the census text (module header, changeset) must stop calling execute off-contract.

R2. "DDL — already held back by deferSchemaDdl" is false for dropTable.setDeferredDdl (sql-driver.ts :10153) sets a flag that only the initObjects/syncSchema path checks (:9393/:9408). dropTable (:8726) runs assertSchemaMutable — which gates schemaMode/dialect (:5162), not deferral — then knex.schema.dropTableIfExists immediately; rotateShards is the same shape. A hook calling driver.dropTable(...) on a managed datasource during a plan boot executes, today and after this PR. The census line (module header + changeset) claims a mechanism that does not exist for those members; correct it, and either cover them or state them as genuinely open.

R3. One unstated residue worth naming in the census: the guard's scan covers "every driver.* instance the kernel publishes", but the engine also receives drivers that are never published as driver.*DatasourceConnectionService.connect() hands them to engine.registerDriver directly (:618), and the only driver.* registration repo-wide is the default driver (packages/runtime/src/default-datasource-plugin.ts:183). A host stack that connects a second datasource during a composed boot holds an engine-side driver the guard cannot see; objectql-mediated writes to objects bound to it would land. Same class as the stated boundaries — but the census's job, by its own words, is to make a deliberate boundary distinguishable from an oversight, so it belongs on the list.

R4. Text accuracy in the same pass:ObjectQLPlugin.start, not init() (docblocks in schema-migration-plugins.ts + changeset); IDataDriver, not IDataSourceDriver.

Changeset grade

minor on @objectstack/cli@17.2.0 is right given the ruling above: a host-visible behavioural narrowing that conforms to documented behaviour is not major, and patch would under-signal a delta a host can observe. The body's "What changed / Who this affects" is the correct shape and names the delta plainly — but it repeats the R1/R2 misstatements ("raw escape hatches (driver-sql's execute() …)", "DDL (already held back by deferSchemaDdl …)") and must be corrected with them.

Summary

Design (b) is the right design, the seam is the right seam and verifiably the only one that intercepts, the tests are real and their controls are the right controls, and the contract question resolves to conformance on both limbs — no accept/reject change, no measured surface widening. What must change before this leaves draft: R1 (cover or report execute(), and correct the "off-contract" claim), R2 (correct the dropTable/deferral claim, cover or state it), R3 (name the engine-held non-default-driver residue), R4 (attribution fixes), with the changeset corrected alongside. The operator-facing sentence this PR exists to make true — "a plan writes nothing" — must not print over a run in which a contract-member write landed silently; that is the same defect this PR was filed to close, one member over.


Generated by Claude Code

…execute() escape hatch, correct the census
R1: execute() is a REQUIRED member of IDataDriver (packages/spec/src/
contracts/data-driver.ts:108, 'Raw Execution (Escape Hatch)'), not a
driver-sql extension, and the guard did not touch it: in one guarded boot
create() was refused and reported while execute("INSERT ...") landed a
row silently under a printed 'a plan writes nothing'. Chosen: REPORT, not
cover — a raw command is unknown by contract ('SQL string, shell command,
or API payload') and SQL text cannot be classified read-vs-write reliably
(a SELECT can quote INSERT in a literal; a CTE can write), while the
framework's own boot work (ensureOverlayIndex's index DDL) runs through
this seam, so a refusal would either break legitimate calls or rest on a
guess. A boot-window execute() is now forwarded, counted per driver,
warned once per driver on stderr, and named in the composition notes —
and no note claims the plan wrote nothing on such a run: the flat claim
is an OUTCOME, printed by disarm() only when it held.
R2: 'DDL already held back by deferSchemaDdl' was false for dropTable /
rotateShards — they run assertSchemaMutable (a schemaMode/dialect gate,
not a deferral check) and execute immediately. The census now states the
split and names them a genuinely open boundary.
R3: drivers the engine holds for a non-default datasource are never
published as driver.* (the only registration is
default-datasource-plugin.ts) and are invisible to the guard's scan —
now stated in the census, module header and changeset.
R4: ObjectQLPlugin.start (plugin.ts:469/:628), not init(), walks the
driver.* services; the interface is IDataDriver, not IDataSourceDriver
(no such symbol exists in spec). Corrected in docblocks, tests and the
changeset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review response — R1–R4 addressed at 840f058ecb (session session_01UngCYXF98BVpYA9hfz6NYk, same branch). Both central facts were re-derived before acting, per the review's own instruction; nothing settled was re-opened.

Re-derivations (before any edit):

  • execute(command: unknown, parameters?, options?): Promise<unknown> confirmed a required member at packages/spec/src/contracts/data-driver.ts:108, under "Raw Execution (Escape Hatch)"; grep -rn IDataSourceDriver packages/spec/src/ exits 1 — no such symbol.
  • Reproduced the hole at 6a8a182b77 with the review's control shape (guarded bootSchemaStack, composeHostStack: true, tables pre-materialized, one hook issuing both calls): create() refused, 0 rows, reported — execute("INSERT INTO sys_metadata …")landed 1 row silently while the notes printed "a plan writes nothing".

R1 — chose REPORT, not cover, for the reason the review anticipated: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (no regex guessing), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). A boot-window execute() is now forwarded, counted per driver (rawExecutions on the guard, --json-visible), warned once per driver on stderr, and named in the composition notes — and the "a plan writes nothing" sentence is now an outcome owned by disarm(): printed when it held, dropped when a raw command went through. The compose-time note no longer pre-claims it. Proof, same control shape at head: create() 0 rows + execute 1 row as before, but notes now read Refused 4 write(s) during the declaration boot: (colon = dropped claim) + Raw execute() was called 1 time(s) during the declaration boot (1 via driver.com.objectstack.driver.sql) and FORWARDED, not refused: … — pinned in both the unit file and a new integration case. Mutation proof (direction written before the run): ablating the execute arm went RED on exactly the 3 predicted cases (14 GREEN), blob-hash mutate/restore verified, trap … EXIT INT TERM, one process.

R2 — the census and changeset no longer claim deferSchemaDdl holds DDL back wholesale. Stated now: the deferral covers the initObjects/syncSchema path (flushed on purpose by apply); dropTable/rotateShards run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately during a declaration boot. Named a genuinely open boundary in the module header, PR body and changeset; not covered here.

R3 — the engine-held non-default-datasource residue is named in the module header census, PR body and changeset: only the default datasource is ever published as driver.* (default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other datasources' drivers straight to engine.registerDriver, invisible to the scan. Likely coverable by arming instances at the registerDriver seam while the guard is armed — proposed as a follow-up, not widened into this PR; scope call is the PM's.

R4 —ObjectQLPlugin.start (loop at plugin.ts:628), not init(); IDataDriver, not IDataSourceDriver — corrected in the docblocks, the unit-test fixture comment, the PR body and the changeset. No occurrence remains in packages/cli/src or .changeset/.

Gates at 840f058ecb (branch merged with current origin/main first — the derivation flagged its gate-family files stale): union via dispatch-gates.mjs --commands, reconciled with comm both directions, exact strings: named 35, ran 35, unreconciled 0. 34 green; check-test-completeness NOT MEASURED (exit 3, PREREQUISITE NOT MET, per its own text). check:dual-build-cjs-loads / check:i18n-coverage / check:type-check-debt green after building the closures they named. Plus unnamed: pnpm lint exit 0, pnpm check:nul-bytes exit 0. CLI package: 224 files, 2551 passed + 15 skipped (two unrelated files hit beforeAll timeouts under full-suite load and pass 15/15 in isolation); typecheck exit 0.

PR remains draft; labels untouched — needs:contract-review stays until you clear it.

Generated by Claude Code


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

APPROVE

Follow-up at-tier contract review under CONTRACT_REVIEW_TIER (claude-fable-5), re-verifying R1–R4 at the new head 840f058ecb. Nothing below is carried forward from the 6a8a182b77 readings — the tree moved (merge of origin/main), so every fact was re-derived on a fresh detached worktree, with the same probe re-run against a fresh build of the head (dist currency confirmed by content grep before trusting it: the new guard strings are present in dist/utils/schema-migration-plugins.js).

1. Report over cover — the right call, on premises that held

All three stated premises re-derived, plus one the fix did not claim:

  • execute(command: unknown, …) is the contract's own opaque member — "SQL string, shell command, or API payload" (packages/spec/src/contracts/data-driver.ts, "Raw Execution (Escape Hatch)"). There is no sound classifier over that input; any bounded cover either refuses allexecute() (breaking legitimate raw reads) or classifies SQL text (a guess, and unreliable in both directions).
  • The framework-traffic premise is real: packages/metadata-protocol/src/protocol.ts:5210 (ensureOverlayIndex) issues its index DDL via (driver as any).execute(sql) — a wholesale refusal would break the framework's own seam users or force an unprincipled caller allowlist.
  • The premise the commit didn't state but which seals it: execute() has no contract-shaped refusal value. Its return is unknown; a fabricated return silently corrupts raw-read callers — strictly worse than the row-write case, where honest no-op shapes exist. So a non-throwing refusal is not even constructible here.

I asked for cover-or-report in R1 and offered report as an acceptable shape; report is not just acceptable — for this member it is the only shape that neither guesses nor breaks. No bounded cover exists without one of those two defects.

2. The disarm()-owned outcome — closes it, does not relocate it (with one stated residue)

Measured behaviourally, both directions, at the new head:

  • Raw traffic present (my original probe, re-run against the built head): create() refused (0 rows, in-run control), execute("INSERT …") forwarded (1 row lands — now the documented behaviour), stderr carries the per-driver warning, and the notes read Refused 1 write(s) during the declaration boot: create() on sys_metadata … — flat claim dropped — followed by Raw execute() was called 1 time(s) … whether this boot wrote is NOT verified, and this run does NOT claim to have written nothing. No note in the run contains "a plan writes nothing". The compose-time note now states mechanism, not outcome ("with the contract's row writes refused at the driver for the whole boot") — the pre-claim is gone.
  • Raw traffic absent: the integration fix case pins Refused 6 write(s) during the declaration boot — a plan writes nothing (3/3 pass), and the unit disarm case pins the claim printed only when it held (36/36 pass).

So the sentence is no longer an assertion printed over an unverified run; it is a measurement over everything the guard can observe. The honest residue, for the record: the claim can still print on a run where a write escaped through a census-stated open boundary — dropTable/rotateShards (verified again: assertSchemaMutable gates schemaMode/dialect, not deferral), a getKnex() handle, or an engine-held second-datasource driver. For getKnex() handles and second drivers that is irreducible without the follow-up seam. For dropTable/rotateShards it is not: those members are unambiguous writes with no classification problem, and the same forward-and-count treatment execute() got would extend the outcome's honesty to them cheaply. Non-blocking — R2 offered "state as open" and that offer stands — but it belongs on the follow-up's scope.

3. R3 residue — stated accurately, and deferral is acceptable

The census/changeset wording matches what I measured, item for item: only the default datasource is ever published as driver.* (re-grepped repo-wide at 840f058ecb: the single registration is packages/runtime/src/default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other drivers straight to engine.registerDriver (now datasource-connection-service.ts:635 after the main merge — same mechanism); consequence stated correctly ("objectql-mediated writes to objects bound to it would land"). The proposed fix seam (arm at engine.registerDriver while the guard is armed) is the right one. Leaving it unimplemented is acceptable for merge: the shape is an edge beyond every measured instance of this defect, and it is stated where a reader of the mechanism will look. It should exist as a tracked issue before this merges, not only as a sentence in a PR body — filing it is the PM's, and the dropTable/rotateShards counting above should ride the same card.

4. R4 zero — re-derived, holds, with controls

  • grep -rn "IDataSourceDriver" packages/cli/src .changeset/ → exit 1 (zero). Repo-wide over all packages/**/*.ts, exit code taken from grep itself (not a pipe tail): 0 matches, exit 1. Positive control: IDataDriver present (4× in the guard module, 2× in the changeset) — the zero is not a tool failure.
  • grep -rn "ObjectQLPlugin\.init\|ObjectQLPlugin's init" packages/cli/src .changeset/ → exit 1 (zero). Control: ObjectQLPlugin.start present 3× in the module, and I re-confirmed the fact itself at the new head (packages/objectql/src/plugin.tsstart = async at :469, discovery loop at :628).

Also re-verified rather than assumed after the merge: the seam facts stand (engine registerDriver discard branch, engine write path as call-time lookup); disarm() restores the execute forwarder too (own descriptor present while armed, gone after, prototype identity preserved — pinned at test :405/:415); and the public-surface zero still holds structurally — the PR's 6-file surface touches neither packages/cli/package.json nor src/index.ts/utils/console.ts (git diff origin/main...840f058ecb on those paths: empty), so the new ForwardedRawExecution/rawExecutions symbols live in the same module the exports map does not reach.

Ruling

The clause-② ruling from the first review stands unchanged at this head: conformance, not a contract accept/reject change; no public-surface widening. All four required items are addressed and verified. needs:contract-review clears — my explicit word, for both carriers (label removal is the PM's write, not mine). The PR stays draft per the PM's parking; this approval is contract-review clearance, not a merge instruction. One expectation attached, non-blocking: the R3 follow-up (engine-seam arming, plus dropTable/rotateShards counting) becomes a tracked issue before merge.


Generated by Claude Code

Merged via the queue into main with commit bf3bbf1Sep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-13332-declaration-boot-write-suppression branch September 1, 2026 06:47
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-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone - #14053

Merged
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression
Sep 1, 2026
Merged

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone#14053
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13332

Design decision (b) — suppress writes at the driver seam for the declaration boot, so read/log hooks still run — as decided by the domain:cli PM seat in the claim comment. It was implementable as stated; the named fallback (report a seam census showing no single driver seam exists) was not taken.

Contract-review response (2026-09-01): the at-tier review's four required items are addressed at 840f058ecb — R1 (the contract's execute() escape hatch is now reported, and no run with a forwarded raw command claims it wrote nothing), R2 (the dropTable/deferral claim corrected — it is a genuinely open boundary, stated), R3 (the engine-held non-default-datasource driver residue named in the census and changeset), R4 (attributions corrected: ObjectQLPlugin.start, IDataDriver). Details inline below and in the review-response comment.

The defect

composeForDeclarations documented the plan path's guarantee in its own words — "(init runs, start does not — a plan writes nothing)" — and implemented it as a Proxy whose only override is start. packages/core/src/kernel.ts then fires three phases unconditionally after the suppressed start pass:

:402 Phase 3 trigger('kernel:ready')
:404 Phase 3.5 trigger('kernel:bootstrapped')
:416 Phase 4 trigger('kernel:listening')

A writing hook registered from init() survives the suppression on all three. The guarantee was therefore a property of plugins that happen to seed from start() — the shape of the one plugin that had been measured — and not a property of the plan path. packages/core/src/kernel.ts is untouched by this PR: the unconditional firing is the condition, not the fix site.

The seam, and why this one

The driver instance, guarded for the length of the kernel bootstrap.

  • Not the driver.* service entry.ObjectQLPlugin.start() walks the kernel's driver.* services (packages/objectql/src/plugin.ts — the discovery loop lives in start, not init) and hands each to the engine, which keys its registry by driver.name and discards a second instance under a name it already holds (packages/objectql/src/engine.ts, registerDriver). A wrapper registered in place of the service would be refused by the engine, and every objectql-mediated write would go straight to the raw driver. The instance is shared and the engine's write path is a call-time property lookup on it, so guarding the object itself covers both the plugin that resolves driver.* directly and the engine that writes through it.
  • Not a list of phase names. This card went from one phase to three before a line was written; a fourth would silently re-open the hole. The guard is phase-agnostic — one choke point, covered on the day a phase ships.
  • Refused members are read off the contract, not off a survey. The row-write surface of IDataDriver (packages/spec/src/contracts/data-driver.ts): create, update, upsert, delete, bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany. Adding a write to that interface is a spec diff every driver has to implement, so the list goes stale loudly rather than silently.
  • The contract's raw-execution escape hatch is REPORTED, not refused.execute() is a required member of IDataDriver (data-driver.ts:108, under the contract's own "Raw Execution (Escape Hatch)" heading) — on every driver, not a driver-sql extension. It is not refused, for a stated reason: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (a SELECT can quote the word INSERT in a literal; a CTE can write), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). What must never happen is the silent half: a boot-window execute() is forwarded, counted per driver, warned once per driver on stderr, and named in the composition notes — and on such a run no note claims the plan wrote nothing. The "a plan writes nothing" sentence is an outcome, printed by disarm() only on runs where it held.
  • A refusal does not throw.context.trigger() dispatches boot hooks propagating (packages/core/src/hook-dispatch.ts), so a throwing refusal would abort the bootstrap — turning "your plugin wrote during a dry run" into "you cannot get a plan at all", on the command whose whole job is to be read before a production apply. A refused call returns a contract-shaped value (create/upsert echo the caller's own payload rather than inventing an id; delete returns the contract's not-found false; updateMany/deleteMany return 0), and the run says so out loud: one warning on stderr per driver/method/object triple, plus a line in the composition notes the plan prints and --json carries.

The guard is armed by a plugin composed first in buildSchemaMigrationPlugins, so its init() is ordered ahead of every host plugin's (resolvePluginOrder is a DFS in registration order), and it re-scans in Phase 2 to pick up a driver registered by a later init(). bootSchemaStack disarms it the moment the bootstrap returns — everything after that is work the command was asked for, so os migrate apply's confirmed DDL flush and the coverage pass are untouched.

Seam census — what is NOT covered, stated rather than hidden

Every one of these is named in the module header too, so a future reader can tell a deliberate boundary from an oversight:

  • execute() — reported, never silent (see above): forwarded during the boot window, counted per driver, warned on stderr, named in the notes, and the "writes nothing" claim dropped for that run. getKnex() (a driver-sql extension, genuinely off-contract) is not intercepted; it was not the path any measured instance of this defect took.
  • DDL — and its members split.deferSchemaDdl holds back the initObjects/syncSchema path, which apply flushes on purpose once the operator confirms — guarding that would refuse the one write these commands exist to make. dropTable (and driver-sql's rotateShards) are NOT held back by that deferral: they run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately, so a hook calling driver.dropTable(...) on a managed datasource during a declaration boot executes, today as before this guard. A genuinely open boundary, stated.
  • Engine-held drivers for non-default datasources. The guard's scan covers the driver.* services the kernel publishes, and the only such registration repo-wide is the default datasource's (packages/runtime/src/default-datasource-plugin.ts). DatasourceConnectionService.connect() hands every other datasource's driver straight to engine.registerDriver, never through driver.* — so a host stack that connects a second datasource during a composed boot holds an engine-side driver this guard cannot see, and objectql-mediated writes to objects bound to it would land. Likely coverable by also arming instances at the engine.registerDriver seam while the guard is armed; proposed as a follow-up rather than widened into this PR — scope growth on a p1 is the PM's call.
  • writes a plugin makes outside the database entirely (filesystem, network).
  • work a hook defers past the end of the bootstrap; the guard covers the boot window.

Verification

The decisive test, with its positive controlpackages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts, booting a real ObjectKernel with a recording driver whose write methods live on the prototype, exactly like a real driver's:

  • positive control: the fixture plugin, composed the way a served boot composes it, writes on all three phases (create:sys_permission_set from start(), then create:sys_ai_model on ready, bootstrapped and listening);
  • the defect: composeForDeclarations alone removes only the start() seed — all three init()-registered hooks still write. Pinned, not described;
  • the fix: zero writes reach the driver, and the log-only hooks still ran — the recorded order is log-only:kernel:ready, write:kernel:ready, …, i.e. every handler executed and only the write was refused;
  • the escape hatch (R1): a guarded boot in which one hook issues both a contract create() (refused — the in-run control) and a raw execute("INSERT …"): the raw command is forwarded (the real driver ran it and its return value came back), counted (rawExecutions = 1 via that driver), and reported — and the disarm note drops the flat claim: expect(note).not.toContain('a plan writes nothing'). The refusal-only case pins the converse: with no raw command forwarded, the claim held and is printed;
  • plus: the whole row-write contract is refused (not just create()), a refused call hands back a contract-shaped value instead of throwing, and disarm() removes the shadowing own properties (the execute forwarder included) rather than overwriting them.

End to end against a real SQL driverpackages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, on a sqlite database whose tables already exist:

  • positive control: the same plugin on a boot with no host composition lands 3 rows;
  • the fix: a boot through bootSchemaStack with the writer coming from objectstack.config.tsand one handed straight to the kernel adds 0 rows (measured as a delta against the control's 3), every log-only hook ran, and composition.notes carries Refused 6 write(s) during the declaration boot — a plan writes nothing (no raw command that run — the claim held);
  • the review's own control shape (R1): in one guarded boot, create() refused (0 rows) while execute("INSERT …")landed 1 row — forwarded on purpose, and no longer silent: the notes carry Refused 4 write(s) during the declaration boot: (the colon marks the dropped claim), Raw execute() was called 1 time(s) during the declaration boot, and no note in the run claims the plan wrote nothing.

Ablations — direction stated in writing before each run.

Original guard ablation (measured at 6a8a182b77): drop the guard from the composed plugin list ⇒ predicted RED on the integration fix case and the two composition-ordering assertions, guard unit file GREEN; observed exactly that (expected 9 to be 3 — six rows the operator never asked for). Mutate/restore/measure in one process under trap … EXIT INT TERM, blob-hash-verified.

R1 reporting ablation (measured at 840f058ecb): remove the execute arm (DRIVER_RAW_EXECUTION_METHODS.slice(0, 0); anchor occurs exactly once) ⇒ predicted RED on the unit escape-hatch case, the disarm restore case (it asserts the execute shadow exists), and the integration R1 case (the unqualified claim returns); predicted GREEN elsewhere, the raw row still landing. Observed exactly that:

worktree before: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
occurrences BEFORE — original: 1, mutant: 0
worktree after : 11c2699deedbe358087e99e3dc91a95d8306e639
occurrences AFTER — original: 0, mutant: 1
Tests 3 failed | 14 passed (17) ← exactly the three predicted cases
worktree restored: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
git diff HEAD: empty · git status --porcelain: empty

Gates — union derived on the merged tree at 840f058ecb with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, reconciled with comm -23/comm -13 in both directions, exact string comparison: named 35, ran 35, unreconciled 0. Plus pnpm lint (repo-wide, eslint . --no-inline-config, exit 0 — no narrowing) and pnpm check:nul-bytes (exit 0), neither of which the derivation names.

34 of the 35 are green. node scripts/check-test-completeness.mjs is NOT MEASURED (exit 3, PREREQUISITE NOT MET) — it grades a saved turbo run test log, does not run tests, and its own text instructs a local family run to record it as NOT MEASURED. Three others first reported PREREQUISITE NOT MET for missing build output and are green after building the closures they named: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt (exit codes captured before any pipe, per gate log).

Tests at 840f058ecb: the guard unit + composition files 36/36; the #13332 integration block 3/3; the whole @objectstack/cli package 224 files / 2551 passed + 15 skipped, with two unrelated files (test/init-created-files-summary.e2e.test.ts, src/commands/datasource/envelope-unwrap.test.ts) timing out their beforeAll hooks under load during the full parallel run and passing 15/15 when re-run in isolation; pnpm --filter @objectstack/cli typecheck exit 0.

Contract review requested

Changing what a declaration boot permits is likely a contract change, and this PR does not decide that. The at-tier review (2026-09-01) ruled clause ② no on both limbs — conformance, not a contract change; no measured public-surface widening — and its four required items are addressed above.

What changed, and for whom. For os migrate plan and os migrate apply: during the kernel bootstrap, a row write issued through any driver.* instance the kernel publishes is now refused and reported instead of executed, and a raw execute() call through such an instance is forwarded but reported — with the run's notes declining to claim the plan wrote nothing. A host whose plugins wrote during that window will see contract writes stop landing and raw commands named; a host that did neither sees no change whatsoever: no disarm note is emitted when nothing was refused and no raw command went through, and the artifact-less, config-less run is untouched (that path returns before the guard is composed). Nothing outside these two commands is affected — os serve, os dev and os start do not boot through bootSchemaStack.

The changeset is minor on @objectstack/cli.

Generated by Claude Code

@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/automation/webhooks.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/kernel/http-protocol.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/knowledge.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

  • content/docs/releases/implementation-status.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v16.mdx(via updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v17.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

What this run could not see
  • 6 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 — 23 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 c75962ac3e0af69ae20f658618c4d3ab58192cc8packageMentionDocs.

Which tree this was computed on

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

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

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

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

REQUEST CHANGES

At-tier contract review under CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs on origin/main @ dda969cd71: export const CONTRACT_REVIEW_TIER = 'claude-fable-5' — this reviewer is that tier). Everything below was re-derived or re-measured on a detached worktree at head 6a8a182b77, not taken from the PR's text.

The ruling this PR routed here (both limbs, judged separately)

1. Accept/reject change — NO, this is conformance, and the PM's Clause-②: no stands. The guarantee was already shipped in three registers before this PR: the module's own printed sentence ("a plan writes nothing"), the operator docs (content/docs/deployment/cli.mdx:671 — "os migrate plan | Warns and continues — a plan writes nothing either way"), and plan.ts's own comments (:53, :141). Nothing is newly accepted or rejected at any input boundary — every host config and plugin that composed before still composes; what changed is that an effect documented not to happen now does not happen. On the "nobody could have depended on it" tension: the evidence runs the other way and it is measured, not asserted — the one known downstream host that wrote during this window (cloud, ensure-default-ai-model-plugin) treated those writes as a defect in itself (cloud#1744) and moved them out (cloud PR #1749). The affected population's own revealed dependence was on the documented guarantee, not the accidental behaviour. A host depending on writes landing during a plan/apply boot was depending on a documented untruth. No gate breach by the PM (the opus dispatch was in-band); routing the question here was still correct, because the delta is real and downstream.

2. Public-surface widening — NO, measured with a control. Built @objectstack/cli and its dependency closure twice (pnpm exec turbo run build --filter='@objectstack/cli...') at head 6a8a182b77 and base ada3834add. The package's exports map exposes exactly two type surfaces: .dist/index.d.ts and ./consoledist/utils/console.d.ts. Both are byte-identical across the two builds (cmp). The only PR-attributable .d.ts delta is dist/utils/schema-migration-plugins.d.ts (~100 changed lines: createDeclarationBootWriteGuard, DeclarationBootWriteGuard, RefusedDeclarationWrite, SchemaMigrationComposition.writeGuard?), which is not reachable through the exports map. Positive control: 5 .js files differ, including schema-migrate.js and schema-migration-plugins.js — the instrument sees this PR's change, so the entry-point zero is a real zero. (The extract-hook-body/lower-callables/lint dist diffs are from main commits the head merge pulled in — git diff ada3834a...6a8a182b -- packages/cli/src/utils/extract-hook-body.ts is empty.)

So on the contract question: clause ② does not apply — the guarantee was already the contract. The REQUEST CHANGES is not about the ruling; it is about claims the PR rests on that did not survive verification.

Verified TRUE (independently)

  • Seam claim holds.packages/objectql/src/engine.tsregisterDriver (~:5172): if (existing !== driver) → warn and discard the supplied instance — a service-level wrapper handed to the engine under a held name would indeed be dropped, and the engine already holds the raw instance from DatasourceConnectionService.connect() (packages/services/service-datasource/src/datasource-connection-service.ts:618). The engine's write path is a call-time property lookup on that shared instance (this.getDriver(object) then await driver.create(...), engine.ts :4536/:6659), so the own-property shadow intercepts objectql-mediated writes. Wrapping the instance is the only placement that intercepts. One misattribution: the driver.* discovery loop is in ObjectQLPlugin.start (packages/objectql/src/plugin.ts:469, loop at :628), not init() as the PR body and the new docblocks say — the engine's own docblock has it right. Substance unaffected; text should be corrected.
  • Non-throwing refusal premise holds.packages/core/src/hook-dispatch.ts header: boot path (kernel:ready/kernel:bootstrapped/kernel:listening) ⇒ dispatchHookPropagating — a throwing handler aborts the bootstrap, so a throwing refusal would turn "your plugin wrote during a dry run" into "no plan at all". A create() refusal echoing the caller's payload is success-shaped to the calling plugin — but the party the guarantee protects is the operator, who is told twice (stderr + notes), reads pass through honestly (a write-then-read sees no phantom row), and every alternative return either fabricates an identity or aborts the boot. Right call.
  • Scope claim holds.composeHostStack: true appears in exactly two non-test call sites: migrate/plan.ts:154 and migrate/apply.ts:181; the guard is composed only under that flag, and buildSchemaMigrationPlugins returns NOTHING_COMPOSED before createDeclarationBootWriteGuard() on the artifact-less config-less path. No bootSchemaStack caller is serve/dev/start. All three phases fire inside kernel.bootstrap() (:331, phases at :402/:414/:424), so the disarm placed after await runtime.start() covers the whole window and comes off before apply's flush.
  • The measurements are real. Re-ran the guard unit file + composition file at head: 35/35 pass. Re-ran the new integration block (-t '13332'): positive control (3 rows) and fix case (delta 0, log-only hooks ran, note carries Refused 6 write(s)) both pass.

What did not survive verification — the required changes

R1. The refusal-surface claim is false as stated, and I measured the hole. The PR: "Refused members are read off the contract, not off a survey… a driver's raw escape hatches — driver-sql's execute() and getKnex(). Off-contract for a plugin." That is wrong twice. execute(command, parameters?, options?) is a required member of the contract itselfpackages/spec/src/contracts/data-driver.ts:108, under the contract's own heading "Raw Execution (Escape Hatch)" — on every driver, not a driver-sql extension. (Also: the interface is IDataDriver; no IDataSourceDriver symbol exists in packages/spec.) Measured, with an in-run control, against the built head via bootSchemaStack (composeHostStack: true, tables pre-materialized, one host plugin whose kernel:ready hook issues both calls):

RESULT create-probe rows (guarded surface, control): 0 ← refused, reported
RESULT exec-probe rows (raw execute() INSERT): 1 ← LANDED
notes: "…row writes refused at the driver for the whole boot — a plan writes nothing."
"Refused 1 write(s) during the declaration boot … create() on sys_metadata."

The same armed guard, the same driver instance, the same hook: create() refused and reported; execute("INSERT INTO sys_metadata …") landed a row in the plan's target database silently, while the run printed "a plan writes nothing" and a refusal list that looks complete. That is precisely the enumerated-subset defect class this repo has on record: the guard reports green for the member it excluded. Required: during the boot window, an execute() call must not land silently — either refuse it (contract return is Promise<unknown>; if refusal is judged too risky because execute also serves reads and SQL cannot be classified reliably, say that reason) or forward it and report it in the same notes/stderr channel so the printed sentence stays true or is qualified. And the census text (module header, changeset) must stop calling execute off-contract.

R2. "DDL — already held back by deferSchemaDdl" is false for dropTable.setDeferredDdl (sql-driver.ts :10153) sets a flag that only the initObjects/syncSchema path checks (:9393/:9408). dropTable (:8726) runs assertSchemaMutable — which gates schemaMode/dialect (:5162), not deferral — then knex.schema.dropTableIfExists immediately; rotateShards is the same shape. A hook calling driver.dropTable(...) on a managed datasource during a plan boot executes, today and after this PR. The census line (module header + changeset) claims a mechanism that does not exist for those members; correct it, and either cover them or state them as genuinely open.

R3. One unstated residue worth naming in the census: the guard's scan covers "every driver.* instance the kernel publishes", but the engine also receives drivers that are never published as driver.*DatasourceConnectionService.connect() hands them to engine.registerDriver directly (:618), and the only driver.* registration repo-wide is the default driver (packages/runtime/src/default-datasource-plugin.ts:183). A host stack that connects a second datasource during a composed boot holds an engine-side driver the guard cannot see; objectql-mediated writes to objects bound to it would land. Same class as the stated boundaries — but the census's job, by its own words, is to make a deliberate boundary distinguishable from an oversight, so it belongs on the list.

R4. Text accuracy in the same pass:ObjectQLPlugin.start, not init() (docblocks in schema-migration-plugins.ts + changeset); IDataDriver, not IDataSourceDriver.

Changeset grade

minor on @objectstack/cli@17.2.0 is right given the ruling above: a host-visible behavioural narrowing that conforms to documented behaviour is not major, and patch would under-signal a delta a host can observe. The body's "What changed / Who this affects" is the correct shape and names the delta plainly — but it repeats the R1/R2 misstatements ("raw escape hatches (driver-sql's execute() …)", "DDL (already held back by deferSchemaDdl …)") and must be corrected with them.

Summary

Design (b) is the right design, the seam is the right seam and verifiably the only one that intercepts, the tests are real and their controls are the right controls, and the contract question resolves to conformance on both limbs — no accept/reject change, no measured surface widening. What must change before this leaves draft: R1 (cover or report execute(), and correct the "off-contract" claim), R2 (correct the dropTable/deferral claim, cover or state it), R3 (name the engine-held non-default-driver residue), R4 (attribution fixes), with the changeset corrected alongside. The operator-facing sentence this PR exists to make true — "a plan writes nothing" — must not print over a run in which a contract-member write landed silently; that is the same defect this PR was filed to close, one member over.


Generated by Claude Code

…execute() escape hatch, correct the census
R1: execute() is a REQUIRED member of IDataDriver (packages/spec/src/
contracts/data-driver.ts:108, 'Raw Execution (Escape Hatch)'), not a
driver-sql extension, and the guard did not touch it: in one guarded boot
create() was refused and reported while execute("INSERT ...") landed a
row silently under a printed 'a plan writes nothing'. Chosen: REPORT, not
cover — a raw command is unknown by contract ('SQL string, shell command,
or API payload') and SQL text cannot be classified read-vs-write reliably
(a SELECT can quote INSERT in a literal; a CTE can write), while the
framework's own boot work (ensureOverlayIndex's index DDL) runs through
this seam, so a refusal would either break legitimate calls or rest on a
guess. A boot-window execute() is now forwarded, counted per driver,
warned once per driver on stderr, and named in the composition notes —
and no note claims the plan wrote nothing on such a run: the flat claim
is an OUTCOME, printed by disarm() only when it held.
R2: 'DDL already held back by deferSchemaDdl' was false for dropTable /
rotateShards — they run assertSchemaMutable (a schemaMode/dialect gate,
not a deferral check) and execute immediately. The census now states the
split and names them a genuinely open boundary.
R3: drivers the engine holds for a non-default datasource are never
published as driver.* (the only registration is
default-datasource-plugin.ts) and are invisible to the guard's scan —
now stated in the census, module header and changeset.
R4: ObjectQLPlugin.start (plugin.ts:469/:628), not init(), walks the
driver.* services; the interface is IDataDriver, not IDataSourceDriver
(no such symbol exists in spec). Corrected in docblocks, tests and the
changeset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review response — R1–R4 addressed at 840f058ecb (session session_01UngCYXF98BVpYA9hfz6NYk, same branch). Both central facts were re-derived before acting, per the review's own instruction; nothing settled was re-opened.

Re-derivations (before any edit):

  • execute(command: unknown, parameters?, options?): Promise<unknown> confirmed a required member at packages/spec/src/contracts/data-driver.ts:108, under "Raw Execution (Escape Hatch)"; grep -rn IDataSourceDriver packages/spec/src/ exits 1 — no such symbol.
  • Reproduced the hole at 6a8a182b77 with the review's control shape (guarded bootSchemaStack, composeHostStack: true, tables pre-materialized, one hook issuing both calls): create() refused, 0 rows, reported — execute("INSERT INTO sys_metadata …")landed 1 row silently while the notes printed "a plan writes nothing".

R1 — chose REPORT, not cover, for the reason the review anticipated: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (no regex guessing), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). A boot-window execute() is now forwarded, counted per driver (rawExecutions on the guard, --json-visible), warned once per driver on stderr, and named in the composition notes — and the "a plan writes nothing" sentence is now an outcome owned by disarm(): printed when it held, dropped when a raw command went through. The compose-time note no longer pre-claims it. Proof, same control shape at head: create() 0 rows + execute 1 row as before, but notes now read Refused 4 write(s) during the declaration boot: (colon = dropped claim) + Raw execute() was called 1 time(s) during the declaration boot (1 via driver.com.objectstack.driver.sql) and FORWARDED, not refused: … — pinned in both the unit file and a new integration case. Mutation proof (direction written before the run): ablating the execute arm went RED on exactly the 3 predicted cases (14 GREEN), blob-hash mutate/restore verified, trap … EXIT INT TERM, one process.

R2 — the census and changeset no longer claim deferSchemaDdl holds DDL back wholesale. Stated now: the deferral covers the initObjects/syncSchema path (flushed on purpose by apply); dropTable/rotateShards run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately during a declaration boot. Named a genuinely open boundary in the module header, PR body and changeset; not covered here.

R3 — the engine-held non-default-datasource residue is named in the module header census, PR body and changeset: only the default datasource is ever published as driver.* (default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other datasources' drivers straight to engine.registerDriver, invisible to the scan. Likely coverable by arming instances at the registerDriver seam while the guard is armed — proposed as a follow-up, not widened into this PR; scope call is the PM's.

R4 —ObjectQLPlugin.start (loop at plugin.ts:628), not init(); IDataDriver, not IDataSourceDriver — corrected in the docblocks, the unit-test fixture comment, the PR body and the changeset. No occurrence remains in packages/cli/src or .changeset/.

Gates at 840f058ecb (branch merged with current origin/main first — the derivation flagged its gate-family files stale): union via dispatch-gates.mjs --commands, reconciled with comm both directions, exact strings: named 35, ran 35, unreconciled 0. 34 green; check-test-completeness NOT MEASURED (exit 3, PREREQUISITE NOT MET, per its own text). check:dual-build-cjs-loads / check:i18n-coverage / check:type-check-debt green after building the closures they named. Plus unnamed: pnpm lint exit 0, pnpm check:nul-bytes exit 0. CLI package: 224 files, 2551 passed + 15 skipped (two unrelated files hit beforeAll timeouts under full-suite load and pass 15/15 in isolation); typecheck exit 0.

PR remains draft; labels untouched — needs:contract-review stays until you clear it.

Generated by Claude Code


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

APPROVE

Follow-up at-tier contract review under CONTRACT_REVIEW_TIER (claude-fable-5), re-verifying R1–R4 at the new head 840f058ecb. Nothing below is carried forward from the 6a8a182b77 readings — the tree moved (merge of origin/main), so every fact was re-derived on a fresh detached worktree, with the same probe re-run against a fresh build of the head (dist currency confirmed by content grep before trusting it: the new guard strings are present in dist/utils/schema-migration-plugins.js).

1. Report over cover — the right call, on premises that held

All three stated premises re-derived, plus one the fix did not claim:

  • execute(command: unknown, …) is the contract's own opaque member — "SQL string, shell command, or API payload" (packages/spec/src/contracts/data-driver.ts, "Raw Execution (Escape Hatch)"). There is no sound classifier over that input; any bounded cover either refuses allexecute() (breaking legitimate raw reads) or classifies SQL text (a guess, and unreliable in both directions).
  • The framework-traffic premise is real: packages/metadata-protocol/src/protocol.ts:5210 (ensureOverlayIndex) issues its index DDL via (driver as any).execute(sql) — a wholesale refusal would break the framework's own seam users or force an unprincipled caller allowlist.
  • The premise the commit didn't state but which seals it: execute() has no contract-shaped refusal value. Its return is unknown; a fabricated return silently corrupts raw-read callers — strictly worse than the row-write case, where honest no-op shapes exist. So a non-throwing refusal is not even constructible here.

I asked for cover-or-report in R1 and offered report as an acceptable shape; report is not just acceptable — for this member it is the only shape that neither guesses nor breaks. No bounded cover exists without one of those two defects.

2. The disarm()-owned outcome — closes it, does not relocate it (with one stated residue)

Measured behaviourally, both directions, at the new head:

  • Raw traffic present (my original probe, re-run against the built head): create() refused (0 rows, in-run control), execute("INSERT …") forwarded (1 row lands — now the documented behaviour), stderr carries the per-driver warning, and the notes read Refused 1 write(s) during the declaration boot: create() on sys_metadata … — flat claim dropped — followed by Raw execute() was called 1 time(s) … whether this boot wrote is NOT verified, and this run does NOT claim to have written nothing. No note in the run contains "a plan writes nothing". The compose-time note now states mechanism, not outcome ("with the contract's row writes refused at the driver for the whole boot") — the pre-claim is gone.
  • Raw traffic absent: the integration fix case pins Refused 6 write(s) during the declaration boot — a plan writes nothing (3/3 pass), and the unit disarm case pins the claim printed only when it held (36/36 pass).

So the sentence is no longer an assertion printed over an unverified run; it is a measurement over everything the guard can observe. The honest residue, for the record: the claim can still print on a run where a write escaped through a census-stated open boundary — dropTable/rotateShards (verified again: assertSchemaMutable gates schemaMode/dialect, not deferral), a getKnex() handle, or an engine-held second-datasource driver. For getKnex() handles and second drivers that is irreducible without the follow-up seam. For dropTable/rotateShards it is not: those members are unambiguous writes with no classification problem, and the same forward-and-count treatment execute() got would extend the outcome's honesty to them cheaply. Non-blocking — R2 offered "state as open" and that offer stands — but it belongs on the follow-up's scope.

3. R3 residue — stated accurately, and deferral is acceptable

The census/changeset wording matches what I measured, item for item: only the default datasource is ever published as driver.* (re-grepped repo-wide at 840f058ecb: the single registration is packages/runtime/src/default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other drivers straight to engine.registerDriver (now datasource-connection-service.ts:635 after the main merge — same mechanism); consequence stated correctly ("objectql-mediated writes to objects bound to it would land"). The proposed fix seam (arm at engine.registerDriver while the guard is armed) is the right one. Leaving it unimplemented is acceptable for merge: the shape is an edge beyond every measured instance of this defect, and it is stated where a reader of the mechanism will look. It should exist as a tracked issue before this merges, not only as a sentence in a PR body — filing it is the PM's, and the dropTable/rotateShards counting above should ride the same card.

4. R4 zero — re-derived, holds, with controls

  • grep -rn "IDataSourceDriver" packages/cli/src .changeset/ → exit 1 (zero). Repo-wide over all packages/**/*.ts, exit code taken from grep itself (not a pipe tail): 0 matches, exit 1. Positive control: IDataDriver present (4× in the guard module, 2× in the changeset) — the zero is not a tool failure.
  • grep -rn "ObjectQLPlugin\.init\|ObjectQLPlugin's init" packages/cli/src .changeset/ → exit 1 (zero). Control: ObjectQLPlugin.start present 3× in the module, and I re-confirmed the fact itself at the new head (packages/objectql/src/plugin.tsstart = async at :469, discovery loop at :628).

Also re-verified rather than assumed after the merge: the seam facts stand (engine registerDriver discard branch, engine write path as call-time lookup); disarm() restores the execute forwarder too (own descriptor present while armed, gone after, prototype identity preserved — pinned at test :405/:415); and the public-surface zero still holds structurally — the PR's 6-file surface touches neither packages/cli/package.json nor src/index.ts/utils/console.ts (git diff origin/main...840f058ecb on those paths: empty), so the new ForwardedRawExecution/rawExecutions symbols live in the same module the exports map does not reach.

Ruling

The clause-② ruling from the first review stands unchanged at this head: conformance, not a contract accept/reject change; no public-surface widening. All four required items are addressed and verified. needs:contract-review clears — my explicit word, for both carriers (label removal is the PM's write, not mine). The PR stays draft per the PM's parking; this approval is contract-review clearance, not a merge instruction. One expectation attached, non-blocking: the R3 follow-up (engine-seam arming, plus dropTable/rotateShards counting) becomes a tracked issue before merge.


Generated by Claude Code

Merged via the queue into main with commit bf3bbf1Sep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-13332-declaration-boot-write-suppression branch September 1, 2026 06:47
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-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone by os-steve · Pull Request #14053 · objectstack-ai/objectstack · GitHub
Skip to content

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone - #14053

Merged
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression
Sep 1, 2026
Merged

fix(cli): make a declaration boot write nothing at the driver seam, not by suppressing start() alone#14053
os-steve merged 8 commits into
mainfrom
claude/issue-13332-declaration-boot-write-suppression

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13332

Design decision (b) — suppress writes at the driver seam for the declaration boot, so read/log hooks still run — as decided by the domain:cli PM seat in the claim comment. It was implementable as stated; the named fallback (report a seam census showing no single driver seam exists) was not taken.

Contract-review response (2026-09-01): the at-tier review's four required items are addressed at 840f058ecb — R1 (the contract's execute() escape hatch is now reported, and no run with a forwarded raw command claims it wrote nothing), R2 (the dropTable/deferral claim corrected — it is a genuinely open boundary, stated), R3 (the engine-held non-default-datasource driver residue named in the census and changeset), R4 (attributions corrected: ObjectQLPlugin.start, IDataDriver). Details inline below and in the review-response comment.

The defect

composeForDeclarations documented the plan path's guarantee in its own words — "(init runs, start does not — a plan writes nothing)" — and implemented it as a Proxy whose only override is start. packages/core/src/kernel.ts then fires three phases unconditionally after the suppressed start pass:

:402 Phase 3 trigger('kernel:ready')
:404 Phase 3.5 trigger('kernel:bootstrapped')
:416 Phase 4 trigger('kernel:listening')

A writing hook registered from init() survives the suppression on all three. The guarantee was therefore a property of plugins that happen to seed from start() — the shape of the one plugin that had been measured — and not a property of the plan path. packages/core/src/kernel.ts is untouched by this PR: the unconditional firing is the condition, not the fix site.

The seam, and why this one

The driver instance, guarded for the length of the kernel bootstrap.

  • Not the driver.* service entry.ObjectQLPlugin.start() walks the kernel's driver.* services (packages/objectql/src/plugin.ts — the discovery loop lives in start, not init) and hands each to the engine, which keys its registry by driver.name and discards a second instance under a name it already holds (packages/objectql/src/engine.ts, registerDriver). A wrapper registered in place of the service would be refused by the engine, and every objectql-mediated write would go straight to the raw driver. The instance is shared and the engine's write path is a call-time property lookup on it, so guarding the object itself covers both the plugin that resolves driver.* directly and the engine that writes through it.
  • Not a list of phase names. This card went from one phase to three before a line was written; a fourth would silently re-open the hole. The guard is phase-agnostic — one choke point, covered on the day a phase ships.
  • Refused members are read off the contract, not off a survey. The row-write surface of IDataDriver (packages/spec/src/contracts/data-driver.ts): create, update, upsert, delete, bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany. Adding a write to that interface is a spec diff every driver has to implement, so the list goes stale loudly rather than silently.
  • The contract's raw-execution escape hatch is REPORTED, not refused.execute() is a required member of IDataDriver (data-driver.ts:108, under the contract's own "Raw Execution (Escape Hatch)" heading) — on every driver, not a driver-sql extension. It is not refused, for a stated reason: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (a SELECT can quote the word INSERT in a literal; a CTE can write), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). What must never happen is the silent half: a boot-window execute() is forwarded, counted per driver, warned once per driver on stderr, and named in the composition notes — and on such a run no note claims the plan wrote nothing. The "a plan writes nothing" sentence is an outcome, printed by disarm() only on runs where it held.
  • A refusal does not throw.context.trigger() dispatches boot hooks propagating (packages/core/src/hook-dispatch.ts), so a throwing refusal would abort the bootstrap — turning "your plugin wrote during a dry run" into "you cannot get a plan at all", on the command whose whole job is to be read before a production apply. A refused call returns a contract-shaped value (create/upsert echo the caller's own payload rather than inventing an id; delete returns the contract's not-found false; updateMany/deleteMany return 0), and the run says so out loud: one warning on stderr per driver/method/object triple, plus a line in the composition notes the plan prints and --json carries.

The guard is armed by a plugin composed first in buildSchemaMigrationPlugins, so its init() is ordered ahead of every host plugin's (resolvePluginOrder is a DFS in registration order), and it re-scans in Phase 2 to pick up a driver registered by a later init(). bootSchemaStack disarms it the moment the bootstrap returns — everything after that is work the command was asked for, so os migrate apply's confirmed DDL flush and the coverage pass are untouched.

Seam census — what is NOT covered, stated rather than hidden

Every one of these is named in the module header too, so a future reader can tell a deliberate boundary from an oversight:

  • execute() — reported, never silent (see above): forwarded during the boot window, counted per driver, warned on stderr, named in the notes, and the "writes nothing" claim dropped for that run. getKnex() (a driver-sql extension, genuinely off-contract) is not intercepted; it was not the path any measured instance of this defect took.
  • DDL — and its members split.deferSchemaDdl holds back the initObjects/syncSchema path, which apply flushes on purpose once the operator confirms — guarding that would refuse the one write these commands exist to make. dropTable (and driver-sql's rotateShards) are NOT held back by that deferral: they run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately, so a hook calling driver.dropTable(...) on a managed datasource during a declaration boot executes, today as before this guard. A genuinely open boundary, stated.
  • Engine-held drivers for non-default datasources. The guard's scan covers the driver.* services the kernel publishes, and the only such registration repo-wide is the default datasource's (packages/runtime/src/default-datasource-plugin.ts). DatasourceConnectionService.connect() hands every other datasource's driver straight to engine.registerDriver, never through driver.* — so a host stack that connects a second datasource during a composed boot holds an engine-side driver this guard cannot see, and objectql-mediated writes to objects bound to it would land. Likely coverable by also arming instances at the engine.registerDriver seam while the guard is armed; proposed as a follow-up rather than widened into this PR — scope growth on a p1 is the PM's call.
  • writes a plugin makes outside the database entirely (filesystem, network).
  • work a hook defers past the end of the bootstrap; the guard covers the boot window.

Verification

The decisive test, with its positive controlpackages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts, booting a real ObjectKernel with a recording driver whose write methods live on the prototype, exactly like a real driver's:

  • positive control: the fixture plugin, composed the way a served boot composes it, writes on all three phases (create:sys_permission_set from start(), then create:sys_ai_model on ready, bootstrapped and listening);
  • the defect: composeForDeclarations alone removes only the start() seed — all three init()-registered hooks still write. Pinned, not described;
  • the fix: zero writes reach the driver, and the log-only hooks still ran — the recorded order is log-only:kernel:ready, write:kernel:ready, …, i.e. every handler executed and only the write was refused;
  • the escape hatch (R1): a guarded boot in which one hook issues both a contract create() (refused — the in-run control) and a raw execute("INSERT …"): the raw command is forwarded (the real driver ran it and its return value came back), counted (rawExecutions = 1 via that driver), and reported — and the disarm note drops the flat claim: expect(note).not.toContain('a plan writes nothing'). The refusal-only case pins the converse: with no raw command forwarded, the claim held and is printed;
  • plus: the whole row-write contract is refused (not just create()), a refused call hands back a contract-shaped value instead of throwing, and disarm() removes the shadowing own properties (the execute forwarder included) rather than overwriting them.

End to end against a real SQL driverpackages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, on a sqlite database whose tables already exist:

  • positive control: the same plugin on a boot with no host composition lands 3 rows;
  • the fix: a boot through bootSchemaStack with the writer coming from objectstack.config.tsand one handed straight to the kernel adds 0 rows (measured as a delta against the control's 3), every log-only hook ran, and composition.notes carries Refused 6 write(s) during the declaration boot — a plan writes nothing (no raw command that run — the claim held);
  • the review's own control shape (R1): in one guarded boot, create() refused (0 rows) while execute("INSERT …")landed 1 row — forwarded on purpose, and no longer silent: the notes carry Refused 4 write(s) during the declaration boot: (the colon marks the dropped claim), Raw execute() was called 1 time(s) during the declaration boot, and no note in the run claims the plan wrote nothing.

Ablations — direction stated in writing before each run.

Original guard ablation (measured at 6a8a182b77): drop the guard from the composed plugin list ⇒ predicted RED on the integration fix case and the two composition-ordering assertions, guard unit file GREEN; observed exactly that (expected 9 to be 3 — six rows the operator never asked for). Mutate/restore/measure in one process under trap … EXIT INT TERM, blob-hash-verified.

R1 reporting ablation (measured at 840f058ecb): remove the execute arm (DRIVER_RAW_EXECUTION_METHODS.slice(0, 0); anchor occurs exactly once) ⇒ predicted RED on the unit escape-hatch case, the disarm restore case (it asserts the execute shadow exists), and the integration R1 case (the unqualified claim returns); predicted GREEN elsewhere, the raw row still landing. Observed exactly that:

worktree before: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
occurrences BEFORE — original: 1, mutant: 0
worktree after : 11c2699deedbe358087e99e3dc91a95d8306e639
occurrences AFTER — original: 0, mutant: 1
Tests 3 failed | 14 passed (17) ← exactly the three predicted cases
worktree restored: cfa3fcf02e7fa214343b566694e373782d4e7c79 (== HEAD blob)
git diff HEAD: empty · git status --porcelain: empty

Gates — union derived on the merged tree at 840f058ecb with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, reconciled with comm -23/comm -13 in both directions, exact string comparison: named 35, ran 35, unreconciled 0. Plus pnpm lint (repo-wide, eslint . --no-inline-config, exit 0 — no narrowing) and pnpm check:nul-bytes (exit 0), neither of which the derivation names.

34 of the 35 are green. node scripts/check-test-completeness.mjs is NOT MEASURED (exit 3, PREREQUISITE NOT MET) — it grades a saved turbo run test log, does not run tests, and its own text instructs a local family run to record it as NOT MEASURED. Three others first reported PREREQUISITE NOT MET for missing build output and are green after building the closures they named: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt (exit codes captured before any pipe, per gate log).

Tests at 840f058ecb: the guard unit + composition files 36/36; the #13332 integration block 3/3; the whole @objectstack/cli package 224 files / 2551 passed + 15 skipped, with two unrelated files (test/init-created-files-summary.e2e.test.ts, src/commands/datasource/envelope-unwrap.test.ts) timing out their beforeAll hooks under load during the full parallel run and passing 15/15 when re-run in isolation; pnpm --filter @objectstack/cli typecheck exit 0.

Contract review requested

Changing what a declaration boot permits is likely a contract change, and this PR does not decide that. The at-tier review (2026-09-01) ruled clause ② no on both limbs — conformance, not a contract change; no measured public-surface widening — and its four required items are addressed above.

What changed, and for whom. For os migrate plan and os migrate apply: during the kernel bootstrap, a row write issued through any driver.* instance the kernel publishes is now refused and reported instead of executed, and a raw execute() call through such an instance is forwarded but reported — with the run's notes declining to claim the plan wrote nothing. A host whose plugins wrote during that window will see contract writes stop landing and raw commands named; a host that did neither sees no change whatsoever: no disarm note is emitted when nothing was refused and no raw command went through, and the artifact-less, config-less run is untouched (that path returns before the guard is composed). Nothing outside these two commands is affected — os serve, os dev and os start do not boot through bootSchemaStack.

The changeset is minor on @objectstack/cli.

Generated by Claude Code

@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/automation/webhooks.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/kernel/http-protocol.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/protocol/knowledge.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

  • content/docs/releases/implementation-status.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v16.mdx(via updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))
  • content/docs/releases/v17.mdx(via deleteMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue), updateMany (literal, a string literal in DRIVER_ROW_WRITE_METHODS; a string literal in refusalValue))

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

What this run could not see
  • 6 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 — 23 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 c75962ac3e0af69ae20f658618c4d3ab58192cc8packageMentionDocs.

Which tree this was computed on

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

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

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

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

REQUEST CHANGES

At-tier contract review under CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs on origin/main @ dda969cd71: export const CONTRACT_REVIEW_TIER = 'claude-fable-5' — this reviewer is that tier). Everything below was re-derived or re-measured on a detached worktree at head 6a8a182b77, not taken from the PR's text.

The ruling this PR routed here (both limbs, judged separately)

1. Accept/reject change — NO, this is conformance, and the PM's Clause-②: no stands. The guarantee was already shipped in three registers before this PR: the module's own printed sentence ("a plan writes nothing"), the operator docs (content/docs/deployment/cli.mdx:671 — "os migrate plan | Warns and continues — a plan writes nothing either way"), and plan.ts's own comments (:53, :141). Nothing is newly accepted or rejected at any input boundary — every host config and plugin that composed before still composes; what changed is that an effect documented not to happen now does not happen. On the "nobody could have depended on it" tension: the evidence runs the other way and it is measured, not asserted — the one known downstream host that wrote during this window (cloud, ensure-default-ai-model-plugin) treated those writes as a defect in itself (cloud#1744) and moved them out (cloud PR #1749). The affected population's own revealed dependence was on the documented guarantee, not the accidental behaviour. A host depending on writes landing during a plan/apply boot was depending on a documented untruth. No gate breach by the PM (the opus dispatch was in-band); routing the question here was still correct, because the delta is real and downstream.

2. Public-surface widening — NO, measured with a control. Built @objectstack/cli and its dependency closure twice (pnpm exec turbo run build --filter='@objectstack/cli...') at head 6a8a182b77 and base ada3834add. The package's exports map exposes exactly two type surfaces: .dist/index.d.ts and ./consoledist/utils/console.d.ts. Both are byte-identical across the two builds (cmp). The only PR-attributable .d.ts delta is dist/utils/schema-migration-plugins.d.ts (~100 changed lines: createDeclarationBootWriteGuard, DeclarationBootWriteGuard, RefusedDeclarationWrite, SchemaMigrationComposition.writeGuard?), which is not reachable through the exports map. Positive control: 5 .js files differ, including schema-migrate.js and schema-migration-plugins.js — the instrument sees this PR's change, so the entry-point zero is a real zero. (The extract-hook-body/lower-callables/lint dist diffs are from main commits the head merge pulled in — git diff ada3834a...6a8a182b -- packages/cli/src/utils/extract-hook-body.ts is empty.)

So on the contract question: clause ② does not apply — the guarantee was already the contract. The REQUEST CHANGES is not about the ruling; it is about claims the PR rests on that did not survive verification.

Verified TRUE (independently)

  • Seam claim holds.packages/objectql/src/engine.tsregisterDriver (~:5172): if (existing !== driver) → warn and discard the supplied instance — a service-level wrapper handed to the engine under a held name would indeed be dropped, and the engine already holds the raw instance from DatasourceConnectionService.connect() (packages/services/service-datasource/src/datasource-connection-service.ts:618). The engine's write path is a call-time property lookup on that shared instance (this.getDriver(object) then await driver.create(...), engine.ts :4536/:6659), so the own-property shadow intercepts objectql-mediated writes. Wrapping the instance is the only placement that intercepts. One misattribution: the driver.* discovery loop is in ObjectQLPlugin.start (packages/objectql/src/plugin.ts:469, loop at :628), not init() as the PR body and the new docblocks say — the engine's own docblock has it right. Substance unaffected; text should be corrected.
  • Non-throwing refusal premise holds.packages/core/src/hook-dispatch.ts header: boot path (kernel:ready/kernel:bootstrapped/kernel:listening) ⇒ dispatchHookPropagating — a throwing handler aborts the bootstrap, so a throwing refusal would turn "your plugin wrote during a dry run" into "no plan at all". A create() refusal echoing the caller's payload is success-shaped to the calling plugin — but the party the guarantee protects is the operator, who is told twice (stderr + notes), reads pass through honestly (a write-then-read sees no phantom row), and every alternative return either fabricates an identity or aborts the boot. Right call.
  • Scope claim holds.composeHostStack: true appears in exactly two non-test call sites: migrate/plan.ts:154 and migrate/apply.ts:181; the guard is composed only under that flag, and buildSchemaMigrationPlugins returns NOTHING_COMPOSED before createDeclarationBootWriteGuard() on the artifact-less config-less path. No bootSchemaStack caller is serve/dev/start. All three phases fire inside kernel.bootstrap() (:331, phases at :402/:414/:424), so the disarm placed after await runtime.start() covers the whole window and comes off before apply's flush.
  • The measurements are real. Re-ran the guard unit file + composition file at head: 35/35 pass. Re-ran the new integration block (-t '13332'): positive control (3 rows) and fix case (delta 0, log-only hooks ran, note carries Refused 6 write(s)) both pass.

What did not survive verification — the required changes

R1. The refusal-surface claim is false as stated, and I measured the hole. The PR: "Refused members are read off the contract, not off a survey… a driver's raw escape hatches — driver-sql's execute() and getKnex(). Off-contract for a plugin." That is wrong twice. execute(command, parameters?, options?) is a required member of the contract itselfpackages/spec/src/contracts/data-driver.ts:108, under the contract's own heading "Raw Execution (Escape Hatch)" — on every driver, not a driver-sql extension. (Also: the interface is IDataDriver; no IDataSourceDriver symbol exists in packages/spec.) Measured, with an in-run control, against the built head via bootSchemaStack (composeHostStack: true, tables pre-materialized, one host plugin whose kernel:ready hook issues both calls):

RESULT create-probe rows (guarded surface, control): 0 ← refused, reported
RESULT exec-probe rows (raw execute() INSERT): 1 ← LANDED
notes: "…row writes refused at the driver for the whole boot — a plan writes nothing."
"Refused 1 write(s) during the declaration boot … create() on sys_metadata."

The same armed guard, the same driver instance, the same hook: create() refused and reported; execute("INSERT INTO sys_metadata …") landed a row in the plan's target database silently, while the run printed "a plan writes nothing" and a refusal list that looks complete. That is precisely the enumerated-subset defect class this repo has on record: the guard reports green for the member it excluded. Required: during the boot window, an execute() call must not land silently — either refuse it (contract return is Promise<unknown>; if refusal is judged too risky because execute also serves reads and SQL cannot be classified reliably, say that reason) or forward it and report it in the same notes/stderr channel so the printed sentence stays true or is qualified. And the census text (module header, changeset) must stop calling execute off-contract.

R2. "DDL — already held back by deferSchemaDdl" is false for dropTable.setDeferredDdl (sql-driver.ts :10153) sets a flag that only the initObjects/syncSchema path checks (:9393/:9408). dropTable (:8726) runs assertSchemaMutable — which gates schemaMode/dialect (:5162), not deferral — then knex.schema.dropTableIfExists immediately; rotateShards is the same shape. A hook calling driver.dropTable(...) on a managed datasource during a plan boot executes, today and after this PR. The census line (module header + changeset) claims a mechanism that does not exist for those members; correct it, and either cover them or state them as genuinely open.

R3. One unstated residue worth naming in the census: the guard's scan covers "every driver.* instance the kernel publishes", but the engine also receives drivers that are never published as driver.*DatasourceConnectionService.connect() hands them to engine.registerDriver directly (:618), and the only driver.* registration repo-wide is the default driver (packages/runtime/src/default-datasource-plugin.ts:183). A host stack that connects a second datasource during a composed boot holds an engine-side driver the guard cannot see; objectql-mediated writes to objects bound to it would land. Same class as the stated boundaries — but the census's job, by its own words, is to make a deliberate boundary distinguishable from an oversight, so it belongs on the list.

R4. Text accuracy in the same pass:ObjectQLPlugin.start, not init() (docblocks in schema-migration-plugins.ts + changeset); IDataDriver, not IDataSourceDriver.

Changeset grade

minor on @objectstack/cli@17.2.0 is right given the ruling above: a host-visible behavioural narrowing that conforms to documented behaviour is not major, and patch would under-signal a delta a host can observe. The body's "What changed / Who this affects" is the correct shape and names the delta plainly — but it repeats the R1/R2 misstatements ("raw escape hatches (driver-sql's execute() …)", "DDL (already held back by deferSchemaDdl …)") and must be corrected with them.

Summary

Design (b) is the right design, the seam is the right seam and verifiably the only one that intercepts, the tests are real and their controls are the right controls, and the contract question resolves to conformance on both limbs — no accept/reject change, no measured surface widening. What must change before this leaves draft: R1 (cover or report execute(), and correct the "off-contract" claim), R2 (correct the dropTable/deferral claim, cover or state it), R3 (name the engine-held non-default-driver residue), R4 (attribution fixes), with the changeset corrected alongside. The operator-facing sentence this PR exists to make true — "a plan writes nothing" — must not print over a run in which a contract-member write landed silently; that is the same defect this PR was filed to close, one member over.


Generated by Claude Code

…execute() escape hatch, correct the census
R1: execute() is a REQUIRED member of IDataDriver (packages/spec/src/
contracts/data-driver.ts:108, 'Raw Execution (Escape Hatch)'), not a
driver-sql extension, and the guard did not touch it: in one guarded boot
create() was refused and reported while execute("INSERT ...") landed a
row silently under a printed 'a plan writes nothing'. Chosen: REPORT, not
cover — a raw command is unknown by contract ('SQL string, shell command,
or API payload') and SQL text cannot be classified read-vs-write reliably
(a SELECT can quote INSERT in a literal; a CTE can write), while the
framework's own boot work (ensureOverlayIndex's index DDL) runs through
this seam, so a refusal would either break legitimate calls or rest on a
guess. A boot-window execute() is now forwarded, counted per driver,
warned once per driver on stderr, and named in the composition notes —
and no note claims the plan wrote nothing on such a run: the flat claim
is an OUTCOME, printed by disarm() only when it held.
R2: 'DDL already held back by deferSchemaDdl' was false for dropTable /
rotateShards — they run assertSchemaMutable (a schemaMode/dialect gate,
not a deferral check) and execute immediately. The census now states the
split and names them a genuinely open boundary.
R3: drivers the engine holds for a non-default datasource are never
published as driver.* (the only registration is
default-datasource-plugin.ts) and are invisible to the guard's scan —
now stated in the census, module header and changeset.
R4: ObjectQLPlugin.start (plugin.ts:469/:628), not init(), walks the
driver.* services; the interface is IDataDriver, not IDataSourceDriver
(no such symbol exists in spec). Corrected in docblocks, tests and the
changeset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review response — R1–R4 addressed at 840f058ecb (session session_01UngCYXF98BVpYA9hfz6NYk, same branch). Both central facts were re-derived before acting, per the review's own instruction; nothing settled was re-opened.

Re-derivations (before any edit):

  • execute(command: unknown, parameters?, options?): Promise<unknown> confirmed a required member at packages/spec/src/contracts/data-driver.ts:108, under "Raw Execution (Escape Hatch)"; grep -rn IDataSourceDriver packages/spec/src/ exits 1 — no such symbol.
  • Reproduced the hole at 6a8a182b77 with the review's control shape (guarded bootSchemaStack, composeHostStack: true, tables pre-materialized, one hook issuing both calls): create() refused, 0 rows, reported — execute("INSERT INTO sys_metadata …")landed 1 row silently while the notes printed "a plan writes nothing".

R1 — chose REPORT, not cover, for the reason the review anticipated: the command is unknown by contract ("SQL string, shell command, or API payload"), SQL text cannot be classified read-vs-write reliably in either direction (no regex guessing), and the framework's own boot-legitimate work runs through this seam (ensureOverlayIndex's index DDL). A boot-window execute() is now forwarded, counted per driver (rawExecutions on the guard, --json-visible), warned once per driver on stderr, and named in the composition notes — and the "a plan writes nothing" sentence is now an outcome owned by disarm(): printed when it held, dropped when a raw command went through. The compose-time note no longer pre-claims it. Proof, same control shape at head: create() 0 rows + execute 1 row as before, but notes now read Refused 4 write(s) during the declaration boot: (colon = dropped claim) + Raw execute() was called 1 time(s) during the declaration boot (1 via driver.com.objectstack.driver.sql) and FORWARDED, not refused: … — pinned in both the unit file and a new integration case. Mutation proof (direction written before the run): ablating the execute arm went RED on exactly the 3 predicted cases (14 GREEN), blob-hash mutate/restore verified, trap … EXIT INT TERM, one process.

R2 — the census and changeset no longer claim deferSchemaDdl holds DDL back wholesale. Stated now: the deferral covers the initObjects/syncSchema path (flushed on purpose by apply); dropTable/rotateShards run assertSchemaMutable — a schemaMode/dialect gate, not a deferral check — and execute immediately during a declaration boot. Named a genuinely open boundary in the module header, PR body and changeset; not covered here.

R3 — the engine-held non-default-datasource residue is named in the module header census, PR body and changeset: only the default datasource is ever published as driver.* (default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other datasources' drivers straight to engine.registerDriver, invisible to the scan. Likely coverable by arming instances at the registerDriver seam while the guard is armed — proposed as a follow-up, not widened into this PR; scope call is the PM's.

R4 —ObjectQLPlugin.start (loop at plugin.ts:628), not init(); IDataDriver, not IDataSourceDriver — corrected in the docblocks, the unit-test fixture comment, the PR body and the changeset. No occurrence remains in packages/cli/src or .changeset/.

Gates at 840f058ecb (branch merged with current origin/main first — the derivation flagged its gate-family files stale): union via dispatch-gates.mjs --commands, reconciled with comm both directions, exact strings: named 35, ran 35, unreconciled 0. 34 green; check-test-completeness NOT MEASURED (exit 3, PREREQUISITE NOT MET, per its own text). check:dual-build-cjs-loads / check:i18n-coverage / check:type-check-debt green after building the closures they named. Plus unnamed: pnpm lint exit 0, pnpm check:nul-bytes exit 0. CLI package: 224 files, 2551 passed + 15 skipped (two unrelated files hit beforeAll timeouts under full-suite load and pass 15/15 in isolation); typecheck exit 0.

PR remains draft; labels untouched — needs:contract-review stays until you clear it.

Generated by Claude Code


Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

APPROVE

Follow-up at-tier contract review under CONTRACT_REVIEW_TIER (claude-fable-5), re-verifying R1–R4 at the new head 840f058ecb. Nothing below is carried forward from the 6a8a182b77 readings — the tree moved (merge of origin/main), so every fact was re-derived on a fresh detached worktree, with the same probe re-run against a fresh build of the head (dist currency confirmed by content grep before trusting it: the new guard strings are present in dist/utils/schema-migration-plugins.js).

1. Report over cover — the right call, on premises that held

All three stated premises re-derived, plus one the fix did not claim:

  • execute(command: unknown, …) is the contract's own opaque member — "SQL string, shell command, or API payload" (packages/spec/src/contracts/data-driver.ts, "Raw Execution (Escape Hatch)"). There is no sound classifier over that input; any bounded cover either refuses allexecute() (breaking legitimate raw reads) or classifies SQL text (a guess, and unreliable in both directions).
  • The framework-traffic premise is real: packages/metadata-protocol/src/protocol.ts:5210 (ensureOverlayIndex) issues its index DDL via (driver as any).execute(sql) — a wholesale refusal would break the framework's own seam users or force an unprincipled caller allowlist.
  • The premise the commit didn't state but which seals it: execute() has no contract-shaped refusal value. Its return is unknown; a fabricated return silently corrupts raw-read callers — strictly worse than the row-write case, where honest no-op shapes exist. So a non-throwing refusal is not even constructible here.

I asked for cover-or-report in R1 and offered report as an acceptable shape; report is not just acceptable — for this member it is the only shape that neither guesses nor breaks. No bounded cover exists without one of those two defects.

2. The disarm()-owned outcome — closes it, does not relocate it (with one stated residue)

Measured behaviourally, both directions, at the new head:

  • Raw traffic present (my original probe, re-run against the built head): create() refused (0 rows, in-run control), execute("INSERT …") forwarded (1 row lands — now the documented behaviour), stderr carries the per-driver warning, and the notes read Refused 1 write(s) during the declaration boot: create() on sys_metadata … — flat claim dropped — followed by Raw execute() was called 1 time(s) … whether this boot wrote is NOT verified, and this run does NOT claim to have written nothing. No note in the run contains "a plan writes nothing". The compose-time note now states mechanism, not outcome ("with the contract's row writes refused at the driver for the whole boot") — the pre-claim is gone.
  • Raw traffic absent: the integration fix case pins Refused 6 write(s) during the declaration boot — a plan writes nothing (3/3 pass), and the unit disarm case pins the claim printed only when it held (36/36 pass).

So the sentence is no longer an assertion printed over an unverified run; it is a measurement over everything the guard can observe. The honest residue, for the record: the claim can still print on a run where a write escaped through a census-stated open boundary — dropTable/rotateShards (verified again: assertSchemaMutable gates schemaMode/dialect, not deferral), a getKnex() handle, or an engine-held second-datasource driver. For getKnex() handles and second drivers that is irreducible without the follow-up seam. For dropTable/rotateShards it is not: those members are unambiguous writes with no classification problem, and the same forward-and-count treatment execute() got would extend the outcome's honesty to them cheaply. Non-blocking — R2 offered "state as open" and that offer stands — but it belongs on the follow-up's scope.

3. R3 residue — stated accurately, and deferral is acceptable

The census/changeset wording matches what I measured, item for item: only the default datasource is ever published as driver.* (re-grepped repo-wide at 840f058ecb: the single registration is packages/runtime/src/default-datasource-plugin.ts:183); DatasourceConnectionService.connect() hands other drivers straight to engine.registerDriver (now datasource-connection-service.ts:635 after the main merge — same mechanism); consequence stated correctly ("objectql-mediated writes to objects bound to it would land"). The proposed fix seam (arm at engine.registerDriver while the guard is armed) is the right one. Leaving it unimplemented is acceptable for merge: the shape is an edge beyond every measured instance of this defect, and it is stated where a reader of the mechanism will look. It should exist as a tracked issue before this merges, not only as a sentence in a PR body — filing it is the PM's, and the dropTable/rotateShards counting above should ride the same card.

4. R4 zero — re-derived, holds, with controls

  • grep -rn "IDataSourceDriver" packages/cli/src .changeset/ → exit 1 (zero). Repo-wide over all packages/**/*.ts, exit code taken from grep itself (not a pipe tail): 0 matches, exit 1. Positive control: IDataDriver present (4× in the guard module, 2× in the changeset) — the zero is not a tool failure.
  • grep -rn "ObjectQLPlugin\.init\|ObjectQLPlugin's init" packages/cli/src .changeset/ → exit 1 (zero). Control: ObjectQLPlugin.start present 3× in the module, and I re-confirmed the fact itself at the new head (packages/objectql/src/plugin.tsstart = async at :469, discovery loop at :628).

Also re-verified rather than assumed after the merge: the seam facts stand (engine registerDriver discard branch, engine write path as call-time lookup); disarm() restores the execute forwarder too (own descriptor present while armed, gone after, prototype identity preserved — pinned at test :405/:415); and the public-surface zero still holds structurally — the PR's 6-file surface touches neither packages/cli/package.json nor src/index.ts/utils/console.ts (git diff origin/main...840f058ecb on those paths: empty), so the new ForwardedRawExecution/rawExecutions symbols live in the same module the exports map does not reach.

Ruling

The clause-② ruling from the first review stands unchanged at this head: conformance, not a contract accept/reject change; no public-surface widening. All four required items are addressed and verified. needs:contract-review clears — my explicit word, for both carriers (label removal is the PM's write, not mine). The PR stays draft per the PM's parking; this approval is contract-review clearance, not a merge instruction. One expectation attached, non-blocking: the R3 follow-up (engine-seam arming, plus dropTable/rotateShards counting) becomes a tracked issue before merge.


Generated by Claude Code

Merged via the queue into main with commit bf3bbf1Sep 1, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-13332-declaration-boot-write-suppression branch September 1, 2026 06:47
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-steve@claude