Skip to content

test(cli): pin the boot consequence of a report-only drift entry through the real artifact boot gate - #12598

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-11728-artifact-boot-gate-pin
Aug 27, 2026
Merged

test(cli): pin the boot consequence of a report-only drift entry through the real artifact boot gate#12598
os-warren merged 2 commits into
mainfrom
claude/issue-11728-artifact-boot-gate-pin

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #11728

Pins the boot consequence of the report-only stale-column drift entry from #11535 by driving real entries through runArtifactBootMigrationGate itself. One new test file; no production code is touched.

The criterion, and why the obvious version of it proves nothing

A drift entry whose category is needs_confirm — the manual_column_type_change op — must not refuse an artifact-pinned boot. Asserting that the emitter still stamps the string needs_confirm would pin the emitter to itself and say nothing about a boot. So every entry here comes from the real emitter (diffManagedTable) and is driven through the real gate. The day schema-drift.ts starts emitting this op as destructive, this file goes red — which is the only day the pin is worth anything.

The asymmetry it protects, re-measured rather than inherited

The gate was re-located by symbol on current origin/main (the card's artifact-boot-migration.ts:137 citation is from 2026-08-24; it happens to still land on the category read, but was not trusted). The three-row table was re-measured here rather than carried over, and it holds:

entry driven through the gate severity result
real manual_column_type_change (stale multi-value column) error ok = trueboots
real drop_column (orphaned column) warning ok = falserefuses the boot
safe warning ok = true — boots

severity is read by no boot gate: grep -n "severity" returns exit 1 (no matches) in both packages/cli/src/utils/artifact-boot-migration.ts and packages/cli/src/commands/serve.ts.

Note the direction of the first two rows. The entry that boots carries the higher severity, and the entry that refuses carries the lower one — so the control is not merely "some destructive entry", it is the one that makes the inversion executable. That inversion is exactly why #11535 could ship a loud finding without bricking every deployment it exists to help; those deployments are all currently serving with the stale column, and this gate runs at kernel:ready, before the socket opens.

The control is the load-bearing half

Three cases land, in packages/cli/src/utils/artifact-boot-migration.report-only-drift.test.ts:

  1. a real manual_column_type_change entry boots (ok === true, destructive === [], no refusal text);
  2. CONTROL — a real destructive entry still refuses (ok === false), so the pin cannot pass against a gate that has stopped refusing anything;
  3. with both present, the refusal names the orphan alone, and the stale entry still travels to the driver as ordinary non-destructive work.

Ablation B below is the argument for case 2, measured rather than asserted.

Ablations

Direction and exact failure count were predicted in writing first; the mutation was proven on disk with anchored grep -cF counts before any result was read; restore ran under trap … EXIT INT TERM and was verified with an empty git diff.

No rebuild, justified by import form: both suites reach the gate as ./artifact-boot-migration.js — a relative specifier inside the same package, which vite resolves to the .ts source; no exports field and no dist/ is consulted. (The new suite's bare @objectstack/driver-sql import does resolve through exports to dist, and driver-sql was built; it is not mutated by either ablation.) Both ablations predict RED, so a mutation that failed to reach the running code would surface as all-green rather than hide.

Ablation A — the #11728 risk realised: the gate starts refusing needs_confirm.
d.category === 'destructive'd.category !== 'safe'.
Predicted 3 failures / 14; observed 3 failed | 11 passed (14), and exactly the three named tests:

× applies needs_confirm alongside safe — the same boundary os migrate apply draws
× a REAL manual_column_type_change entry boots — the gate is what says so, not its category
× with both present the refusal is the orphan ALONE — the stale column is never a cause

The control stayed green, as predicted — it is insensitive to this mutation by design.

Ablation B — the gate stops refusing anything (a stale read of a renamed vocabulary).
d.category === 'destructive'd.category === ('nonexistent_category' as any).
Predicted 4 failures / 14; observed 4 failed | 10 passed (14), exactly the four named:

× refuses, and names every destructive change plus the resolving command
× still applies the safe half before refusing — never all-or-nothing
× CONTROL: a REAL destructive entry still refuses the boot — proving the gate can refuse
× with both present the refusal is the orphan ALONE — the stale column is never a cause

⭐ And case 1 — the pin itself — stayed green. That is the whole case for the control: a one-armed pin passes in full colour against a gate that refuses nothing, reading as coverage of a boot decision that is no longer being made.

Verification

All figures below are from commit 0f99e0693b, which is this branch's final commit; the gate union was derived at that same commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack over the real changeset (1 path, three-dot semantics against the merge base). Every exit code was captured with no pipe between the command and $?, and each gate's own verdict line is what was read.

Green — 15 path-matched + 6 convention-triggered families, plus the affected package's own checks:

  • pnpm --filter @objectstack/cli typecheck — exit 0, script echoed (> tsc --noEmit)
  • the two affected suites — Test Files 2 passed (2) · Tests 14 passed (14)
  • check:test-source-alias"OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/"
  • check:engine-double-contract"OK — 689 pinned, 134 in the DEBT ledger, 3 exempt"
  • check:cross-package-test-inputs (+ the ci.yml spelling) — "OK: 20 package(s) read outside themselves, all declared"
  • check:where-matcher, check:query-options-erasure — both "baseline key set verified against b72db01: no files added"
  • check:type-check-debt --re-measure"OK — 31 ledger entr(ies) re-measured, 1687 raw tsc error(s) total, none above its recorded number"
  • check:type-check-coverage, check:nul-bytes, check:i18n, check:i18n-coverage, check:objectql-double-limit, check:page-declaration-shape, check:published-files, check:slot-lookup, check:type-source-resolution, check-ci-filter-parity, check-comment-mask-adoption, check-plugin-teardown-shape, check-affected-docs, check-drift-comment

Red: none.

Declared narrowing: the full @objectstack/cli suite (185 files / ~13 min) was not run locally — the two affected files were. CI runs the package suite in full.

Two gates first reported COULD NOT MEASURE rather than red on this fresh worktree (check:i18n and check:i18n-coverage, both naming unbuilt workspace output); both were re-measured green after building, and are reported above as measured, not as inferred.

Scope

Clause ②: no accept/reject behaviour change. Evidence is the diff itself — 1 file changed, 190 insertions(+), and the one file is a .test.ts. No production file is touched, so there is no surface on which acceptance could move. No existing assertion was changed; the sibling suite artifact-boot-migration.test.ts is untouched (0 deletions in the diff).

skip-changeset: nothing published changes. A version bump here would announce a release that ships no behaviour, so the label is used rather than a fabricated changeset.

The pin lives in packages/cli because the gate does, and packages/drivers/driver-sql must not acquire a dependency on the CLI to observe it. The edge that makes this possible already runs the other way — @objectstack/cli depends on @objectstack/driver-sql and already imports its drift surface at run time from a test — so this adds no package edge and nothing to the shrink-only KNOWN_UNALIASED_TEST_IMPORTS ledger.

Generated by Claude Code


Generated by Claude Code

…ugh the real gate

The #11535 stale-column finding (`manual_column_type_change`) carries
`severity: 'error'` and must still boot: every deployment it exists to help is
serving with that column today, and the artifact boot gate runs at
`kernel:ready`, before the socket opens.

Asserting the emitted category would pin the emitter to itself. These cases
build REAL entries with `diffManagedTable` and drive them through
`runArtifactBootMigrationGate`, so the day `schema-drift.ts` emits this op as
`destructive` the pin goes red.

Paired with a control that a real `destructive` entry still refuses the boot —
without it the pin passes just as green against a gate that has stopped
refusing anything, which would read as coverage while checking nothing. The
control is deliberately the LOWER-severity entry ('warning'), so the two arms
also state the severity/consequence inversion as an executable fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
@os-warren os-warren added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 26, 2026 — with Claude
@os-warren
os-warren marked this pull request as ready for review August 26, 2026 15:45
@os-warren
os-warren enabled auto-merge August 26, 2026 15:45

Copy link
Copy Markdown
Collaborator Author

PM review — accepted, flipped ready, auto-merge armed

Measured: 1 file, +190, 0 deletions, and the file is a .test.ts. Clause ② is no for the strongest possible reason — there is no production surface in the diff on which accept/reject behaviour could move.

I re-ran the two facts the pin rests on, independently:

severity  in artifact-boot-migration.ts : 0 hits
category  in the same file              : 4 hits   ← control fires
the read  :137  drift.filter((d) => d.category === 'destructive')

So "severity is read by no boot gate" is measured, not recalled — and the card's :137 citation still lands, which the dev established by re-locating the symbol rather than trusting the number.

⭐ Leg B is the measured proof that the control was mandatory

I made the destructive control a hard requirement in the brief on the argument that a one-armed pin "reads as coverage". That was an assertion. This PR turned it into a measurement:

Mutation B — the gate stops refusing anything (category === 'nonexistent_category', a stale read of a renamed vocabulary).
Predicted: 4 red, and crucially the pin case predicted to stay GREEN.
Observed: exactly those 4, and the pin case absent from the failure list — it passed.

A one-armed pin passes in full colour against a gate that has stopped making the boot decision entirely. The control is the arm that goes red there. That is the false green this family keeps producing, caught and demonstrated rather than argued about.

Leg A is the card's own risk realised — the gate starts refusing needs_confirm — predicted 3 by name, observed exactly 3, with the control correctly staying green because it is insensitive to that mutation by design. Predicting what must stay green is what makes both legs readings rather than coincidences.

The pin drives the real emitter, which is what makes it a pin

Both non-safe entries are built by @objectstack/driver-sql's diffManagedTable, not hand-stamped. So "the day schema-drift.ts emits this op as destructive, the pin goes red" — it observes the producer, not a literal the test wrote for itself. The sibling suite's existing needs_confirm case is hand-stamped, correct on its own terms, and left untouched; it simply cannot see the emitter, which is precisely the gap this card was filed to close. Naming that distinction instead of deleting the older case is right.

⭐ And the re-measurement found something sharper than the card claimed: the severities run opposite to the boot consequence. The entry that boots carries severity error; the entry that refuses carries severity warning. The card said severity is not read; the tree says severity would give you the wrong answer if it were. That makes the asymmetry vivid instead of abstract, and it is the kind of detail that only turns up when you drive the real emitter.

No package edge or KNOWN_UNALIASED_TEST_IMPORTS entry was needed — the cli → driver-sql edge already runs at test time. Checked rather than assumed.

Not-measured, correctly

The full @objectstack/cli suite (185 files, ~13 min) is a declared narrowing, with the two affected files run in full and CI running the package anyway. And check:i18n / check:i18n-coverage first returned exit 1 as PREREQUISITE NOT MET / COULD NOT MEASURE — recorded as not measured, not red, quoting the gates' own "Nothing was checked", then re-measured green after building. The distinction is made explicitly rather than collapsed, which is the third dispatch today to get that right.

Handed up rather than filed blind — both already tracked

The 9 spec gate families with dead path populations are #12514 (I confirmed that dedup earlier today when two other devs handed up the same observation), and the @objectstack/runtime TEST_DEBT 227-vs-226 advisory names its own tracker and remedy in the gate's own output. Declining to file without a dedup channel was right both times.

CI is the remaining gate.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 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 — 0 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 aa5994e17a47187c5a09bc82b1fc1163f250092bpackageMentionDocs.

@github-actions github-actions Bot added the tests label Aug 27, 2026
@os-warren
os-warren added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit 097fc55 Aug 27, 2026
33 checks passed
@os-warren
os-warren deleted the claude/issue-11728-artifact-boot-gate-pin branch August 27, 2026 03:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pin the boot consequence of a report-only drift entry through the real artifact boot gate (follow-up to #11535)

2 participants