You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
driver-sql: single→multi-value field change never migrates the existing column type (varchar/text kept), arrays silently stored as stringified literals — data corruption on Postgres #11535
When a field is changed from single-value to multi-value (e.g. a lookup gaining multiple: true), a fresh database creates the column as json — but on an existing PostgreSQL database the old varchar/text column is kept forever, silently. Writes then store the array as a stringified literal ('["id1","id2"]') into the old column, reads return that raw string, and downstream code (hooks copying the value into child records) treats the whole string as a single id — i.e. silent data corruption, not just a display glitch.
Environment
@objectstack/*@17.2.0 (driver-sql), PostgreSQL 16
App project with a long-lived database that has been through several metadata upgrades
Reproduction
Define an object with a single-value lookup field on Postgres; boot; insert a record (column is created as varchar).
Change the field to multi-value (multiple: true); reboot.
Observe: no migration, no warning. information_schema.columns still shows character varying; a fresh DB of the same metadata shows json.
Create a record selecting 2 users → stored value is the literal string ["userA","userB"]; list/detail UI renders the raw string; any hook reading the field receives a string and (in our case) copied it verbatim into a child record's single-lookup column, producing a reference to a non-existent sys_user#'["…"]'.
Root cause (as far as we can see)
The schema-drift repair in driver-sql 17.2 supports only: relax/tighten_not_null, widen/narrow_varchar, drop_column, drop_column_default, plus index actions. There is no "change column base type" action, and the mismatch is not reported either. Additionally the old single-value btree index on the column is left behind (json cannot carry btree; after our manual ALTER, the drift log correctly flags that index as orphaned).
Write path formatInput JSON.stringifies for json fields on non-SQLite drivers, but the read path only JSON.parses on SQLite — on Postgres it relies on the pg driver's column-type-based decoding, which a stale varchar column defeats. SQLite dev environments therefore never expose the bug.
Expected
Either migrate the column type when a field becomes multi-value (ALTER TABLE … TYPE json USING …, dropping/rebuilding incompatible indexes), or at minimum fail loudly / log a drift error so operators know the column must be migrated by hand before data gets corrupted.
Workaround we applied in production
Manual, inside one transaction: drop the legacy btree index, then ALTER TABLE t ALTER COLUMN col TYPE json USING (CASE WHEN col = '' THEN NULL WHEN col LIKE '[%' THEN col::json ELSE to_json(col) END),
then repair the child rows that had already been corrupted. Works, but every project with a long-lived DB will hit this on any single→multi field change.
Triage (Routine seat, hourly round): → pm:queue + domain:engine, type Bug. P0-suspect — flagged to the engine seat for front-of-queue treatment (silent data corruption: arrays stored as stringified literals into an unmigrated varchar/text column on Postgres; the release-board four-type test's "shipped-data shape you cannot repair after release" class). Not stamping priority:p0 from triage — the engine seat should confirm reachability (single→multi field change through os migrate on a live table) and escalate if it reproduces.
Family serial note: part of this round's driver-sql group (#11535#11541#11550#11565 + blocked #11431) — fold-or-serial answered under the five gates at dispatch.
Claim: PM loop round 2
Session: session_01VK8rFDtg8eREaxBGX99Csn
Branch: claude/issue-11535-drift-detect-base-type-mismatch
Worktree: objectstack-issue-11535
Domain: domain:engine
File surface: packages/drivers/driver-sql/src/schema-drift.ts + its test files (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus (tier: dispatch-gates.mjs --tier returns no path-derived mandate. Judgement tier because the severity/category choice has deployment consequences — see the premise below.)
Clause-②: no — this adds detection and reporting of a physical/declared mismatch. It does not move the metadata contract's accept set in either direction and widens no public surface; it is declared ≠ enforced catching up on the enforcement side.
Serial constraints cleared: landing point is schema-drift.ts (1563 lines), measured — no open PR touches that file (all open PRs enumerated, diffs read). File-disjoint from the sibling dispatched in the same round (#11379, sql-driver.tsintrospectFor" + "eignKeys). The two sql-driver.ts holds (#6009, #8740) name symbols in that other file and are not reachable from this surface.
⛔ This card is SPLIT, and the split is the point
This is a user-filed production report — silent data corruption on a long-lived Postgres database, with a manual workaround already applied in production. It deserves a fix now, and the card itself frames two routes:
Either migrate the column type when a field becomes multi-value (ALTER TABLE … TYPE json USING …, dropping/rebuilding incompatible indexes), or at minimum fail loudly / log a drift error so operators know the column must be migrated by hand before data gets corrupted.
Only the second route is dispatched. The first is not mine to authorise:
An automatic ALTER TABLE … TYPE … USING … over existing rows, with index drop/rebuild, is the shape of a migration over existing data and a destructive, hard-to-roll-back action. Both sit squarely on the maintainer's floor, and neither an execution seat nor triage may adjudicate them.
The second route is a different thing entirely: today the drift detector's action vocabulary is relax/tighten_not_null, widen/narrow_varchar, drop_column, drop_column_default — verified at schema-drift.ts:129-133 — and a base-type mismatch is not reported at all. Making it visible restores an invariant rather than deciding anything, which is explicitly not an escalation.
The auto-migration half is filed as its own decision card for the maintainer, with the four-facet analysis; this claim links it once filed.
⛔ Therefore: do NOT implement a column-type migration in this PR. Not a partial one, not one behind a flag, not a "prepare the op type but don't run it" version that quietly lands the vocabulary. If you conclude detection is impossible without it, that is a fork — report it and stop; ⛔ do not push through and ⛔ do not silently fall back to the other route.
Ruling, with its premise — verify the premise before you build on it
The route above is decided. It hangs on one named, falsifiable premise:
PREMISE: a new error-severity drift finding can be emitted for a base-type mismatch without changing whether an existing deployment boots.
⚠️ This matters more than it looks. Every affected database is, by the card's own account, already running with a stale varchar column. If an error drift finding refuses the boot, then "reporting loudly" converts a currently-serving (if corrupt) deployment into a refusing one — that is a breaking change for exactly the users this card is meant to help, and it would need the maintainer, not me.
So: measure what an error-severity finding actually does in this codebase before choosing a severity. If it blocks boot, ⛔ do not ship that, and report the fork with what you measured. schema-drift.ts:392 already describes narrow_varchar as severity error, category destructive — read how that is consumed, do not infer it from the words.
What good looks like
The mismatch is detected and named precisely — the table, the column, the declared type, the physical type, and the remedy an operator must run by hand.
The orphaned single-value btree index the card describes is part of the picture; the reporter notes the drift log flags it correctly after a manual ALTER, which is a clue about what the detector can already see.
⚠️ A pin that only asserts "a finding was produced" is weak. What distinguishes this defect is that the finding is produced for the right column with the right diagnosis on a shape that currently produces silence.
SQLite dev environments never expose this (the card explains why: the read path only JSON.parses on SQLite). A pin that passes only on SQLite would be exactly the false green this repo keeps paying for. Live Postgres is available in the container — ⛔ check first and start it if down, never assume it is running.
Reporting keyword
Use Part of #11535, ⛔ not Fixes. The card's remaining half is a live maintainer decision, and a merge that silently closed this user's corruption report would be wrong. CI enforces the pairing (Part-of PR must not also close its card).
Typo correction to the claim comment above (5395496220), for the record since claim comments are read by other seats doing region checks: the sibling's method name in the "Serial constraints cleared" line is garbled — it reads introspectFor" + "eignKeys and should read introspectForeignKeys. Nothing else in that line changes: sibling card #11379, file packages/drivers/driver-sql/src/sql-driver.ts, still a different file from this card's schema-drift.ts, so the disjointness claim stands as written.
{
"issue": 11535,
"status": "done",
"branch": "claude/issue-11535-drift-detect-base-type-mismatch",
"pr": "https://github.com/objectstack-ai/objectstack/pull/11720",
"premise_still_valid": true,
"premise_notes": {
"issue_premise": "CONFIRMED end to end on live Postgres 16.13 and MySQL 8.0.46. After a field gains multiple:true on an existing DB the column stays character varying(255)/varchar, detectManagedDrift() returns [] and zero [schema-drift] warn lines are logged, and the next write stores the literal '[\"user_A\",\"user_B\"]' and reads it back with typeof==='string', isArray===false. The reporter's root-cause paragraph is confirmed rather than assumed: with the FK left in place the insert failed with Key (assignee)=([\"user_A\",\"user_B\"]) is not present in table \"sys_user\", i.e. the write path's stringification caught in the act.",
"ruling_premise": "HALF FALSE, and the falsified half is the one the dispatch flagged. The boot-gating axis is CATEGORY, not SEVERITY. severity is read by NO gate (the ordinary boot path warns on every entry regardless); category==='destructive' is read by runArtifactBootMigrationGate (packages/cli/src/utils/artifact-boot-migration.ts:137, kernel:ready, before the socket opens) and REFUSES the boot. Measured both ways with the real gate function: destructive -> ok=false, needs_confirm -> ok=true, safe -> ok=true. An ordinary (non-artifact) boot completed even with a destructive entry present. Shipped as severity 'error' + category 'needs_confirm': loud on every path, refuses no boot, excluded from dev auto-reconcile (which takes 'safe' only).",
"pm_assumption_1_landing_point": "CONFIRMED - packages/drivers/driver-sql/src/schema-drift.ts was the right file; no sql-driver.ts edit was needed.",
"pm_assumption_2_detector_sees_physical_type": "CONFIRMED - PhysicalColumn.type is populated from knex columnInfo(); the differ already had the physical type word in hand and simply never compared it.",
"pm_assumption_3_not_clause_2": "CONFIRMED - adds a report only. No metadata accept/reject set moved."
},
"summary": "Adds base-type drift detection for a multi-value field left on a stale varchar/text column, the shape that today produces total silence. The finding names the table, column, declared type (json), physical type, and a dialect-correct statement the operator runs by hand - and the suite EXECUTES that statement against live Postgres and MySQL over every row state, asserting the finding then clears. Detection only: the new manual_column_type_change op has no reconciler arm by design, and the doc comment says the automatic ALTER remains the maintainer's open decision. Dialect scope is measured, not assumed - the same fixture corrupts on Postgres 16.13 and MySQL 8.0.46 and does NOT on SQLite (reads back as a real array), so SQLite is excluded; a stale integer/timestamp column is excluded because the server already refuses that write loudly, so there is no silence to break. One bounded in-place fix in the same file and defect class is included and declared (see deviations).",
"gates": {
"run_at_commit": "ecd52f335e",
"pnpm lint (whole repo, eslint . --no-inline-config)": 0,
"pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit)": 0,
"drift family, 9 test files, all 3 dialects live": "0 (115 passed, 1 skipped)",
"pnpm check:changeset-gate-self-tests": 0,
"pnpm check:driver-conformance": 0,
"pnpm check:objectui-changeset": 0,
"pnpm check:published-files": 0,
"pnpm check:slot-lookup": 0,
"pnpm check:test-source-alias": 0,
"pnpm check:type-source-resolution": 0,
"node scripts/check-adr-0087-registration.mjs": 0,
"node scripts/check-changeset-no-major.mjs": 0,
"node scripts/check-empty-changeset.mjs": 0,
"node scripts/check-plugin-teardown-shape.mjs": 0,
"node scripts/pm/release-rehearsal-clone.mjs --self-test": 0,
"node scripts/docs-audit/check-affected-docs.mjs": 0,
"pnpm check:query-options-erasure": 0,
"pnpm check:engine-double-contract": 0,
"pnpm check:cross-package-test-inputs": 0,
"pnpm check:where-matcher": 0,
"pnpm check:type-check-coverage": 0,
"pnpm check:nul-bytes": 0,
"NOT RUN (declared narrowing)": "pnpm check:type-check-debt --re-measure - needs the whole workspace closure built. It re-runs tsc PER LEDGER ENTRY and driver-sql appears in neither the DEBT nor the EXEMPT block of scripts/check-type-check-coverage.mjs, so this change cannot move a ledgered count. The structural half (check:type-check-coverage) passed, and the package's own tsc --noEmit is green over a program that provably includes the new test file (tsc --listFiles shows it; tsconfig includes src/**/* with no test exclusion)."
},
"files_changed": [
"packages/drivers/driver-sql/src/schema-drift.ts",
"packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts (new)",
".changeset/drift-detect-multi-value-base-type-mismatch.md (new)"
],
"tests": "Ablation, both legs proven ON DISK (not by the editor's exit code): mutated `const declaresJsonColumn = field.multiple === true` -> `false` under a `trap '<restore>' EXIT INT TERM`; anchor counts after mutation removed-text=0 injected-marker=1; ABLATED RUN = 9 failed | 13 passed (both live cells' detectManagedDrift() case failed, and the narrow_varchar regression case failed); restore verified injected-marker=0, original-anchor=1, clean tree; RESTORED RUN = 22 passed | 1 skipped. NO REBUILD LEG, and the reason is structural: the suite imports the subject by a RELATIVE specifier ('./schema-drift.js') from inside its own package, so vitest resolves it to src/schema-drift.ts - nothing reaches a dependency's `exports` -> dist/, which is the condition that makes a rebuild load-bearing. Live servers were down at start and were started per the dispatch: Postgres 16.13 at timezone='Asia/Shanghai', MySQL 8.0.46 at time_zone='+08:00', process TZ=America/New_York. The remedy SQL the finding prints was EXECUTED against both live servers over 4 row states; after it, both dialects return byte-identical results (corrupted -> [\"x\",\"y\"], legacy single -> [\"a\"], empty string -> null, NULL -> null) and the finding clears.",
"deviations": [
"BOUNDED IN-PLACE FIX, declared: the varchar-width branch is now guarded by `!declaresJsonColumn`. Measured on the pre-fix tree, `{ multiple: true, maxLength: 50 }` over a stale varchar(255) produced narrow_varchar at severity 'error', category DESTRUCTIVE on both enforcing dialects - a finding that refuses the artifact-pinned boot and invites `os migrate apply --allow-destructive` to rewrite the column to varchar(50), the exact opposite of the repair it needs (json). All four exemption conditions hold: same defect class as this card; mechanical, with the correct shape pinned by the EMITTER (createColumn returns at `if (field.multiple) { table.json(name); return; }` before maxLength is ever read) and by the already-landed #11431 principle; no other claim holds this file (measured at dispatch); same gate family, no new verification surface. The declared FILE surface did not grow - it is the same schema-drift.ts - and the fix is named with its evidence in the PR body. The single-value width branch is untouched and pinned as untouched.",
"The emitted Postgres remedy deviates from the reporter's production SQL in two places, both forced by measurement: (a) their `to_json(col)` turns a legacy single value into the JSON SCALAR \"a\" under a now-multi-value field - it read back with Array.isArray===false - so `json_build_array` is used instead, which makes Postgres agree with MySQL's JSON_ARRAY; (b) `json_build_array(NULL)` is `[null]`, so an explicit `IS NULL` arm was added - without it every NULL row silently gained a value, observed on the live server before the arm existed.",
"MINOR, unfixable without a force-push: the second commit's body carries a `Part of #11535` line. The squash guidance prefers card relationships declared once in the PR body only. It is harmless here - `Part of` is not a closing keyword and the two commit messages do not contradict each other - and correcting it would require rewriting a pushed commit, which is forbidden.",
"A stronger pin for the boot consequence would live in packages/cli next to artifact-boot-migration.test.ts, feeding a real base-type entry through the actual gate. That is outside the declared file surface, so it was NOT added; the pin in driver-sql asserts the category structurally with the measurement written into its comment. Flagging so PM can decide whether to dispatch it."
],
"open_questions": [
{
"question": "Is `manual_column_type_change` an acceptable name and shape for a DriftOp that the reconciler must never execute? The ruling forbids landing migration vocabulary, including 'add the op type but never run it'. ManagedDriftEntry requires an `op`, so a finding cannot travel without one, and no existing op is safe to borrow (each has a reconciler arm that would do the wrong thing).",
"options": [
"A - keep `manual_column_type_change`: the name asserts the platform does NOT act, the doc comment states there is deliberately no arm and that the automatic ALTER is the maintainer's open decision, and the reconciler's decline is pinned",
"B - rename to something with no migration word at all (e.g. `report_column_type_mismatch`), making it unmistakable that the op's action is 'report'",
"C - make `op` optional on ManagedDriftEntry for report-only findings - larger blast radius, every consumer that reads `d.op.type` would need a null path"
],
"recommendation": "A. It is the absence of an automatic migration made explicit rather than a placeholder for one, and it names what the OPERATOR must do, which is what the dispatch asked the finding to carry. B is a defensible rename if the maintainer reads A as pre-empting the decision - it is a one-line change with no behavioural difference. C should be refused: it widens a shared contract to serve one finding."
},
{
"question": "The category vocabulary has no 'report only' state, so this finding relies on the reconciler declining an op it has no arm for, and the decline is logged as \"unsupported on dialect 'postgres'\" - which is misleading, since it is unsupported on every dialect by design. Filed as #11721 rather than fixed, because the repair is in sql-driver.ts and packages/cli, outside this round's declared surface.",
"options": [
"A - leave as-is; the decline works and is pinned",
"B - dispatch #11721 to add a REPORT_ONLY_DRIFT_OPS partition read by applyMigrationEntries, so such ops are separated before the dialect switch and get their own wording"
],
"recommendation": "B, but as its own card - it touches the file a sibling dispatch held this round, and the boundary it draws should be declared once rather than re-derived, which is the exact property artifact-boot-migration.ts warns about."
}
],
"out_of_scope_findings": [
"filed as #11721: the drift category vocabulary has no 'report only' state, so a never-reconcilable finding must borrow needs_confirm and be declined by the reconciler; the decline message also misattributes the refusal to the dialect",
"filed as #11722: applyMigrationEntries reports every SQLite entry as applied after a table rebuild, including ops rebuildSqliteTablePatched is documented to ignore - latent today (the only ops the differ emits on SQLite are the four the rebuild handles), a false green when the next non-rebuildable op arrives"
]
}
Supersedes comment 5396318675: its leading <!-- os-dev-report --> marker did not survive the body sanitizer (read back — the stored body begins with a blank line), which would have made the report invisible to a marker scan. Re-posted with the marker as literal text. One string in the previous copy was also mangled by the same sanitizer (the trap example lost its angle-bracketed placeholder); it is spelled without angle brackets below. Content is otherwise identical.
{
"issue": 11535,
"status": "done",
"branch": "claude/issue-11535-drift-detect-base-type-mismatch",
"pr": "https://github.com/objectstack-ai/objectstack/pull/11720",
"premise_still_valid": true,
"premise_notes": {
"issue_premise": "CONFIRMED end to end on live Postgres 16.13 and MySQL 8.0.46. After a field gains multiple:true on an existing DB the column stays character varying(255)/varchar, detectManagedDrift() returns [] and zero [schema-drift] warn lines are logged, and the next write stores the literal '[\"user_A\",\"user_B\"]' and reads it back with typeof==='string', isArray===false. The reporter's root-cause paragraph is confirmed rather than assumed: with the FK left in place the insert failed with Key (assignee)=(['user_A','user_B']) is not present in table sys_user, i.e. the write path's stringification caught in the act.",
"ruling_premise": "HALF FALSE, and the falsified half is the one the dispatch flagged. The boot-gating axis is CATEGORY, not SEVERITY. severity is read by NO gate (the ordinary boot path warns on every entry regardless); category==='destructive' is read by runArtifactBootMigrationGate (packages/cli/src/utils/artifact-boot-migration.ts:137, kernel:ready, before the socket opens) and REFUSES the boot. Measured both ways with the real gate function: destructive gives ok=false, needs_confirm gives ok=true, safe gives ok=true. An ordinary (non-artifact) boot completed even with a destructive entry present. Shipped as severity 'error' + category 'needs_confirm': loud on every path, refuses no boot, excluded from dev auto-reconcile (which takes 'safe' only).",
"pm_assumption_1_landing_point": "CONFIRMED - packages/drivers/driver-sql/src/schema-drift.ts was the right file; no sql-driver.ts edit was needed.",
"pm_assumption_2_detector_sees_physical_type": "CONFIRMED - PhysicalColumn.type is populated from knex columnInfo(); the differ already had the physical type word in hand and simply never compared it.",
"pm_assumption_3_not_clause_2": "CONFIRMED - adds a report only. No metadata accept/reject set moved."
},
"summary": "Adds base-type drift detection for a multi-value field left on a stale varchar/text column, the shape that today produces total silence. The finding names the table, column, declared type (json), physical type, and a dialect-correct statement the operator runs by hand - and the suite EXECUTES that statement against live Postgres and MySQL over every row state, asserting the finding then clears. Detection only: the new manual_column_type_change op has no reconciler arm by design, and the doc comment says the automatic ALTER remains the maintainer's open decision. Dialect scope is measured, not assumed - the same fixture corrupts on Postgres 16.13 and MySQL 8.0.46 and does NOT on SQLite (reads back as a real array), so SQLite is excluded; a stale integer/timestamp column is excluded because the server already refuses that write loudly, so there is no silence to break. One bounded in-place fix in the same file and defect class is included and declared (see deviations).",
"gates": {
"run_at_commit": "ecd52f335e",
"pnpm lint (whole repo, eslint . --no-inline-config)": 0,
"pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit)": 0,
"drift family, 9 test files, all 3 dialects live": "0 (115 passed, 1 skipped)",
"pnpm check:changeset-gate-self-tests": 0,
"pnpm check:driver-conformance": 0,
"pnpm check:objectui-changeset": 0,
"pnpm check:published-files": 0,
"pnpm check:slot-lookup": 0,
"pnpm check:test-source-alias": 0,
"pnpm check:type-source-resolution": 0,
"node scripts/check-adr-0087-registration.mjs": 0,
"node scripts/check-changeset-no-major.mjs": 0,
"node scripts/check-empty-changeset.mjs": 0,
"node scripts/check-plugin-teardown-shape.mjs": 0,
"node scripts/pm/release-rehearsal-clone.mjs --self-test": 0,
"node scripts/docs-audit/check-affected-docs.mjs": 0,
"pnpm check:query-options-erasure": 0,
"pnpm check:engine-double-contract": 0,
"pnpm check:cross-package-test-inputs": 0,
"pnpm check:where-matcher": 0,
"pnpm check:type-check-coverage": 0,
"pnpm check:nul-bytes": 0,
"NOT RUN (declared narrowing)": "pnpm check:type-check-debt --re-measure - needs the whole workspace closure built. It re-runs tsc PER LEDGER ENTRY and driver-sql appears in neither the DEBT nor the EXEMPT block of scripts/check-type-check-coverage.mjs, so this change cannot move a ledgered count. The structural half (check:type-check-coverage) passed, and the package's own tsc --noEmit is green over a program that provably includes the new test file (tsc --listFiles shows it; tsconfig includes src/**/* with no test exclusion)."
},
"files_changed": [
"packages/drivers/driver-sql/src/schema-drift.ts",
"packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts (new)",
".changeset/drift-detect-multi-value-base-type-mismatch.md (new)"
],
"tests": "Ablation, both legs proven ON DISK (not by the editor's exit code): mutated `const declaresJsonColumn = field.multiple === true` to `false` under a shell trap on EXIT INT TERM that restores the file; anchor counts after mutation removed-text=0 injected-marker=1; ABLATED RUN = 9 failed | 13 passed (both live cells' detectManagedDrift() case failed, and the narrow_varchar regression case failed); restore verified injected-marker=0, original-anchor=1, clean tree; RESTORED RUN = 22 passed | 1 skipped. NO REBUILD LEG, and the reason is structural: the suite imports the subject by a RELATIVE specifier ('./schema-drift.js') from inside its own package, so vitest resolves it to src/schema-drift.ts - nothing reaches a dependency's exports map into dist/, which is the condition that makes a rebuild load-bearing. Live servers were down at start and were started per the dispatch: Postgres 16.13 at timezone='Asia/Shanghai', MySQL 8.0.46 at time_zone='+08:00', process TZ=America/New_York. The remedy SQL the finding prints was EXECUTED against both live servers over 4 row states; after it, both dialects return byte-identical results (corrupted gives ['x','y'], legacy single gives ['a'], empty string gives null, NULL gives null) and the finding clears.",
"deviations": [
"BOUNDED IN-PLACE FIX, declared: the varchar-width branch is now guarded by `!declaresJsonColumn`. Measured on the pre-fix tree, `{ multiple: true, maxLength: 50 }` over a stale varchar(255) produced narrow_varchar at severity 'error', category DESTRUCTIVE on both enforcing dialects - a finding that refuses the artifact-pinned boot and invites `os migrate apply --allow-destructive` to rewrite the column to varchar(50), the exact opposite of the repair it needs (json). All four exemption conditions hold: same defect class as this card; mechanical, with the correct shape pinned by the EMITTER (createColumn returns at the field.multiple branch, emitting table.json(name), before maxLength is ever read) and by the already-landed #11431 principle; no other claim holds this file (measured at dispatch); same gate family, no new verification surface. The declared FILE surface did not grow - it is the same schema-drift.ts - and the fix is named with its evidence in the PR body. The single-value width branch is untouched and pinned as untouched.",
"The emitted Postgres remedy deviates from the reporter's production SQL in two places, both forced by measurement: (a) their to_json(col) turns a legacy single value into a JSON SCALAR under a now-multi-value field - it read back with Array.isArray===false - so json_build_array is used instead, which makes Postgres agree with MySQL's JSON_ARRAY; (b) json_build_array(NULL) is a one-element array containing null, so an explicit IS NULL arm was added - without it every NULL row silently gained a value, observed on the live server before the arm existed.",
"MINOR, unfixable without a force-push: the second commit's body carries a `Part of #11535` line. The squash guidance prefers card relationships declared once in the PR body only. It is harmless here - `Part of` is not a closing keyword and the two commit messages do not contradict each other - and correcting it would require rewriting a pushed commit, which is forbidden.",
"A stronger pin for the boot consequence would live in packages/cli next to artifact-boot-migration.test.ts, feeding a real base-type entry through the actual gate. That is outside the declared file surface, so it was NOT added; the pin in driver-sql asserts the category structurally with the measurement written into its comment. Flagging so PM can decide whether to dispatch it.",
"REPORTING: the first report comment (5396318675) lost its HTML-comment marker to the body sanitizer. This comment is the authoritative copy."
],
"open_questions": [
{
"question": "Is `manual_column_type_change` an acceptable name and shape for a DriftOp that the reconciler must never execute? The ruling forbids landing migration vocabulary, including 'add the op type but never run it'. ManagedDriftEntry requires an `op`, so a finding cannot travel without one, and no existing op is safe to borrow (each has a reconciler arm that would do the wrong thing).",
"options": [
"A - keep `manual_column_type_change`: the name asserts the platform does NOT act, the doc comment states there is deliberately no arm and that the automatic ALTER is the maintainer's open decision, and the reconciler's decline is pinned",
"B - rename to something with no migration word at all (e.g. `report_column_type_mismatch`), making it unmistakable that the op's action is 'report'",
"C - make `op` optional on ManagedDriftEntry for report-only findings - larger blast radius, every consumer that reads d.op.type would need a null path"
],
"recommendation": "A. It is the absence of an automatic migration made explicit rather than a placeholder for one, and it names what the OPERATOR must do, which is what the dispatch asked the finding to carry. B is a defensible rename if the maintainer reads A as pre-empting the decision - it is a one-line change with no behavioural difference. C should be refused: it widens a shared contract to serve one finding."
},
{
"question": "The category vocabulary has no 'report only' state, so this finding relies on the reconciler declining an op it has no arm for, and the decline is logged as unsupported on dialect 'postgres' - which is misleading, since it is unsupported on every dialect by design. Filed as #11721 rather than fixed, because the repair is in sql-driver.ts and packages/cli, outside this round's declared surface.",
"options": [
"A - leave as-is; the decline works and is pinned",
"B - dispatch #11721 to add a REPORT_ONLY_DRIFT_OPS partition read by applyMigrationEntries, so such ops are separated before the dialect switch and get their own wording"
],
"recommendation": "B, but as its own card - it touches the file a sibling dispatch held this round, and the boundary it draws should be declared once rather than re-derived, which is the exact property artifact-boot-migration.ts warns about."
}
],
"out_of_scope_findings": [
"filed as #11721: the drift category vocabulary has no 'report only' state, so a never-reconcilable finding must borrow needs_confirm and be declined by the reconciler; the decline message also misattributes the refusal to the dialect",
"filed as #11722: applyMigrationEntries reports every SQLite entry as applied after a table rebuild, including ops rebuildSqliteTablePatched is documented to ignore - latent today (the only ops the differ emits on SQLite are the four the rebuild handles), a false green when the next non-rebuildable op arrives"
]
}
Reviewer of record: domain:engine seat, huangyiirene, session session_01VK8rFDtg8eREaxBGX99Csn. Checked against the diff, not the report. ⛔ Landing held — last section.
⭐ The headline: my ruling's premise was falsified, on exactly the axis I flagged
I hung the route on a named premise and told the dev to verify it before building:
PREMISE: a new error-severity drift finding can be emitted without changing whether an existing deployment boots.
It is half false, and the false half is the dangerous one. The boot-gating axis is category, not severity:
severity is read by no gate — the ordinary boot path warns on every entry regardless.
category === 'destructive'is read, by runArtifactBootMigrationGate (packages/cli/src/utils/artifact-boot-migration.ts:137) at kernel:ready, before the socket opens, and it refuses the boot.
Measured through the real gate function, both ways: destructive → ok=false; needs_confirm → ok=true; safe → ok=true.
Had the dev taken my premise at face value and reached for a "loud" severity, it would have made no difference to booting — and had it reached for destructive as the way to be loud, it would have bricked the boot of every already-running deployment this card exists to help. Shipped instead as severity error + category needs_confirm: loud on every path, refuses no boot, and excluded from dev auto-reconcile (which takes safe only). That is the correct resolution, and it exists because the premise was measured rather than inherited.
The boundary I drew — verified held, not taken on trust
I forbade the auto-migration half in any form, including "add the op type but never run it". Checked directly in the diff:
✅ No execution seam added at all — zero await, knex, .raw(, or execute in the added source. The ALTER TABLE strings that appear are returned as remedy text for the operator to run by hand, which is what the card asked for.
✅ Path surface respected: schema-drift.ts + its new test + a changeset. No sql-driver.ts, no packages/cli, no governed path, no packages/spec.
Answering the dev's open question 1 — decided, not escalated
Is manual_column_type_change an acceptable name for a DriftOp the reconciler must never execute?
Keep it (option A). The dev was right to ask, and asking is what converts my prohibition into a decision I can make rather than a rule it quietly bent. ManagedDriftEntry requires an op, so a finding cannot travel without one, and every existing op carries a reconciler arm that would do the wrong thing — so the choice was never "op or no op".
A is fine because nothing about it is quiet: the prefix names the operator's action, the docblock is explicit that there is no arm and that the ALTER is a live maintainer decision, and the reconciler's decline is pinned. My prohibition was aimed at machinery landing silently and pre-empting #11700; none of that is happening. B (report_column_type_mismatch) is a defensible rename with zero behavioural difference — not worth a dev round and a CI cycle now, and trivially done later if the #11700 ruling makes the wording awkward. ⛔ C is refused for the reason the dev gives: it widens a shared contract to serve one finding.
The declared in-place fix — accepted, and it is the best catch in the PR
The varchar-width branch is now guarded by !declaresJsonColumn. Verified in the diff; the single-value branch is untouched and pinned as untouched.
This matters more than its size. Measured on the pre-fix tree, a { multiple: true, maxLength: 50 } field over a stale varchar(255) produced narrow_varchar at severity error, category destructive — which (per the finding above) refuses the artifact-pinned boot, and invites the operator to run os migrate apply --allow-destructive, rewriting the column to varchar(50): the exact opposite of the repair it needs. So the detector was not merely silent on this shape, it was actively steering operators toward making the corruption worse. The card never named that, and it is squarely the same defect class. The four exemption conditions hold and it is declared with its evidence in the PR body rather than slipped in.
Evidence quality
Ablation with both legs proven on disk rather than by an editor's exit code: mutating declaresJsonColumn to false under a trap-restore gave 9 failed | 13 passed, restore verified by anchor counts and a clean tree, restored run 22 passed | 1 skipped. No rebuild leg needed and the reason is structural (relative specifier → vitest resolves source, so the stale-dist false-green direction is unreachable).
⭐ The remedy SQL is not merely printed — the suite executes it against live Postgres 16.13 and MySQL 8.0.46 over four row states and asserts the finding then clears. Two corrections to the reporter's production SQL were forced by measurement, not preference: to_json(col) produces a JSON scalar under a now-multi-value field (so json_build_array is used, making PG agree with MySQL's JSON_ARRAY), and json_build_array(NULL) is [null] — a one-element array — so an explicit IS NULL arm was added after the version without it was run and observed to give every NULL row a value. Dialect scope is measured too: the fixture corrupts on PG and MySQL and does not on SQLite, so SQLite is excluded rather than assumed in.
Declared narrowing — accepted
check:type-check-debt --re-measure not run, on the structural argument that driver-sql appears in neither the DEBT nor the EXEMPT block, so this change cannot move a ledgered count; the structural half (check:type-check-coverage) passed and the package's own tsc --noEmit is green over a program that provably includes the new test file (--listFiles). Reasonable, and CI runs the real gate.
Minor, no action: the second commit body carries a Part of #11535 line. Harmless (not a closing keyword, no contradiction) and correcting it would need a force-push on a pushed commit — ⛔ correctly not done.
The flagged-but-not-added CLI pin — a pin feeding a real base-type entry through the actual boot gate. The dev correctly did not add it (outside the declared surface) and flagged it for me. It pins the property this whole card rests on, so it is worth having; it lands in packages/cli, which is another seat's lane, so I am filing it there rather than reaching across.
⛔ Landing held
Review verdict is ACCEPT. The flip is not taken: I have not yet read this PR's check runs, and entry needs every check green — not the required subset. I will take that reading before flipping, and this card gets Part of, so it stays open for the maintainer's #11700 decision either way.
Detection half landed — this card moves to pm:blocked, it does not close
PR #11720 merged as 09f93617e5. Verified by content on origin/main, ⛔ not by the API's merged field (which was measured wrong on another PR today): manual_column_type_change is present in packages/drivers/driver-sql/src/schema-drift.ts.
What is fixed for you now: the silence is gone. A multi-value field left on a stale varchar/text column is reported — naming the table, the column, the declared type, the physical type, and a dialect-correct statement to run by hand. The finding is loud on every path and ⛔ refuses no boot (severity error, category needs_confirm — the boot gate reads category, and destructive is the value that would have stopped your already-running database from starting).
Also fixed, and it was doing active harm: on the pre-fix tree a multiple: true field with a maxLength produced a narrow_varchar finding at category destructive — which refused an artifact-pinned boot and invited os migrate apply --allow-destructive to rewrite the column to a narrower varchar, the exact opposite of the repair it needs. That branch is now guarded.
What is not fixed, and why this card stays open: the platform still does not migrate the column for you. That was the other half, and it is now ruled.
The ruling on the other half
Maintainer, 2026-08-24 (#11700, now closed). ⛔ Quoted verbatim: 「11700 11693 不需要考虑历史数据,其他按照你的建议继续」 — route C: the platform warns and ships an explicit, operator-run migration command (with a dry run, rollback notes and docs). It is ⛔ never run automatically at boot. Unattended auto-migration (A) was rejected: it is the only route where the platform alters a customer's production table with nobody watching.
That command is #11733, and this card is blocked on it.
⚠️One part of your report is explicitly out of scope, so you are not waiting on it: repairing rows already corrupted before the detection landed. The ruling struck that — as you did in production, existing corrupted rows are repaired by the operator. ⛔ No card is being filed for it; #11733 says so in its own text so a later dev does not add backfill by reflex.
State
pm:dispatched → pm:blocked in one label write, assignee cleared, Blocked-by: #11733 above for the reverse index. The card reopens for dispatch the moment #11733 lands.
Claim: PM loop round 2
Session: session_01W6HFzyH98W1YaQXhJUJt6o
Branch: claude/issue-11535-multi-value-column-signal
Worktree: objectstack-issue-11535
Domain: domain:engine
File surface: packages/drivers/driver-sql/src/schema-drift.ts + its test files (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus
Clause-②: no — conditional on the fence below. A louder warning over an already-detected condition moves no accept door. ⚠️ A load-time or boot-time refusal would, and the remaining scope's wording ("refusal/warning") admits both readings — see the fence.
Serial constraints cleared: none. schema-drift.ts (1762 lines) is touched by no open PR, and is a different file from the two cards dispatched alongside (sql-driver.ts#11782, the conformance test #11456).
Unblocked, and re-measured rather than inherited
Blocked-by: #11733 discharged — triage's unlock scan (5404296978) recorded PR #11894 landing os migrate multi-value-columns as 0e5bea6. Verified here that the detection half is genuinely on main, ⛔ not assumed from the prior ACCEPT:
manual_column_type_change @ origin/main:…/schema-drift.ts → 2
CONTROL (schema-drift reachable) → 26
⚠️ P0-suspect — confirming reachability is part of this card, not a preamble
Triage flagged this P0-suspect and explicitly did not stamp priority:p0, leaving the engine seat to confirm:
silent data corruption: arrays stored as stringified literals into an unmigrated varchar/text column on Postgres … the engine seat should confirm reachability (single→multi field change through os migrate on a live table) and escalate if it reproduces.
⇒ Re-measure the reproduction on current origin/main first. If it still reproduces end-to-end through the operator path, say so plainly in the report — that is an escalation trigger this seat will act on, ⛔ not a footnote. If #11720's detection plus #11733's migration command have together made it unreachable, that is an equally valuable answer and premise_still_valid: false is the right return.
⛔ THE FENCE — boot-gating, and why it is not a style question
This card's own history contains the trap. The prior round hung its route on a premise that was measured false on the dangerous half (5396424187):
Boot-gating is decided by category, NOT severity.severity is read by no gate. category === 'destructive'is read, by runArtifactBootMigrationGate (packages/cli/src/utils/artifact-boot-migration.ts:137) at kernel:ready — before the socket opens.
⇒ The landed finding deliberately uses category: 'needs_confirm', precisely so it refuses no boot.
So, binding on this dispatch:
⛔ Do not raise this finding's category to destructive, and do not introduce any new destructive-category finding on this path. That would stop an already-running production database from starting — an accept-set change of the most expensive kind, on the maintainer's floor.
⛔ Do not add a load-time or write-time refusal for the unmigrated-column condition. Warn, loudly and with the remedy command named. If you judge a refusal is the right answer, that is a fork: stop and report — ⛔ do not ship it.
If either fence would be crossed, the card's Clause-②: no above stops being true, and it needs re-dispatch at CONTRACT_REVIEW_TIER. Say so rather than proceeding.
What is actually left
Per triage's unlock note: the silent-corruption half — the loud signal when a single→multi field change meets an unmigrated column, now that os migrate multi-value-columns exists to point operators at. That command is the new fact this card was waiting for: the message can now name a remedy instead of describing a problem.
⚠️ Sync origin/main first — schema-drift.ts took #11720 today, and the card body's line numbers predate it. Re-locate by symbol.
{
"issue": 11535,
"status": "done",
"branch": "claude/issue-11535-multi-value-column-signal",
"pr": "https://github.com/objectstack-ai/objectstack/pull/12012",
"premise_still_valid": true,
"p0_confirmation": {
"verdict": "STILL REPRODUCES — escalation trigger, not a footnote",
"measured_at": "origin/main d63b014360, live Postgres 16.13 (server timezone Asia/Shanghai, process TZ=America/New_York)",
"path": "operator path end to end: single-value table created and populated -> metadata gains multiple:true -> initObjects (additive-only, column never revisited) -> write",
"observed": "column stays `character varying(255)` while a fresh DB of the same metadata gets `json`; the array reads back as the STRING '[\"x\",\"y\"]' (typeof === 'string', Array.isArray === false)",
"what_changed_since_the_report": "the SILENCE is closed (#11720 reports it) and a remedy exists (#11733 `os migrate multi-value-columns`). The REACHABILITY is not closed and is now by ruling: #11700 route C settled that the platform warns and ships an operator-run migration rather than refusing the write or auto-altering the table. So there is no un-ruled work behind the P0 suspicion — the residual exposure is the maintainer's decided position, not a gap.",
"mysql": "NOT measured here — mysqld is not installed in this container, so that live cell is a named skip. CI runs it."
},
"summary": "The remaining half: the finding now NAMES `os migrate multi-value-columns` instead of describing a problem. Measured on origin/main, the line an operator actually meets opened with 'ObjectStack will NOT change this column for you. Migrate it by hand' and then printed raw SQL — a sentence that became FALSE when #11733 landed, and which sent operators to hand-write DDL on a production table while the command with a dry run, a prompt and a post-run re-detection sat one line away, unnamed. The message now leads with the command and keeps the hand-run statement after it; both operator surfaces pick it up unchanged because both print `message` verbatim (boot warning, and `os migrate plan` via renderPlan). Non-vacuity is pinned in both directions, including on a live database re-booted after the repair.",
"fence_respected": {
"category": "UNCHANGED at 'needs_confirm' — not raised to 'destructive'. No new destructive-category finding on this path.",
"refusal": "NONE added. No load-time, no write-time, no boot-time refusal. The change is message text only.",
"clause_2": "still `no`, and the condition it was conditional on holds. The diff moves no accept/reject set: the finding fires on exactly the same predicate as before (`declaresJsonColumn && multiValueColumnTypeIsLoadBearing(dialect) && acceptsStringifiedJson(col.type)`, untouched). The one new export is module-scoped and deliberately NOT added to the package's public index.ts, so the published surface is unchanged."
},
"files_changed": [
"packages/drivers/driver-sql/src/schema-drift.ts",
"packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts",
".changeset/drift-multi-value-column-names-remedy-command.md (new)"
],
"gates": {
"read_at_commit": "765f07814e",
"working_tree_at_that_read": "clean (0 changes), asserted inside the same run",
"derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack — exit 0; --repo assertion checked against this checkout's origin and held; change set derived by the script itself from the merge base (3 paths), NOT from a hand-made git diff",
"note_on_exit_capture": "every gate redirected to its own log FIRST and $? captured SECOND — no `cmd | tail; EXIT=$?` anywhere",
"pnpm check:changeset-gate-self-tests": 0,
"pnpm check:driver-conformance": 0,
"pnpm check:objectui-changeset": 0,
"pnpm check:published-files": 0,
"pnpm check:slot-lookup": 0,
"pnpm check:test-source-alias": 0,
"pnpm check:type-source-resolution": 0,
"node scripts/check-adr-0087-registration.mjs": 0,
"node scripts/check-changeset-no-major.mjs": 0,
"node scripts/check-empty-changeset.mjs": 0,
"node scripts/check-plugin-teardown-shape.mjs": 0,
"node scripts/docs-audit/check-affected-docs.mjs": 0,
"node scripts/docs-audit/check-drift-comment.mjs": 0,
"node scripts/pm/release-rehearsal-clone.mjs --self-test": 0,
"pnpm check:query-options-erasure": 0,
"pnpm check:type-check-coverage": 0,
"pnpm check:engine-double-contract": 0,
"pnpm check:cross-package-test-inputs": 0,
"pnpm check:where-matcher": 0,
"pnpm check:nul-bytes": 0,
"pnpm lint (whole repo, eslint . --no-inline-config)": 0,
"pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit)": 0,
"NOT RUN (declared narrowing)": "pnpm check:type-check-debt --re-measure — needs the whole workspace closure built and re-runs tsc PER LEDGER ENTRY. Measured HERE rather than inherited from the prior round: driver-sql appears in neither the EXEMPT nor the TEST_DEBT block of scripts/check-type-check-coverage.mjs (both blocks brace-matched and grepped), so this change cannot move a ledgered count. The structural half passed, and `tsc --listFiles` shows BOTH changed files inside driver-sql's own program, which exits 0.",
"verdict_lines_read": "gates quoted their own output, not a bare $? — e.g. 'check-driver-conformance: OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt.' and 'check-nul-bytes: OK (scanned 6662 text file(s) ... no raw ASCII control bytes).'"
},
"tests": "Suite (live Postgres 16.13, TZ=America/New_York): schema-drift.base-type-mismatch.test.ts 24 passed | 2 skipped (was 18 passed before this change). Drift family, 6 files: 99 passed | 2 skipped. CROSS-PACKAGE: driver-sql REBUILT, then the CLI's three multi-value-columns suites run against that dist — 21 passed. That rebuild is load-bearing there (packages/cli imports @objectstack/driver-sql through its exports map into dist/) and is NOT load-bearing for driver-sql's own suite, which imports the subject by a relative specifier ('./schema-drift.js') so vitest resolves src/. ABLATION: mutated the ${MULTI_VALUE_COLUMN_REMEDY_COMMAND} interpolation out of the message (⚠️ mutating the CONSTANT would have been VOID — the suite imports it, so a mutated constant would be compared against a mutated message and pass). Mutation PROVEN ON DISK before any result was read, by anchored counts on the text I intended to change, not by the editor's exit code and not by a bare `git diff --stat`: removed-text 1 -> 0, injected marker 0 -> 1, with the script aborting (exit 90) if either count is wrong. Direction PREDICTED IN ADVANCE: 3 red — the two 'names the command' cases (unit + live boot) plus the live remedy case via its non-vacuity line — with the negative-direction case predicted to stay GREEN, since with nothing naming the command it still passes. OBSERVED: 'Tests 3 failed | 21 passed | 2 skipped', exactly those three. Restore under `trap ... EXIT INT TERM`: marker 0, anchor 1, diff against the pre-mutation copy byte-identical, git status clean. The ablation also PROVES the source-resolution claim rather than asserting it: source mutated with no rebuild and the suite went red; had it stayed green that would have been the stale-dist signature. Not a dist-leg ablation, so the strippable-marker caveat does not apply.",
"non_vacuity": {
"fires": "unit on both corrupting dialects, and on a real live-Postgres BOOT (captured through reconcileAndWarnDrift -> logger, not reconstructed): the line names the table, the physical type, the dry run and --apply, and carries the dialect statement intact. Also pinned: the command appears BEFORE the raw SQL.",
"does_not_fire": "six shapes enumerated — migrated json column, single-value field, single-value NARROW width drift, single-value WIDEN width drift, SQLite, stale integer — asserting no message names the command and no manual_column_type_change op appears. Two of those rows DO produce a finding, asserted, so the loop reads real messages instead of passing over empty arrays. On a LIVE database: after the remedy runs, a restart stops recommending it.",
"false_green_caught_while_writing_it": "the post-repair silence check was initially vacuous. `driftWarned` is a per-INSTANCE throttle keyed by driftKey(d), so re-initObjects-ing the SAME driver stays silent whether or not the drift cleared — it would have passed against nothing. It now boots a SECOND driver (what a restart actually is) and asserts the pre-migration boot did name it, so the silence belongs to the repair."
},
"deviations": [
"REPORTING CHANNEL, declared: the role file's REST list endpoints are unreachable from this seat — api.github.com returns {\"message\":\"GitHub access is not enabled for this session...\"} for every call, and no `gh` CLI is installed. The issue body/comments, the dedupe search, the PR and this comment therefore went through the MCP GitHub tools, which is the only channel available rather than a preference. The local-grep half of the dedupe search was done normally.",
"One new module-level export (MULTI_VALUE_COLUMN_REMEDY_COMMAND) in schema-drift.ts, so the package's own suites can pin the spelling. Deliberately NOT added to index.ts — the published surface is unchanged, and a published export is the step that would let the CLI import its own command id back out of the driver it boots.",
"The 10-minute foreground cap (exit 143) killed one gate-union re-run mid-way under lock contention. Re-run to completion afterwards; the reported run is the complete one, and its own output asserts the commit and the clean tree it was read at. No reading from the killed run is reported."
],
"open_questions": [
{
"question": "Does #11535 close on this PR, or stay open? I used `Part of`, which is the conservative choice, but a case for `Fixes` is real and PM/maintainer should pick rather than inherit my caution. The card's own 'Expected' asked for EITHER an automatic column migration OR a loud failure naming the remedy; both halves now exist (operator-run migration in #11733, loud finding naming it here), so the reporter's ask is arguably satisfied in full. Against closing: the corruption is still REACHABLE — a single->multi change on a live database still writes a stringified array until the operator acts — and rows corrupted before that are out of scope by the #11700 ruling. Closing a user-filed silent-corruption report while the corruption can still occur is the kind of thing worth deciding deliberately.",
"options": [
"A - keep `Part of`; PM/maintainer closes this card by hand after reading this, with a closing comment that states plainly what is and is not fixed for the reporter",
"B - switch the PR's first line to the closing keyword form for this card, so the merge closes it: the remaining scope triage named is delivered, and the residual reachability is a RULED position rather than open work",
"C - keep `Part of` and leave the card open indefinitely as a tracker for the residual reachability"
],
"recommendation": "A. It gets the same end state as B without a merge silently closing a production corruption report, and it puts a human sentence in front of the reporter — who is a CONTRIBUTOR on this repo and has been following the card — saying which half is fixed and that their remaining exposure is a decision, not an oversight. C should be refused: there is no open work behind it, so the card would sit in the queue forever re-attracting dispatch, which is what the last two rounds already cost."
},
{
"question": "Should the P0 suspicion be resolved as NOT-P0 and the suspicion retired? Triage said 'escalate if it reproduces', and it reproduces — but the escalation's usual purpose (accelerate a fix) has no target left: the fix route was ruled on #11700 and both halves have landed. Stamping priority:p0 now would raise an alarm that points at no dispatchable work.",
"options": [
"A - retire the P0 suspicion with a note recording that it DID reproduce and why it is nevertheless not P0 (the ruled position, not an unfixed defect)",
"B - stamp priority:p0 anyway, so the reproduction is visible in the priority view",
"C - leave the suspicion unresolved"
],
"recommendation": "A. B creates an alarm with no work behind it, which is how a priority label stops meaning anything; C leaves the next triage round re-deriving the same measurement. A keeps the measurement on the record — which is the part that has lasting value — without pretending there is a queue item."
}
],
"out_of_scope_findings": [
"filed as #12016 [finding]: driver-sql hardcodes the `os migrate multi-value-columns` CLI command id in an operator-facing warning (third copy in content/docs/deployment/cli.mdx), and nothing mechanically ties the spellings together — a rename that updated packages/cli and the docs but not the driver leaves the warning pointing at a command that does not exist, and no suite reads that as wrong because driver-sql's pin asserts the message contains the constant, which would still match itself. Unassigned, `finding` label + no `pm:queue` per os-dev.md (observational missing-guard, not a concrete defect anyone has hit). Dedupe-searched (nearest hit #8364 is closed and about a different pin). Back-linked to #11535 and PR #12012."
]
}
Supersedes comment 5405609870: its leading marker did not survive the body sanitizer (read back — the stored body begins with a blank line), which would have made the report invisible to a marker scan. Re-posted with the marker as literal text. Content is identical apart from one added deviations entry recording this.
{
"issue": 11535,
"status": "done",
"branch": "claude/issue-11535-multi-value-column-signal",
"pr": "https://github.com/objectstack-ai/objectstack/pull/12012",
"premise_still_valid": true,
"p0_confirmation": {
"verdict": "STILL REPRODUCES — escalation trigger, not a footnote",
"measured_at": "origin/main d63b014360, live Postgres 16.13 (server timezone Asia/Shanghai, process TZ=America/New_York)",
"path": "operator path end to end: single-value table created and populated -> metadata gains multiple:true -> initObjects (additive-only, column never revisited) -> write",
"observed": "column stays `character varying(255)` while a fresh DB of the same metadata gets `json`; the array reads back as the STRING '[\"x\",\"y\"]' (typeof === 'string', Array.isArray === false)",
"what_changed_since_the_report": "the SILENCE is closed (#11720 reports it) and a remedy exists (#11733 `os migrate multi-value-columns`). The REACHABILITY is not closed and is now by ruling: #11700 route C settled that the platform warns and ships an operator-run migration rather than refusing the write or auto-altering the table. So there is no un-ruled work behind the P0 suspicion — the residual exposure is the maintainer's decided position, not a gap.",
"mysql": "NOT measured here — mysqld is not installed in this container, so that live cell is a named skip. CI runs it."
},
"summary": "The remaining half: the finding now NAMES `os migrate multi-value-columns` instead of describing a problem. Measured on origin/main, the line an operator actually meets opened with 'ObjectStack will NOT change this column for you. Migrate it by hand' and then printed raw SQL — a sentence that became FALSE when #11733 landed, and which sent operators to hand-write DDL on a production table while the command with a dry run, a prompt and a post-run re-detection sat one line away, unnamed. The message now leads with the command and keeps the hand-run statement after it; both operator surfaces pick it up unchanged because both print `message` verbatim (boot warning, and `os migrate plan` via renderPlan). Non-vacuity is pinned in both directions, including on a live database re-booted after the repair.",
"fence_respected": {
"category": "UNCHANGED at 'needs_confirm' — not raised to 'destructive'. No new destructive-category finding on this path.",
"refusal": "NONE added. No load-time, no write-time, no boot-time refusal. The change is message text only.",
"clause_2": "still `no`, and the condition it was conditional on holds. The diff moves no accept/reject set: the finding fires on exactly the same predicate as before (`declaresJsonColumn && multiValueColumnTypeIsLoadBearing(dialect) && acceptsStringifiedJson(col.type)`, untouched). The one new export is module-scoped and deliberately NOT added to the package's public index.ts, so the published surface is unchanged."
},
"files_changed": [
"packages/drivers/driver-sql/src/schema-drift.ts",
"packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts",
".changeset/drift-multi-value-column-names-remedy-command.md (new)"
],
"gates": {
"read_at_commit": "765f07814e",
"working_tree_at_that_read": "clean (0 changes), asserted inside the same run",
"derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack — exit 0; --repo assertion checked against this checkout's origin and held; change set derived by the script itself from the merge base (3 paths), NOT from a hand-made git diff",
"note_on_exit_capture": "every gate redirected to its own log FIRST and $? captured SECOND — no `cmd | tail; EXIT=$?` anywhere",
"pnpm check:changeset-gate-self-tests": 0,
"pnpm check:driver-conformance": 0,
"pnpm check:objectui-changeset": 0,
"pnpm check:published-files": 0,
"pnpm check:slot-lookup": 0,
"pnpm check:test-source-alias": 0,
"pnpm check:type-source-resolution": 0,
"node scripts/check-adr-0087-registration.mjs": 0,
"node scripts/check-changeset-no-major.mjs": 0,
"node scripts/check-empty-changeset.mjs": 0,
"node scripts/check-plugin-teardown-shape.mjs": 0,
"node scripts/docs-audit/check-affected-docs.mjs": 0,
"node scripts/docs-audit/check-drift-comment.mjs": 0,
"node scripts/pm/release-rehearsal-clone.mjs --self-test": 0,
"pnpm check:query-options-erasure": 0,
"pnpm check:type-check-coverage": 0,
"pnpm check:engine-double-contract": 0,
"pnpm check:cross-package-test-inputs": 0,
"pnpm check:where-matcher": 0,
"pnpm check:nul-bytes": 0,
"pnpm lint (whole repo, eslint . --no-inline-config)": 0,
"pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit)": 0,
"NOT RUN (declared narrowing)": "pnpm check:type-check-debt --re-measure — needs the whole workspace closure built and re-runs tsc PER LEDGER ENTRY. Measured HERE rather than inherited from the prior round: driver-sql appears in neither the EXEMPT nor the TEST_DEBT block of scripts/check-type-check-coverage.mjs (both blocks brace-matched and grepped), so this change cannot move a ledgered count. The structural half passed, and `tsc --listFiles` shows BOTH changed files inside driver-sql's own program, which exits 0.",
"verdict_lines_read": "gates quoted their own output, not a bare $? — e.g. 'check-driver-conformance: OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt.' and 'check-nul-bytes: OK (scanned 6662 text file(s) ... no raw ASCII control bytes).'"
},
"tests": "Suite (live Postgres 16.13, TZ=America/New_York): schema-drift.base-type-mismatch.test.ts 24 passed | 2 skipped (was 18 passed before this change). Drift family, 6 files: 99 passed | 2 skipped. CROSS-PACKAGE: driver-sql REBUILT, then the CLI's three multi-value-columns suites run against that dist — 21 passed. That rebuild is load-bearing there (packages/cli imports @objectstack/driver-sql through its exports map into dist/) and is NOT load-bearing for driver-sql's own suite, which imports the subject by a relative specifier ('./schema-drift.js') so vitest resolves src/. ABLATION: mutated the ${MULTI_VALUE_COLUMN_REMEDY_COMMAND} interpolation out of the message (mutating the CONSTANT would have been VOID — the suite imports it, so a mutated constant would be compared against a mutated message and pass). Mutation PROVEN ON DISK before any result was read, by anchored counts on the text I intended to change, not by the editor's exit code and not by a bare `git diff --stat`: removed-text 1 -> 0, injected marker 0 -> 1, with the script aborting (exit 90) if either count is wrong. Direction PREDICTED IN ADVANCE: 3 red — the two 'names the command' cases (unit + live boot) plus the live remedy case via its non-vacuity line — with the negative-direction case predicted to stay GREEN, since with nothing naming the command it still passes. OBSERVED: 'Tests 3 failed | 21 passed | 2 skipped', exactly those three. Restore under `trap ... EXIT INT TERM`: marker 0, anchor 1, diff against the pre-mutation copy byte-identical, git status clean. The ablation also PROVES the source-resolution claim rather than asserting it: source mutated with no rebuild and the suite went red; had it stayed green that would have been the stale-dist signature. Not a dist-leg ablation, so the strippable-marker caveat does not apply.",
"non_vacuity": {
"fires": "unit on both corrupting dialects, and on a real live-Postgres BOOT (captured through reconcileAndWarnDrift -> logger, not reconstructed): the line names the table, the physical type, the dry run and --apply, and carries the dialect statement intact. Also pinned: the command appears BEFORE the raw SQL.",
"does_not_fire": "six shapes enumerated — migrated json column, single-value field, single-value NARROW width drift, single-value WIDEN width drift, SQLite, stale integer — asserting no message names the command and no manual_column_type_change op appears. Two of those rows DO produce a finding, asserted, so the loop reads real messages instead of passing over empty arrays. On a LIVE database: after the remedy runs, a restart stops recommending it.",
"false_green_caught_while_writing_it": "the post-repair silence check was initially vacuous. `driftWarned` is a per-INSTANCE throttle keyed by driftKey(d), so re-initObjects-ing the SAME driver stays silent whether or not the drift cleared — it would have passed against nothing. It now boots a SECOND driver (what a restart actually is) and asserts the pre-migration boot did name it, so the silence belongs to the repair."
},
"deviations": [
"REPORTING CHANNEL, declared: the role file's REST list endpoints are unreachable from this seat — api.github.com returns {\"message\":\"GitHub access is not enabled for this session...\"} for every call, and no `gh` CLI is installed. The issue body/comments, the dedupe search, the PR and this comment therefore went through the MCP GitHub tools, which is the only channel available rather than a preference. The local-grep half of the dedupe search was done normally.",
"One new module-level export (MULTI_VALUE_COLUMN_REMEDY_COMMAND) in schema-drift.ts, so the package's own suites can pin the spelling. Deliberately NOT added to index.ts — the published surface is unchanged, and a published export is the step that would let the CLI import its own command id back out of the driver it boots.",
"The 10-minute foreground cap (exit 143) killed one gate-union re-run mid-way under lock contention. Re-run to completion afterwards; the reported run is the complete one, and its own output asserts the commit and the clean tree it was read at. No reading from the killed run is reported.",
"REPORTING, measured not assumed: the first copy of this report (comment 5405609870) lost its HTML-comment marker to the body sanitizer — read back, the stored body begins with a blank line, so a marker scan would not see it. Same failure the prior round hit on this card. This comment is the AUTHORITATIVE copy and spells the marker as literal text."
],
"open_questions": [
{
"question": "Does #11535 close on this PR, or stay open? I used `Part of`, which is the conservative choice, but a case for the closing form is real and PM/maintainer should pick rather than inherit my caution. The card's own 'Expected' asked for EITHER an automatic column migration OR a loud failure naming the remedy; both halves now exist (operator-run migration in #11733, loud finding naming it here), so the reporter's ask is arguably satisfied in full. Against closing: the corruption is still REACHABLE — a single->multi change on a live database still writes a stringified array until the operator acts — and rows corrupted before that are out of scope by the #11700 ruling. Closing a user-filed silent-corruption report while the corruption can still occur is the kind of thing worth deciding deliberately.",
"options": [
"A - keep `Part of`; PM/maintainer closes this card by hand after reading this, with a closing comment that states plainly what is and is not fixed for the reporter",
"B - switch the PR's first line to the closing keyword form for this card, so the merge closes it: the remaining scope triage named is delivered, and the residual reachability is a RULED position rather than open work",
"C - keep `Part of` and leave the card open indefinitely as a tracker for the residual reachability"
],
"recommendation": "A. It gets the same end state as B without a merge silently closing a production corruption report, and it puts a human sentence in front of the reporter — who is a CONTRIBUTOR on this repo and has been following the card — saying which half is fixed and that their remaining exposure is a decision, not an oversight. C should be refused: there is no open work behind it, so the card would sit in the queue forever re-attracting dispatch, which is what the last two rounds already cost."
},
{
"question": "Should the P0 suspicion be resolved as NOT-P0 and the suspicion retired? Triage said 'escalate if it reproduces', and it reproduces — but the escalation's usual purpose (accelerate a fix) has no target left: the fix route was ruled on #11700 and both halves have landed. Stamping priority:p0 now would raise an alarm that points at no dispatchable work.",
"options": [
"A - retire the P0 suspicion with a note recording that it DID reproduce and why it is nevertheless not P0 (the ruled position, not an unfixed defect)",
"B - stamp priority:p0 anyway, so the reproduction is visible in the priority view",
"C - leave the suspicion unresolved"
],
"recommendation": "A. B creates an alarm with no work behind it, which is how a priority label stops meaning anything; C leaves the next triage round re-deriving the same measurement. A keeps the measurement on the record — which is the part that has lasting value — without pretending there is a queue item."
}
],
"out_of_scope_findings": [
"filed as #12016 [finding]: driver-sql hardcodes the `os migrate multi-value-columns` CLI command id in an operator-facing warning (third copy in content/docs/deployment/cli.mdx), and nothing mechanically ties the spellings together — a rename that updated packages/cli and the docs but not the driver leaves the warning pointing at a command that does not exist, and no suite reads that as wrong because driver-sql's pin asserts the message contains the constant, which would still match itself. Unassigned, `finding` label + no `pm:queue` per os-dev.md (observational missing-guard, not a concrete defect anyone has hit). Dedupe-searched (nearest hit #8364 is closed and about a different pin). Back-linked to #11535 and PR #12012."
]
}
Reviewer of record: domain:engine seat, os-warren, session session_01W6HFzyH98W1YaQXhJUJt6o. Checked against the diff, ⛔ not the report.
1. ⚠️ The P0 confirmation — it REPRODUCES, and that is the headline
Triage asked this seat to confirm reachability and escalate if it reproduces. It was measured end-to-end on the operator path, live Postgres 16.13 at d63b014360:
single-value table created and populated → metadata gains multiple: true → initObjects (additive-only, column never revisited) → write
⇒ column stays character varying(255) while a fresh DB of the same metadata gets json; the array reads back as the STRING'["x","y"]' (typeof === 'string', Array.isArray === false).
⛔ So the silent corruption is still reachable today. That fact is recorded here in full, and it is in this round's report to the maintainer rather than buried in a card.
Ruling on Q2 — retire the P0 suspicion, A
Not because it failed to reproduce — it did — but because the escalation has no target left. A priority:p0 stamp is a dispatch-ordering signal, and there is no dispatchable work behind this one:
What remains — a single→multi change writing a stringified array until the operator acts — is the maintainer's decided position, not a gap.
⇒ Stamping p0 would raise an alarm pointing at nothing, which is how a priority label stops meaning anything. Option B refused for that reason; option C refused because it leaves the next triage round re-deriving the same measurement. The measurement is the durable artefact, and it is on the record above.
Ruling on Q1 — keep Part of, this seat closes the card by hand, A
Agreed, and the dev's argument against C is exactly right and worth quoting because it names a cost this lane has already paid twice:
there is no open work behind it, so the card would sit in the queue forever re-attracting dispatch, which is what the last two rounds already cost.
That is literally true — this card was blocked one round and dispatched the next. ⛔ A tracker with no work behind it is not free.
B (switch to a closing keyword) reaches the same end state, but I am refusing it for the reason the dev gave: a merge should not silently close a user-filed silent-corruption report while the corruption remains reachable. The reporter is a contributor on this repo and has been following the card. They are owed a sentence written by a person, not an auto-close.
⇒ I close it by hand when #12012 lands, with a closing comment stating plainly which half is fixed and that the residual exposure is a decision, not an oversight.
2. The review
⭐ Both fences held, and the diff proves it rather than asserting it
Fence
Reading from the diff
⛔ never raise category to destructive
Untouched — severity: 'error', category: 'needs_confirm'. The boot gate reads category, so an already-serving database still starts.
⛔ no load/write/boot-time refusal
None added. The change is message text plus one module constant.
Clause-②: no held
The finding fires on the identical predicate (declaresJsonColumn && multiValueColumnTypeIsLoadBearing && acceptsStringifiedJson, untouched), and the new export is module-scoped and ⛔ deliberately not added to index.ts — published surface unchanged.
Path face
3 files, all driver-sql + one changeset. No governed path, no packages/spec/src/**.
The actual defect this fixed is better than the card described
The message opened with "ObjectStack will NOT change this column for you. Migrate it by hand" — a sentence that was true when written and became false the moment #11733 landed. Operators were being sent to hand-write DDL on a production table while the command with a dry run, a prompt and a post-run re-detection sat one line away, unnamed.
⭐ The regression guard for that specific false sentence is kept, with the right justification: the failure it describes is invisible — the message would still be loud, still name the right column, still print working SQL, while telling the operator that the command two lines below does not exist.
⭐ A cross-package contract pinned from the emitting side
planStaleColumnTargets in packages/cli recovers the dialect by testing which dialect's statement the message includes — a ManagedDriftEntry carries no dialect. So a reword that paraphrases or line-wraps the SQL makes every finding remedy_not_recognized: the command this message now points at would refuse to run, and nothing in driver-sql's own suite would have noticed. The suite now reproduces that probe rather than describing it.
⭐ The false green the dev caught while writing it
The post-repair silence check was initially vacuous: driftWarned is a per-instance throttle keyed by driftKey(d), so re-initObjects-ing the same driver stays silent whether or not the drift cleared. It now boots a second driver — which is what a restart actually is — and asserts the pre-migration boot did name it, so the silence belongs to the repair. ⇒ Catching your own vacuous assertion mid-write is worth more than the test it replaced.
Non-vacuity: six must-not-fire shapes enumerated, and ⭐ two of them deliberately DO produce a finding, so the loop reads real messages instead of passing over empty arrays. Ablation mutated the interpolation, not the constant — mutating the constant would have been VOID, since the suite imports it and would have compared a mutated message to a mutated expectation. Direction predicted (3 red, named), observed exactly.
CI: 34 checks, all green. Path face clean ⇒ releasing landing.
Noted, not owed
mysqld is not installed in that container, so the MySQL live cell was a named skip, ⛔ not a silent one — CI runs it. And #12016 was filed for the hardcoded command id having three copies with nothing tying them together; it neighbours #12017, which this seat filed for the same class one layer over.
Closing — and what that does and does not mean for you
PR #12012 landed. Verified by content on origin/main, ⛔ not by the API's merged field: MULTI_VALUE_COLUMN_REMEDY_COMMAND → 2 in schema-drift.ts (control manual_column_type_change → 2), and main's tip is the squash commit c804f0ca5f.
Closed by hand rather than by a closing keyword, deliberately — ⛔ a merge should not silently close a silent-corruption report. @os-sam, this paragraph is the point of that choice.
What is fixed
Your report was that the corruption was silent. It is not silent any more, and there is now a way out that is not hand-written DDL:
It is reported — a multi-value field left on a stale varchar/text column is named on every boot and by os migrate plan, with the table, the column, the declared type and the physical type.
⭐ A false sentence was removed. The warning used to open with "ObjectStack will NOT change this column for you. Migrate it by hand" — true when written, false from the moment that command shipped. It was sending operators to hand-write DDL on a production table while the safer route sat one line away, unnamed.
What is NOT fixed, stated plainly
⚠️The corruption is still reachable. This seat re-measured it end-to-end on live Postgres 16.13 before closing, on the operator path, and it reproduces: after a field gains multiple: true, the column stays character varying(255) and the next write stores the literal '["x","y"]', read back as a string. Nothing changes that until an operator runs the command.
That is a decision, not an oversight — and it is the maintainer's, recorded on #11700 (2026-08-24, route C): the platform warns and ships an explicit operator-run migration, and never alters a customer's production table unattended. Auto-migration was considered and rejected on exactly that ground.
⚠️ Also unchanged, and worth knowing: rows written while the column was stale may hold a stringified array in a related single-value column. Neither the command nor the hand-run statement repairs those — the #11700 ruling put historical data out of scope in as many words.
Why the card closes rather than staying open as a tracker
There is no open work behind it. Both halves triage named have landed (detection #11720, remedy command #11733, and this PR making the message name it). A card with nothing dispatchable in it sits in the queue re-attracting dispatch — which is what the last two rounds of this lane actually cost.
⇒ If the residual reachability should be revisited, that is a new decision card against the #11700 ruling, not a continuation of this one. ⛔ Reopening is free, and this comment is the evidence anyone would need to argue for it.
The P0 suspicion, resolved
Triage flagged this P0-suspect and asked the engine seat to escalate if it reproduces. It reproduces, and the measurement is on the record above. priority:p0 is deliberately not stamped: it is a dispatch-ordering signal, and there is no dispatchable work behind it — the route is ruled and both halves shipped. ⛔ An alarm with no work behind it is how a priority label stops meaning anything. Full reasoning at 5405686794.
Activity
os-zhuang commentedon Aug 24, 2026
Triage (Routine seat, hourly round): →
pm:queue+domain:engine, type Bug. P0-suspect — flagged to the engine seat for front-of-queue treatment (silent data corruption: arrays stored as stringified literals into an unmigrated varchar/text column on Postgres; the release-board four-type test's "shipped-data shape you cannot repair after release" class). Not stampingpriority:p0from triage — the engine seat should confirm reachability (single→multi field change throughos migrateon a live table) and escalate if it reproduces.Family serial note: part of this round's driver-sql group (#11535 #11541 #11550 #11565 + blocked #11431) — fold-or-serial answered under the five gates at dispatch.
Generated by Claude Code
huangyiirene commentedon Aug 24, 2026
Claim: PM loop round 2
Session:
session_01VK8rFDtg8eREaxBGX99CsnBranch:
claude/issue-11535-drift-detect-base-type-mismatchWorktree:
objectstack-issue-11535Domain:
domain:engineFile surface:
packages/drivers/driver-sql/src/schema-drift.ts+ its test files (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opus(tier:dispatch-gates.mjs --tierreturns no path-derived mandate. Judgement tier because the severity/category choice has deployment consequences — see the premise below.)Clause-②: no — this adds detection and reporting of a physical/declared mismatch. It does not move the metadata contract's accept set in either direction and widens no public surface; it is
declared ≠ enforcedcatching up on the enforcement side.Serial constraints cleared: landing point is
schema-drift.ts(1563 lines), measured — no open PR touches that file (all open PRs enumerated, diffs read). File-disjoint from the sibling dispatched in the same round (#11379,sql-driver.tsintrospectFor" + "eignKeys). The twosql-driver.tsholds (#6009, #8740) name symbols in that other file and are not reachable from this surface.⛔ This card is SPLIT, and the split is the point
This is a user-filed production report — silent data corruption on a long-lived Postgres database, with a manual workaround already applied in production. It deserves a fix now, and the card itself frames two routes:
Only the second route is dispatched. The first is not mine to authorise:
ALTER TABLE … TYPE … USING …over existing rows, with index drop/rebuild, is the shape of a migration over existing data and a destructive, hard-to-roll-back action. Both sit squarely on the maintainer's floor, and neither an execution seat nor triage may adjudicate them.relax/tighten_not_null,widen/narrow_varchar,drop_column,drop_column_default— verified atschema-drift.ts:129-133— and a base-type mismatch is not reported at all. Making it visible restores an invariant rather than deciding anything, which is explicitly not an escalation.The auto-migration half is filed as its own decision card for the maintainer, with the four-facet analysis; this claim links it once filed.
⛔ Therefore: do NOT implement a column-type migration in this PR. Not a partial one, not one behind a flag, not a "prepare the op type but don't run it" version that quietly lands the vocabulary. If you conclude detection is impossible without it, that is a fork — report it and stop; ⛔ do not push through and ⛔ do not silently fall back to the other route.
Ruling, with its premise — verify the premise before you build on it
The route above is decided. It hangs on one named, falsifiable premise:
varcharcolumn. If anerrordrift finding refuses the boot, then "reporting loudly" converts a currently-serving (if corrupt) deployment into a refusing one — that is a breaking change for exactly the users this card is meant to help, and it would need the maintainer, not me.So: measure what an
error-severity finding actually does in this codebase before choosing a severity. If it blocks boot, ⛔ do not ship that, and report the fork with what you measured.schema-drift.ts:392already describesnarrow_varcharas severityerror, category destructive — read how that is consumed, do not infer it from the words.What good looks like
ALTER, which is a clue about what the detector can already see.JSON.parses on SQLite). A pin that passes only on SQLite would be exactly the false green this repo keeps paying for. Live Postgres is available in the container — ⛔ check first and start it if down, never assume it is running.Reporting keyword
Use
Part of #11535, ⛔ notFixes. The card's remaining half is a live maintainer decision, and a merge that silently closed this user's corruption report would be wrong. CI enforces the pairing (Part-of PR must not also close its card).Generated by Claude Code
huangyiirene commentedon Aug 24, 2026
Typo correction to the claim comment above (
5395496220), for the record since claim comments are read by other seats doing region checks: the sibling's method name in the "Serial constraints cleared" line is garbled — it readsintrospectFor" + "eignKeysand should readintrospectForeignKeys. Nothing else in that line changes: sibling card #11379, filepackages/drivers/driver-sql/src/sql-driver.ts, still a different file from this card'sschema-drift.ts, so the disjointness claim stands as written.Generated by Claude Code
test(driver-sql): type the drift suite's find() queries instead of ca…
huangyiirene commentedon Aug 24, 2026
{ "issue": 11535, "status": "done", "branch": "claude/issue-11535-drift-detect-base-type-mismatch", "pr": "https://github.com/objectstack-ai/objectstack/pull/11720", "premise_still_valid": true, "premise_notes": { "issue_premise": "CONFIRMED end to end on live Postgres 16.13 and MySQL 8.0.46. After a field gains multiple:true on an existing DB the column stays character varying(255)/varchar, detectManagedDrift() returns [] and zero [schema-drift] warn lines are logged, and the next write stores the literal '[\"user_A\",\"user_B\"]' and reads it back with typeof==='string', isArray===false. The reporter's root-cause paragraph is confirmed rather than assumed: with the FK left in place the insert failed with Key (assignee)=([\"user_A\",\"user_B\"]) is not present in table \"sys_user\", i.e. the write path's stringification caught in the act.", "ruling_premise": "HALF FALSE, and the falsified half is the one the dispatch flagged. The boot-gating axis is CATEGORY, not SEVERITY. severity is read by NO gate (the ordinary boot path warns on every entry regardless); category==='destructive' is read by runArtifactBootMigrationGate (packages/cli/src/utils/artifact-boot-migration.ts:137, kernel:ready, before the socket opens) and REFUSES the boot. Measured both ways with the real gate function: destructive -> ok=false, needs_confirm -> ok=true, safe -> ok=true. An ordinary (non-artifact) boot completed even with a destructive entry present. Shipped as severity 'error' + category 'needs_confirm': loud on every path, refuses no boot, excluded from dev auto-reconcile (which takes 'safe' only).", "pm_assumption_1_landing_point": "CONFIRMED - packages/drivers/driver-sql/src/schema-drift.ts was the right file; no sql-driver.ts edit was needed.", "pm_assumption_2_detector_sees_physical_type": "CONFIRMED - PhysicalColumn.type is populated from knex columnInfo(); the differ already had the physical type word in hand and simply never compared it.", "pm_assumption_3_not_clause_2": "CONFIRMED - adds a report only. No metadata accept/reject set moved." }, "summary": "Adds base-type drift detection for a multi-value field left on a stale varchar/text column, the shape that today produces total silence. The finding names the table, column, declared type (json), physical type, and a dialect-correct statement the operator runs by hand - and the suite EXECUTES that statement against live Postgres and MySQL over every row state, asserting the finding then clears. Detection only: the new manual_column_type_change op has no reconciler arm by design, and the doc comment says the automatic ALTER remains the maintainer's open decision. Dialect scope is measured, not assumed - the same fixture corrupts on Postgres 16.13 and MySQL 8.0.46 and does NOT on SQLite (reads back as a real array), so SQLite is excluded; a stale integer/timestamp column is excluded because the server already refuses that write loudly, so there is no silence to break. One bounded in-place fix in the same file and defect class is included and declared (see deviations).", "gates": { "run_at_commit": "ecd52f335e", "pnpm lint (whole repo, eslint . --no-inline-config)": 0, "pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit)": 0, "drift family, 9 test files, all 3 dialects live": "0 (115 passed, 1 skipped)", "pnpm check:changeset-gate-self-tests": 0, "pnpm check:driver-conformance": 0, "pnpm check:objectui-changeset": 0, "pnpm check:published-files": 0, "pnpm check:slot-lookup": 0, "pnpm check:test-source-alias": 0, "pnpm check:type-source-resolution": 0, "node scripts/check-adr-0087-registration.mjs": 0, "node scripts/check-changeset-no-major.mjs": 0, "node scripts/check-empty-changeset.mjs": 0, "node scripts/check-plugin-teardown-shape.mjs": 0, "node scripts/pm/release-rehearsal-clone.mjs --self-test": 0, "node scripts/docs-audit/check-affected-docs.mjs": 0, "pnpm check:query-options-erasure": 0, "pnpm check:engine-double-contract": 0, "pnpm check:cross-package-test-inputs": 0, "pnpm check:where-matcher": 0, "pnpm check:type-check-coverage": 0, "pnpm check:nul-bytes": 0, "NOT RUN (declared narrowing)": "pnpm check:type-check-debt --re-measure - needs the whole workspace closure built. It re-runs tsc PER LEDGER ENTRY and driver-sql appears in neither the DEBT nor the EXEMPT block of scripts/check-type-check-coverage.mjs, so this change cannot move a ledgered count. The structural half (check:type-check-coverage) passed, and the package's own tsc --noEmit is green over a program that provably includes the new test file (tsc --listFiles shows it; tsconfig includes src/**/* with no test exclusion)." }, "files_changed": [ "packages/drivers/driver-sql/src/schema-drift.ts", "packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts (new)", ".changeset/drift-detect-multi-value-base-type-mismatch.md (new)" ], "tests": "Ablation, both legs proven ON DISK (not by the editor's exit code): mutated `const declaresJsonColumn = field.multiple === true` -> `false` under a `trap '<restore>' EXIT INT TERM`; anchor counts after mutation removed-text=0 injected-marker=1; ABLATED RUN = 9 failed | 13 passed (both live cells' detectManagedDrift() case failed, and the narrow_varchar regression case failed); restore verified injected-marker=0, original-anchor=1, clean tree; RESTORED RUN = 22 passed | 1 skipped. NO REBUILD LEG, and the reason is structural: the suite imports the subject by a RELATIVE specifier ('./schema-drift.js') from inside its own package, so vitest resolves it to src/schema-drift.ts - nothing reaches a dependency's `exports` -> dist/, which is the condition that makes a rebuild load-bearing. Live servers were down at start and were started per the dispatch: Postgres 16.13 at timezone='Asia/Shanghai', MySQL 8.0.46 at time_zone='+08:00', process TZ=America/New_York. The remedy SQL the finding prints was EXECUTED against both live servers over 4 row states; after it, both dialects return byte-identical results (corrupted -> [\"x\",\"y\"], legacy single -> [\"a\"], empty string -> null, NULL -> null) and the finding clears.", "deviations": [ "BOUNDED IN-PLACE FIX, declared: the varchar-width branch is now guarded by `!declaresJsonColumn`. Measured on the pre-fix tree, `{ multiple: true, maxLength: 50 }` over a stale varchar(255) produced narrow_varchar at severity 'error', category DESTRUCTIVE on both enforcing dialects - a finding that refuses the artifact-pinned boot and invites `os migrate apply --allow-destructive` to rewrite the column to varchar(50), the exact opposite of the repair it needs (json). All four exemption conditions hold: same defect class as this card; mechanical, with the correct shape pinned by the EMITTER (createColumn returns at `if (field.multiple) { table.json(name); return; }` before maxLength is ever read) and by the already-landed #11431 principle; no other claim holds this file (measured at dispatch); same gate family, no new verification surface. The declared FILE surface did not grow - it is the same schema-drift.ts - and the fix is named with its evidence in the PR body. The single-value width branch is untouched and pinned as untouched.", "The emitted Postgres remedy deviates from the reporter's production SQL in two places, both forced by measurement: (a) their `to_json(col)` turns a legacy single value into the JSON SCALAR \"a\" under a now-multi-value field - it read back with Array.isArray===false - so `json_build_array` is used instead, which makes Postgres agree with MySQL's JSON_ARRAY; (b) `json_build_array(NULL)` is `[null]`, so an explicit `IS NULL` arm was added - without it every NULL row silently gained a value, observed on the live server before the arm existed.", "MINOR, unfixable without a force-push: the second commit's body carries a `Part of #11535` line. The squash guidance prefers card relationships declared once in the PR body only. It is harmless here - `Part of` is not a closing keyword and the two commit messages do not contradict each other - and correcting it would require rewriting a pushed commit, which is forbidden.", "A stronger pin for the boot consequence would live in packages/cli next to artifact-boot-migration.test.ts, feeding a real base-type entry through the actual gate. That is outside the declared file surface, so it was NOT added; the pin in driver-sql asserts the category structurally with the measurement written into its comment. Flagging so PM can decide whether to dispatch it." ], "open_questions": [ { "question": "Is `manual_column_type_change` an acceptable name and shape for a DriftOp that the reconciler must never execute? The ruling forbids landing migration vocabulary, including 'add the op type but never run it'. ManagedDriftEntry requires an `op`, so a finding cannot travel without one, and no existing op is safe to borrow (each has a reconciler arm that would do the wrong thing).", "options": [ "A - keep `manual_column_type_change`: the name asserts the platform does NOT act, the doc comment states there is deliberately no arm and that the automatic ALTER is the maintainer's open decision, and the reconciler's decline is pinned", "B - rename to something with no migration word at all (e.g. `report_column_type_mismatch`), making it unmistakable that the op's action is 'report'", "C - make `op` optional on ManagedDriftEntry for report-only findings - larger blast radius, every consumer that reads `d.op.type` would need a null path" ], "recommendation": "A. It is the absence of an automatic migration made explicit rather than a placeholder for one, and it names what the OPERATOR must do, which is what the dispatch asked the finding to carry. B is a defensible rename if the maintainer reads A as pre-empting the decision - it is a one-line change with no behavioural difference. C should be refused: it widens a shared contract to serve one finding." }, { "question": "The category vocabulary has no 'report only' state, so this finding relies on the reconciler declining an op it has no arm for, and the decline is logged as \"unsupported on dialect 'postgres'\" - which is misleading, since it is unsupported on every dialect by design. Filed as #11721 rather than fixed, because the repair is in sql-driver.ts and packages/cli, outside this round's declared surface.", "options": [ "A - leave as-is; the decline works and is pinned", "B - dispatch #11721 to add a REPORT_ONLY_DRIFT_OPS partition read by applyMigrationEntries, so such ops are separated before the dialect switch and get their own wording" ], "recommendation": "B, but as its own card - it touches the file a sibling dispatch held this round, and the boundary it draws should be declared once rather than re-derived, which is the exact property artifact-boot-migration.ts warns about." } ], "out_of_scope_findings": [ "filed as #11721: the drift category vocabulary has no 'report only' state, so a never-reconcilable finding must borrow needs_confirm and be declined by the reconciler; the decline message also misattributes the refusal to the dialect", "filed as #11722: applyMigrationEntries reports every SQLite entry as applied after a table rebuild, including ops rebuildSqliteTablePatched is documented to ignore - latent today (the only ops the differ emits on SQLite are the four the rebuild handles), a false green when the next non-rebuildable op arrives" ] }Generated by Claude Code
huangyiirene commentedon Aug 24, 2026
os-dev-report
Supersedes comment 5396318675: its leading
<!-- os-dev-report -->marker did not survive the body sanitizer (read back — the stored body begins with a blank line), which would have made the report invisible to a marker scan. Re-posted with the marker as literal text. One string in the previous copy was also mangled by the same sanitizer (thetrapexample lost its angle-bracketed placeholder); it is spelled without angle brackets below. Content is otherwise identical.{ "issue": 11535, "status": "done", "branch": "claude/issue-11535-drift-detect-base-type-mismatch", "pr": "https://github.com/objectstack-ai/objectstack/pull/11720", "premise_still_valid": true, "premise_notes": { "issue_premise": "CONFIRMED end to end on live Postgres 16.13 and MySQL 8.0.46. After a field gains multiple:true on an existing DB the column stays character varying(255)/varchar, detectManagedDrift() returns [] and zero [schema-drift] warn lines are logged, and the next write stores the literal '[\"user_A\",\"user_B\"]' and reads it back with typeof==='string', isArray===false. The reporter's root-cause paragraph is confirmed rather than assumed: with the FK left in place the insert failed with Key (assignee)=(['user_A','user_B']) is not present in table sys_user, i.e. the write path's stringification caught in the act.", "ruling_premise": "HALF FALSE, and the falsified half is the one the dispatch flagged. The boot-gating axis is CATEGORY, not SEVERITY. severity is read by NO gate (the ordinary boot path warns on every entry regardless); category==='destructive' is read by runArtifactBootMigrationGate (packages/cli/src/utils/artifact-boot-migration.ts:137, kernel:ready, before the socket opens) and REFUSES the boot. Measured both ways with the real gate function: destructive gives ok=false, needs_confirm gives ok=true, safe gives ok=true. An ordinary (non-artifact) boot completed even with a destructive entry present. Shipped as severity 'error' + category 'needs_confirm': loud on every path, refuses no boot, excluded from dev auto-reconcile (which takes 'safe' only).", "pm_assumption_1_landing_point": "CONFIRMED - packages/drivers/driver-sql/src/schema-drift.ts was the right file; no sql-driver.ts edit was needed.", "pm_assumption_2_detector_sees_physical_type": "CONFIRMED - PhysicalColumn.type is populated from knex columnInfo(); the differ already had the physical type word in hand and simply never compared it.", "pm_assumption_3_not_clause_2": "CONFIRMED - adds a report only. No metadata accept/reject set moved." }, "summary": "Adds base-type drift detection for a multi-value field left on a stale varchar/text column, the shape that today produces total silence. The finding names the table, column, declared type (json), physical type, and a dialect-correct statement the operator runs by hand - and the suite EXECUTES that statement against live Postgres and MySQL over every row state, asserting the finding then clears. Detection only: the new manual_column_type_change op has no reconciler arm by design, and the doc comment says the automatic ALTER remains the maintainer's open decision. Dialect scope is measured, not assumed - the same fixture corrupts on Postgres 16.13 and MySQL 8.0.46 and does NOT on SQLite (reads back as a real array), so SQLite is excluded; a stale integer/timestamp column is excluded because the server already refuses that write loudly, so there is no silence to break. One bounded in-place fix in the same file and defect class is included and declared (see deviations).", "gates": { "run_at_commit": "ecd52f335e", "pnpm lint (whole repo, eslint . --no-inline-config)": 0, "pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit)": 0, "drift family, 9 test files, all 3 dialects live": "0 (115 passed, 1 skipped)", "pnpm check:changeset-gate-self-tests": 0, "pnpm check:driver-conformance": 0, "pnpm check:objectui-changeset": 0, "pnpm check:published-files": 0, "pnpm check:slot-lookup": 0, "pnpm check:test-source-alias": 0, "pnpm check:type-source-resolution": 0, "node scripts/check-adr-0087-registration.mjs": 0, "node scripts/check-changeset-no-major.mjs": 0, "node scripts/check-empty-changeset.mjs": 0, "node scripts/check-plugin-teardown-shape.mjs": 0, "node scripts/pm/release-rehearsal-clone.mjs --self-test": 0, "node scripts/docs-audit/check-affected-docs.mjs": 0, "pnpm check:query-options-erasure": 0, "pnpm check:engine-double-contract": 0, "pnpm check:cross-package-test-inputs": 0, "pnpm check:where-matcher": 0, "pnpm check:type-check-coverage": 0, "pnpm check:nul-bytes": 0, "NOT RUN (declared narrowing)": "pnpm check:type-check-debt --re-measure - needs the whole workspace closure built. It re-runs tsc PER LEDGER ENTRY and driver-sql appears in neither the DEBT nor the EXEMPT block of scripts/check-type-check-coverage.mjs, so this change cannot move a ledgered count. The structural half (check:type-check-coverage) passed, and the package's own tsc --noEmit is green over a program that provably includes the new test file (tsc --listFiles shows it; tsconfig includes src/**/* with no test exclusion)." }, "files_changed": [ "packages/drivers/driver-sql/src/schema-drift.ts", "packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts (new)", ".changeset/drift-detect-multi-value-base-type-mismatch.md (new)" ], "tests": "Ablation, both legs proven ON DISK (not by the editor's exit code): mutated `const declaresJsonColumn = field.multiple === true` to `false` under a shell trap on EXIT INT TERM that restores the file; anchor counts after mutation removed-text=0 injected-marker=1; ABLATED RUN = 9 failed | 13 passed (both live cells' detectManagedDrift() case failed, and the narrow_varchar regression case failed); restore verified injected-marker=0, original-anchor=1, clean tree; RESTORED RUN = 22 passed | 1 skipped. NO REBUILD LEG, and the reason is structural: the suite imports the subject by a RELATIVE specifier ('./schema-drift.js') from inside its own package, so vitest resolves it to src/schema-drift.ts - nothing reaches a dependency's exports map into dist/, which is the condition that makes a rebuild load-bearing. Live servers were down at start and were started per the dispatch: Postgres 16.13 at timezone='Asia/Shanghai', MySQL 8.0.46 at time_zone='+08:00', process TZ=America/New_York. The remedy SQL the finding prints was EXECUTED against both live servers over 4 row states; after it, both dialects return byte-identical results (corrupted gives ['x','y'], legacy single gives ['a'], empty string gives null, NULL gives null) and the finding clears.", "deviations": [ "BOUNDED IN-PLACE FIX, declared: the varchar-width branch is now guarded by `!declaresJsonColumn`. Measured on the pre-fix tree, `{ multiple: true, maxLength: 50 }` over a stale varchar(255) produced narrow_varchar at severity 'error', category DESTRUCTIVE on both enforcing dialects - a finding that refuses the artifact-pinned boot and invites `os migrate apply --allow-destructive` to rewrite the column to varchar(50), the exact opposite of the repair it needs (json). All four exemption conditions hold: same defect class as this card; mechanical, with the correct shape pinned by the EMITTER (createColumn returns at the field.multiple branch, emitting table.json(name), before maxLength is ever read) and by the already-landed #11431 principle; no other claim holds this file (measured at dispatch); same gate family, no new verification surface. The declared FILE surface did not grow - it is the same schema-drift.ts - and the fix is named with its evidence in the PR body. The single-value width branch is untouched and pinned as untouched.", "The emitted Postgres remedy deviates from the reporter's production SQL in two places, both forced by measurement: (a) their to_json(col) turns a legacy single value into a JSON SCALAR under a now-multi-value field - it read back with Array.isArray===false - so json_build_array is used instead, which makes Postgres agree with MySQL's JSON_ARRAY; (b) json_build_array(NULL) is a one-element array containing null, so an explicit IS NULL arm was added - without it every NULL row silently gained a value, observed on the live server before the arm existed.", "MINOR, unfixable without a force-push: the second commit's body carries a `Part of #11535` line. The squash guidance prefers card relationships declared once in the PR body only. It is harmless here - `Part of` is not a closing keyword and the two commit messages do not contradict each other - and correcting it would require rewriting a pushed commit, which is forbidden.", "A stronger pin for the boot consequence would live in packages/cli next to artifact-boot-migration.test.ts, feeding a real base-type entry through the actual gate. That is outside the declared file surface, so it was NOT added; the pin in driver-sql asserts the category structurally with the measurement written into its comment. Flagging so PM can decide whether to dispatch it.", "REPORTING: the first report comment (5396318675) lost its HTML-comment marker to the body sanitizer. This comment is the authoritative copy." ], "open_questions": [ { "question": "Is `manual_column_type_change` an acceptable name and shape for a DriftOp that the reconciler must never execute? The ruling forbids landing migration vocabulary, including 'add the op type but never run it'. ManagedDriftEntry requires an `op`, so a finding cannot travel without one, and no existing op is safe to borrow (each has a reconciler arm that would do the wrong thing).", "options": [ "A - keep `manual_column_type_change`: the name asserts the platform does NOT act, the doc comment states there is deliberately no arm and that the automatic ALTER is the maintainer's open decision, and the reconciler's decline is pinned", "B - rename to something with no migration word at all (e.g. `report_column_type_mismatch`), making it unmistakable that the op's action is 'report'", "C - make `op` optional on ManagedDriftEntry for report-only findings - larger blast radius, every consumer that reads d.op.type would need a null path" ], "recommendation": "A. It is the absence of an automatic migration made explicit rather than a placeholder for one, and it names what the OPERATOR must do, which is what the dispatch asked the finding to carry. B is a defensible rename if the maintainer reads A as pre-empting the decision - it is a one-line change with no behavioural difference. C should be refused: it widens a shared contract to serve one finding." }, { "question": "The category vocabulary has no 'report only' state, so this finding relies on the reconciler declining an op it has no arm for, and the decline is logged as unsupported on dialect 'postgres' - which is misleading, since it is unsupported on every dialect by design. Filed as #11721 rather than fixed, because the repair is in sql-driver.ts and packages/cli, outside this round's declared surface.", "options": [ "A - leave as-is; the decline works and is pinned", "B - dispatch #11721 to add a REPORT_ONLY_DRIFT_OPS partition read by applyMigrationEntries, so such ops are separated before the dialect switch and get their own wording" ], "recommendation": "B, but as its own card - it touches the file a sibling dispatch held this round, and the boundary it draws should be declared once rather than re-derived, which is the exact property artifact-boot-migration.ts warns about." } ], "out_of_scope_findings": [ "filed as #11721: the drift category vocabulary has no 'report only' state, so a never-reconcilable finding must borrow needs_confirm and be declined by the reconciler; the decline message also misattributes the refusal to the dialect", "filed as #11722: applyMigrationEntries reports every SQLite entry as applied after a table rebuild, including ops rebuildSqliteTablePatched is documented to ignore - latent today (the only ops the differ emits on SQLite are the four the rebuild handles), a false green when the next non-rebuildable op arrives" ] }Generated by Claude Code
huangyiirene commentedon Aug 24, 2026
ACCEPT — PR #11720 (detection half)
Reviewer of record:
domain:engineseat,huangyiirene, sessionsession_01VK8rFDtg8eREaxBGX99Csn. Checked against the diff, not the report. ⛔ Landing held — last section.⭐ The headline: my ruling's premise was falsified, on exactly the axis I flagged
I hung the route on a named premise and told the dev to verify it before building:
It is half false, and the false half is the dangerous one. The boot-gating axis is category, not severity:
severityis read by no gate — the ordinary boot path warns on every entry regardless.category === 'destructive'is read, byrunArtifactBootMigrationGate(packages/cli/src/utils/artifact-boot-migration.ts:137) atkernel:ready, before the socket opens, and it refuses the boot.destructive→ok=false;needs_confirm→ok=true;safe→ok=true.Had the dev taken my premise at face value and reached for a "loud" severity, it would have made no difference to booting — and had it reached for
destructiveas the way to be loud, it would have bricked the boot of every already-running deployment this card exists to help. Shipped instead as severityerror+ categoryneeds_confirm: loud on every path, refuses no boot, and excluded from dev auto-reconcile (which takessafeonly). That is the correct resolution, and it exists because the premise was measured rather than inherited.The boundary I drew — verified held, not taken on trust
I forbade the auto-migration half in any form, including "add the op type but never run it". Checked directly in the diff:
await,knex,.raw(, orexecutein the added source. TheALTER TABLEstrings that appear are returned as remedy text for the operator to run by hand, which is what the card asked for.schema-drift.ts+ its new test + a changeset. Nosql-driver.ts, nopackages/cli, no governed path, nopackages/spec.ALTER TABLE … TYPE … USING …itself is a live maintainer decision (the other half of driver-sql: single→multi-value field change never migrates the existing column type (varchar/text kept), arrays silently stored as stringified literals — data corruption on Postgres #11535)" — and it points at the open decision rather than pre-empting it.Answering the dev's open question 1 — decided, not escalated
Keep it (option A). The dev was right to ask, and asking is what converts my prohibition into a decision I can make rather than a rule it quietly bent.
ManagedDriftEntryrequires anop, so a finding cannot travel without one, and every existing op carries a reconciler arm that would do the wrong thing — so the choice was never "op or no op".A is fine because nothing about it is quiet: the prefix names the operator's action, the docblock is explicit that there is no arm and that the ALTER is a live maintainer decision, and the reconciler's decline is pinned. My prohibition was aimed at machinery landing silently and pre-empting #11700; none of that is happening. B (
report_column_type_mismatch) is a defensible rename with zero behavioural difference — not worth a dev round and a CI cycle now, and trivially done later if the #11700 ruling makes the wording awkward. ⛔ C is refused for the reason the dev gives: it widens a shared contract to serve one finding.The declared in-place fix — accepted, and it is the best catch in the PR
The varchar-width branch is now guarded by
!declaresJsonColumn. Verified in the diff; the single-value branch is untouched and pinned as untouched.This matters more than its size. Measured on the pre-fix tree, a
{ multiple: true, maxLength: 50 }field over a stalevarchar(255)producednarrow_varcharat severityerror, category destructive — which (per the finding above) refuses the artifact-pinned boot, and invites the operator to runos migrate apply --allow-destructive, rewriting the column tovarchar(50): the exact opposite of the repair it needs. So the detector was not merely silent on this shape, it was actively steering operators toward making the corruption worse. The card never named that, and it is squarely the same defect class. The four exemption conditions hold and it is declared with its evidence in the PR body rather than slipped in.Evidence quality
Ablation with both legs proven on disk rather than by an editor's exit code: mutating
declaresJsonColumntofalseunder a trap-restore gave9 failed | 13 passed, restore verified by anchor counts and a clean tree, restored run22 passed | 1 skipped. No rebuild leg needed and the reason is structural (relative specifier → vitest resolves source, so the stale-distfalse-green direction is unreachable).⭐ The remedy SQL is not merely printed — the suite executes it against live Postgres 16.13 and MySQL 8.0.46 over four row states and asserts the finding then clears. Two corrections to the reporter's production SQL were forced by measurement, not preference:
to_json(col)produces a JSON scalar under a now-multi-value field (sojson_build_arrayis used, making PG agree with MySQL'sJSON_ARRAY), andjson_build_array(NULL)is[null]— a one-element array — so an explicitIS NULLarm was added after the version without it was run and observed to give every NULL row a value. Dialect scope is measured too: the fixture corrupts on PG and MySQL and does not on SQLite, so SQLite is excluded rather than assumed in.Declared narrowing — accepted
check:type-check-debt --re-measurenot run, on the structural argument thatdriver-sqlappears in neither the DEBT nor the EXEMPT block, so this change cannot move a ledgered count; the structural half (check:type-check-coverage) passed and the package's owntsc --noEmitis green over a program that provably includes the new test file (--listFiles). Reasonable, and CI runs the real gate.Minor, no action: the second commit body carries a
Part of #11535line. Harmless (not a closing keyword, no contradiction) and correcting it would need a force-push on a pushed commit — ⛔ correctly not done.Follow-ups
needs_confirmand be declined by the reconciler #11721 (report-only category state; the decline is also misattributed to the dialect) — agreed it is its own card: it touchessql-driver.tsandpackages/cli, outside this surface.applyMigrationEntriesreports every SQLite entry as applied after a table rebuild, including ops the rebuild is documented to ignore #11722 (applyMigrationEntriesreports every SQLite entry as applied after a table rebuild, including ops the rebuild ignores) — latent today, a false green when the first non-rebuildable op arrives. Good catch.packages/cli, which is another seat's lane, so I am filing it there rather than reaching across.⛔ Landing held
Review verdict is ACCEPT. The flip is not taken: I have not yet read this PR's check runs, and entry needs every check green — not the
requiredsubset. I will take that reading before flipping, and this card getsPart of, so it stays open for the maintainer's #11700 decision either way.Generated by Claude Code
huangyiirene commentedon Aug 24, 2026
Blocked-by: #11733
Detection half landed — this card moves to
pm:blocked, it does not closePR #11720 merged as
09f93617e5. Verified by content onorigin/main, ⛔ not by the API'smergedfield (which was measured wrong on another PR today):manual_column_type_changeis present inpackages/drivers/driver-sql/src/schema-drift.ts.What is fixed for you now: the silence is gone. A multi-value field left on a stale
varchar/textcolumn is reported — naming the table, the column, the declared type, the physical type, and a dialect-correct statement to run by hand. The finding is loud on every path and ⛔ refuses no boot (severityerror, categoryneeds_confirm— the boot gate reads category, anddestructiveis the value that would have stopped your already-running database from starting).Also fixed, and it was doing active harm: on the pre-fix tree a
multiple: truefield with amaxLengthproduced anarrow_varcharfinding at category destructive — which refused an artifact-pinned boot and invitedos migrate apply --allow-destructiveto rewrite the column to a narrower varchar, the exact opposite of the repair it needs. That branch is now guarded.What is not fixed, and why this card stays open: the platform still does not migrate the column for you. That was the other half, and it is now ruled.
The ruling on the other half
Maintainer, 2026-08-24 (#11700, now closed). ⛔ Quoted verbatim: 「11700 11693 不需要考虑历史数据,其他按照你的建议继续」 — route C: the platform warns and ships an explicit, operator-run migration command (with a dry run, rollback notes and docs). It is ⛔ never run automatically at boot. Unattended auto-migration (A) was rejected: it is the only route where the platform alters a customer's production table with nobody watching.
That command is #11733, and this card is blocked on it.
State
pm:dispatched→pm:blockedin one label write, assignee cleared,Blocked-by: #11733above for the reverse index. The card reopens for dispatch the moment #11733 lands.Generated by Claude Code
8 remaining items
os-warren commentedon Aug 25, 2026
Claim: PM loop round 2⚠️ A load-time or boot-time refusal would, and the remaining scope's wording ("refusal/warning") admits both readings — see the fence.
Session:
session_01W6HFzyH98W1YaQXhJUJt6oBranch:
claude/issue-11535-multi-value-column-signalWorktree:
objectstack-issue-11535Domain:
domain:engineFile surface:
packages/drivers/driver-sql/src/schema-drift.ts+ its test files (stop on breach; explain in the report)Container & model: M,
mode:subagent,model: opusClause-②: no — conditional on the fence below. A louder warning over an already-detected condition moves no accept door.
Serial constraints cleared: none.
schema-drift.ts(1762 lines) is touched by no open PR, and is a different file from the two cards dispatched alongside (sql-driver.ts#11782, the conformance test #11456).Unblocked, and re-measured rather than inherited
Blocked-by: #11733discharged — triage's unlock scan (5404296978) recorded PR #11894 landingos migrate multi-value-columnsas0e5bea6. Verified here that the detection half is genuinely onmain, ⛔ not assumed from the prior ACCEPT:Triage flagged this P0-suspect and explicitly did not stamp
priority:p0, leaving the engine seat to confirm:⇒ Re-measure the reproduction on current
origin/mainfirst. If it still reproduces end-to-end through the operator path, say so plainly in the report — that is an escalation trigger this seat will act on, ⛔ not a footnote. If #11720's detection plus #11733's migration command have together made it unreachable, that is an equally valuable answer andpremise_still_valid: falseis the right return.⛔ THE FENCE — boot-gating, and why it is not a style question
This card's own history contains the trap. The prior round hung its route on a premise that was measured false on the dangerous half (
5396424187):⇒ The landed finding deliberately uses
category: 'needs_confirm', precisely so it refuses no boot.So, binding on this dispatch:
categorytodestructive, and do not introduce any newdestructive-category finding on this path. That would stop an already-running production database from starting — an accept-set change of the most expensive kind, on the maintainer's floor.Clause-②: noabove stops being true, and it needs re-dispatch atCONTRACT_REVIEW_TIER. Say so rather than proceeding.What is actually left
Per triage's unlock note: the silent-corruption half — the loud signal when a single→multi field change meets an unmigrated column, now that
os migrate multi-value-columnsexists to point operators at. That command is the new fact this card was waiting for: the message can now name a remedy instead of describing a problem.origin/mainfirst —schema-drift.tstook #11720 today, and the card body's line numbers predate it. Re-locate by symbol.Generated by Claude Code
os-warren commentedon Aug 25, 2026
{ "issue": 11535, "status": "done", "branch": "claude/issue-11535-multi-value-column-signal", "pr": "https://github.com/objectstack-ai/objectstack/pull/12012", "premise_still_valid": true, "p0_confirmation": { "verdict": "STILL REPRODUCES — escalation trigger, not a footnote", "measured_at": "origin/main d63b014360, live Postgres 16.13 (server timezone Asia/Shanghai, process TZ=America/New_York)", "path": "operator path end to end: single-value table created and populated -> metadata gains multiple:true -> initObjects (additive-only, column never revisited) -> write", "observed": "column stays `character varying(255)` while a fresh DB of the same metadata gets `json`; the array reads back as the STRING '[\"x\",\"y\"]' (typeof === 'string', Array.isArray === false)", "what_changed_since_the_report": "the SILENCE is closed (#11720 reports it) and a remedy exists (#11733 `os migrate multi-value-columns`). The REACHABILITY is not closed and is now by ruling: #11700 route C settled that the platform warns and ships an operator-run migration rather than refusing the write or auto-altering the table. So there is no un-ruled work behind the P0 suspicion — the residual exposure is the maintainer's decided position, not a gap.", "mysql": "NOT measured here — mysqld is not installed in this container, so that live cell is a named skip. CI runs it." }, "summary": "The remaining half: the finding now NAMES `os migrate multi-value-columns` instead of describing a problem. Measured on origin/main, the line an operator actually meets opened with 'ObjectStack will NOT change this column for you. Migrate it by hand' and then printed raw SQL — a sentence that became FALSE when #11733 landed, and which sent operators to hand-write DDL on a production table while the command with a dry run, a prompt and a post-run re-detection sat one line away, unnamed. The message now leads with the command and keeps the hand-run statement after it; both operator surfaces pick it up unchanged because both print `message` verbatim (boot warning, and `os migrate plan` via renderPlan). Non-vacuity is pinned in both directions, including on a live database re-booted after the repair.", "fence_respected": { "category": "UNCHANGED at 'needs_confirm' — not raised to 'destructive'. No new destructive-category finding on this path.", "refusal": "NONE added. No load-time, no write-time, no boot-time refusal. The change is message text only.", "clause_2": "still `no`, and the condition it was conditional on holds. The diff moves no accept/reject set: the finding fires on exactly the same predicate as before (`declaresJsonColumn && multiValueColumnTypeIsLoadBearing(dialect) && acceptsStringifiedJson(col.type)`, untouched). The one new export is module-scoped and deliberately NOT added to the package's public index.ts, so the published surface is unchanged." }, "files_changed": [ "packages/drivers/driver-sql/src/schema-drift.ts", "packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts", ".changeset/drift-multi-value-column-names-remedy-command.md (new)" ], "gates": { "read_at_commit": "765f07814e", "working_tree_at_that_read": "clean (0 changes), asserted inside the same run", "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack — exit 0; --repo assertion checked against this checkout's origin and held; change set derived by the script itself from the merge base (3 paths), NOT from a hand-made git diff", "note_on_exit_capture": "every gate redirected to its own log FIRST and $? captured SECOND — no `cmd | tail; EXIT=$?` anywhere", "pnpm check:changeset-gate-self-tests": 0, "pnpm check:driver-conformance": 0, "pnpm check:objectui-changeset": 0, "pnpm check:published-files": 0, "pnpm check:slot-lookup": 0, "pnpm check:test-source-alias": 0, "pnpm check:type-source-resolution": 0, "node scripts/check-adr-0087-registration.mjs": 0, "node scripts/check-changeset-no-major.mjs": 0, "node scripts/check-empty-changeset.mjs": 0, "node scripts/check-plugin-teardown-shape.mjs": 0, "node scripts/docs-audit/check-affected-docs.mjs": 0, "node scripts/docs-audit/check-drift-comment.mjs": 0, "node scripts/pm/release-rehearsal-clone.mjs --self-test": 0, "pnpm check:query-options-erasure": 0, "pnpm check:type-check-coverage": 0, "pnpm check:engine-double-contract": 0, "pnpm check:cross-package-test-inputs": 0, "pnpm check:where-matcher": 0, "pnpm check:nul-bytes": 0, "pnpm lint (whole repo, eslint . --no-inline-config)": 0, "pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit)": 0, "NOT RUN (declared narrowing)": "pnpm check:type-check-debt --re-measure — needs the whole workspace closure built and re-runs tsc PER LEDGER ENTRY. Measured HERE rather than inherited from the prior round: driver-sql appears in neither the EXEMPT nor the TEST_DEBT block of scripts/check-type-check-coverage.mjs (both blocks brace-matched and grepped), so this change cannot move a ledgered count. The structural half passed, and `tsc --listFiles` shows BOTH changed files inside driver-sql's own program, which exits 0.", "verdict_lines_read": "gates quoted their own output, not a bare $? — e.g. 'check-driver-conformance: OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt.' and 'check-nul-bytes: OK (scanned 6662 text file(s) ... no raw ASCII control bytes).'" }, "tests": "Suite (live Postgres 16.13, TZ=America/New_York): schema-drift.base-type-mismatch.test.ts 24 passed | 2 skipped (was 18 passed before this change). Drift family, 6 files: 99 passed | 2 skipped. CROSS-PACKAGE: driver-sql REBUILT, then the CLI's three multi-value-columns suites run against that dist — 21 passed. That rebuild is load-bearing there (packages/cli imports @objectstack/driver-sql through its exports map into dist/) and is NOT load-bearing for driver-sql's own suite, which imports the subject by a relative specifier ('./schema-drift.js') so vitest resolves src/. ABLATION: mutated the ${MULTI_VALUE_COLUMN_REMEDY_COMMAND} interpolation out of the message (⚠️ mutating the CONSTANT would have been VOID — the suite imports it, so a mutated constant would be compared against a mutated message and pass). Mutation PROVEN ON DISK before any result was read, by anchored counts on the text I intended to change, not by the editor's exit code and not by a bare `git diff --stat`: removed-text 1 -> 0, injected marker 0 -> 1, with the script aborting (exit 90) if either count is wrong. Direction PREDICTED IN ADVANCE: 3 red — the two 'names the command' cases (unit + live boot) plus the live remedy case via its non-vacuity line — with the negative-direction case predicted to stay GREEN, since with nothing naming the command it still passes. OBSERVED: 'Tests 3 failed | 21 passed | 2 skipped', exactly those three. Restore under `trap ... EXIT INT TERM`: marker 0, anchor 1, diff against the pre-mutation copy byte-identical, git status clean. The ablation also PROVES the source-resolution claim rather than asserting it: source mutated with no rebuild and the suite went red; had it stayed green that would have been the stale-dist signature. Not a dist-leg ablation, so the strippable-marker caveat does not apply.", "non_vacuity": { "fires": "unit on both corrupting dialects, and on a real live-Postgres BOOT (captured through reconcileAndWarnDrift -> logger, not reconstructed): the line names the table, the physical type, the dry run and --apply, and carries the dialect statement intact. Also pinned: the command appears BEFORE the raw SQL.", "does_not_fire": "six shapes enumerated — migrated json column, single-value field, single-value NARROW width drift, single-value WIDEN width drift, SQLite, stale integer — asserting no message names the command and no manual_column_type_change op appears. Two of those rows DO produce a finding, asserted, so the loop reads real messages instead of passing over empty arrays. On a LIVE database: after the remedy runs, a restart stops recommending it.", "false_green_caught_while_writing_it": "the post-repair silence check was initially vacuous. `driftWarned` is a per-INSTANCE throttle keyed by driftKey(d), so re-initObjects-ing the SAME driver stays silent whether or not the drift cleared — it would have passed against nothing. It now boots a SECOND driver (what a restart actually is) and asserts the pre-migration boot did name it, so the silence belongs to the repair." }, "deviations": [ "REPORTING CHANNEL, declared: the role file's REST list endpoints are unreachable from this seat — api.github.com returns {\"message\":\"GitHub access is not enabled for this session...\"} for every call, and no `gh` CLI is installed. The issue body/comments, the dedupe search, the PR and this comment therefore went through the MCP GitHub tools, which is the only channel available rather than a preference. The local-grep half of the dedupe search was done normally.", "One new module-level export (MULTI_VALUE_COLUMN_REMEDY_COMMAND) in schema-drift.ts, so the package's own suites can pin the spelling. Deliberately NOT added to index.ts — the published surface is unchanged, and a published export is the step that would let the CLI import its own command id back out of the driver it boots.", "The 10-minute foreground cap (exit 143) killed one gate-union re-run mid-way under lock contention. Re-run to completion afterwards; the reported run is the complete one, and its own output asserts the commit and the clean tree it was read at. No reading from the killed run is reported." ], "open_questions": [ { "question": "Does #11535 close on this PR, or stay open? I used `Part of`, which is the conservative choice, but a case for `Fixes` is real and PM/maintainer should pick rather than inherit my caution. The card's own 'Expected' asked for EITHER an automatic column migration OR a loud failure naming the remedy; both halves now exist (operator-run migration in #11733, loud finding naming it here), so the reporter's ask is arguably satisfied in full. Against closing: the corruption is still REACHABLE — a single->multi change on a live database still writes a stringified array until the operator acts — and rows corrupted before that are out of scope by the #11700 ruling. Closing a user-filed silent-corruption report while the corruption can still occur is the kind of thing worth deciding deliberately.", "options": [ "A - keep `Part of`; PM/maintainer closes this card by hand after reading this, with a closing comment that states plainly what is and is not fixed for the reporter", "B - switch the PR's first line to the closing keyword form for this card, so the merge closes it: the remaining scope triage named is delivered, and the residual reachability is a RULED position rather than open work", "C - keep `Part of` and leave the card open indefinitely as a tracker for the residual reachability" ], "recommendation": "A. It gets the same end state as B without a merge silently closing a production corruption report, and it puts a human sentence in front of the reporter — who is a CONTRIBUTOR on this repo and has been following the card — saying which half is fixed and that their remaining exposure is a decision, not an oversight. C should be refused: there is no open work behind it, so the card would sit in the queue forever re-attracting dispatch, which is what the last two rounds already cost." }, { "question": "Should the P0 suspicion be resolved as NOT-P0 and the suspicion retired? Triage said 'escalate if it reproduces', and it reproduces — but the escalation's usual purpose (accelerate a fix) has no target left: the fix route was ruled on #11700 and both halves have landed. Stamping priority:p0 now would raise an alarm that points at no dispatchable work.", "options": [ "A - retire the P0 suspicion with a note recording that it DID reproduce and why it is nevertheless not P0 (the ruled position, not an unfixed defect)", "B - stamp priority:p0 anyway, so the reproduction is visible in the priority view", "C - leave the suspicion unresolved" ], "recommendation": "A. B creates an alarm with no work behind it, which is how a priority label stops meaning anything; C leaves the next triage round re-deriving the same measurement. A keeps the measurement on the record — which is the part that has lasting value — without pretending there is a queue item." } ], "out_of_scope_findings": [ "filed as #12016 [finding]: driver-sql hardcodes the `os migrate multi-value-columns` CLI command id in an operator-facing warning (third copy in content/docs/deployment/cli.mdx), and nothing mechanically ties the spellings together — a rename that updated packages/cli and the docs but not the driver leaves the warning pointing at a command that does not exist, and no suite reads that as wrong because driver-sql's pin asserts the message contains the constant, which would still match itself. Unassigned, `finding` label + no `pm:queue` per os-dev.md (observational missing-guard, not a concrete defect anyone has hit). Dedupe-searched (nearest hit #8364 is closed and about a different pin). Back-linked to #11535 and PR #12012." ] }Generated by Claude Code
os-warren commentedon Aug 25, 2026
os-dev-report
Supersedes comment 5405609870: its leading marker did not survive the body sanitizer (read back — the stored body begins with a blank line), which would have made the report invisible to a marker scan. Re-posted with the marker as literal text. Content is identical apart from one added
deviationsentry recording this.{ "issue": 11535, "status": "done", "branch": "claude/issue-11535-multi-value-column-signal", "pr": "https://github.com/objectstack-ai/objectstack/pull/12012", "premise_still_valid": true, "p0_confirmation": { "verdict": "STILL REPRODUCES — escalation trigger, not a footnote", "measured_at": "origin/main d63b014360, live Postgres 16.13 (server timezone Asia/Shanghai, process TZ=America/New_York)", "path": "operator path end to end: single-value table created and populated -> metadata gains multiple:true -> initObjects (additive-only, column never revisited) -> write", "observed": "column stays `character varying(255)` while a fresh DB of the same metadata gets `json`; the array reads back as the STRING '[\"x\",\"y\"]' (typeof === 'string', Array.isArray === false)", "what_changed_since_the_report": "the SILENCE is closed (#11720 reports it) and a remedy exists (#11733 `os migrate multi-value-columns`). The REACHABILITY is not closed and is now by ruling: #11700 route C settled that the platform warns and ships an operator-run migration rather than refusing the write or auto-altering the table. So there is no un-ruled work behind the P0 suspicion — the residual exposure is the maintainer's decided position, not a gap.", "mysql": "NOT measured here — mysqld is not installed in this container, so that live cell is a named skip. CI runs it." }, "summary": "The remaining half: the finding now NAMES `os migrate multi-value-columns` instead of describing a problem. Measured on origin/main, the line an operator actually meets opened with 'ObjectStack will NOT change this column for you. Migrate it by hand' and then printed raw SQL — a sentence that became FALSE when #11733 landed, and which sent operators to hand-write DDL on a production table while the command with a dry run, a prompt and a post-run re-detection sat one line away, unnamed. The message now leads with the command and keeps the hand-run statement after it; both operator surfaces pick it up unchanged because both print `message` verbatim (boot warning, and `os migrate plan` via renderPlan). Non-vacuity is pinned in both directions, including on a live database re-booted after the repair.", "fence_respected": { "category": "UNCHANGED at 'needs_confirm' — not raised to 'destructive'. No new destructive-category finding on this path.", "refusal": "NONE added. No load-time, no write-time, no boot-time refusal. The change is message text only.", "clause_2": "still `no`, and the condition it was conditional on holds. The diff moves no accept/reject set: the finding fires on exactly the same predicate as before (`declaresJsonColumn && multiValueColumnTypeIsLoadBearing(dialect) && acceptsStringifiedJson(col.type)`, untouched). The one new export is module-scoped and deliberately NOT added to the package's public index.ts, so the published surface is unchanged." }, "files_changed": [ "packages/drivers/driver-sql/src/schema-drift.ts", "packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts", ".changeset/drift-multi-value-column-names-remedy-command.md (new)" ], "gates": { "read_at_commit": "765f07814e", "working_tree_at_that_read": "clean (0 changes), asserted inside the same run", "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack — exit 0; --repo assertion checked against this checkout's origin and held; change set derived by the script itself from the merge base (3 paths), NOT from a hand-made git diff", "note_on_exit_capture": "every gate redirected to its own log FIRST and $? captured SECOND — no `cmd | tail; EXIT=$?` anywhere", "pnpm check:changeset-gate-self-tests": 0, "pnpm check:driver-conformance": 0, "pnpm check:objectui-changeset": 0, "pnpm check:published-files": 0, "pnpm check:slot-lookup": 0, "pnpm check:test-source-alias": 0, "pnpm check:type-source-resolution": 0, "node scripts/check-adr-0087-registration.mjs": 0, "node scripts/check-changeset-no-major.mjs": 0, "node scripts/check-empty-changeset.mjs": 0, "node scripts/check-plugin-teardown-shape.mjs": 0, "node scripts/docs-audit/check-affected-docs.mjs": 0, "node scripts/docs-audit/check-drift-comment.mjs": 0, "node scripts/pm/release-rehearsal-clone.mjs --self-test": 0, "pnpm check:query-options-erasure": 0, "pnpm check:type-check-coverage": 0, "pnpm check:engine-double-contract": 0, "pnpm check:cross-package-test-inputs": 0, "pnpm check:where-matcher": 0, "pnpm check:nul-bytes": 0, "pnpm lint (whole repo, eslint . --no-inline-config)": 0, "pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit)": 0, "NOT RUN (declared narrowing)": "pnpm check:type-check-debt --re-measure — needs the whole workspace closure built and re-runs tsc PER LEDGER ENTRY. Measured HERE rather than inherited from the prior round: driver-sql appears in neither the EXEMPT nor the TEST_DEBT block of scripts/check-type-check-coverage.mjs (both blocks brace-matched and grepped), so this change cannot move a ledgered count. The structural half passed, and `tsc --listFiles` shows BOTH changed files inside driver-sql's own program, which exits 0.", "verdict_lines_read": "gates quoted their own output, not a bare $? — e.g. 'check-driver-conformance: OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt.' and 'check-nul-bytes: OK (scanned 6662 text file(s) ... no raw ASCII control bytes).'" }, "tests": "Suite (live Postgres 16.13, TZ=America/New_York): schema-drift.base-type-mismatch.test.ts 24 passed | 2 skipped (was 18 passed before this change). Drift family, 6 files: 99 passed | 2 skipped. CROSS-PACKAGE: driver-sql REBUILT, then the CLI's three multi-value-columns suites run against that dist — 21 passed. That rebuild is load-bearing there (packages/cli imports @objectstack/driver-sql through its exports map into dist/) and is NOT load-bearing for driver-sql's own suite, which imports the subject by a relative specifier ('./schema-drift.js') so vitest resolves src/. ABLATION: mutated the ${MULTI_VALUE_COLUMN_REMEDY_COMMAND} interpolation out of the message (mutating the CONSTANT would have been VOID — the suite imports it, so a mutated constant would be compared against a mutated message and pass). Mutation PROVEN ON DISK before any result was read, by anchored counts on the text I intended to change, not by the editor's exit code and not by a bare `git diff --stat`: removed-text 1 -> 0, injected marker 0 -> 1, with the script aborting (exit 90) if either count is wrong. Direction PREDICTED IN ADVANCE: 3 red — the two 'names the command' cases (unit + live boot) plus the live remedy case via its non-vacuity line — with the negative-direction case predicted to stay GREEN, since with nothing naming the command it still passes. OBSERVED: 'Tests 3 failed | 21 passed | 2 skipped', exactly those three. Restore under `trap ... EXIT INT TERM`: marker 0, anchor 1, diff against the pre-mutation copy byte-identical, git status clean. The ablation also PROVES the source-resolution claim rather than asserting it: source mutated with no rebuild and the suite went red; had it stayed green that would have been the stale-dist signature. Not a dist-leg ablation, so the strippable-marker caveat does not apply.", "non_vacuity": { "fires": "unit on both corrupting dialects, and on a real live-Postgres BOOT (captured through reconcileAndWarnDrift -> logger, not reconstructed): the line names the table, the physical type, the dry run and --apply, and carries the dialect statement intact. Also pinned: the command appears BEFORE the raw SQL.", "does_not_fire": "six shapes enumerated — migrated json column, single-value field, single-value NARROW width drift, single-value WIDEN width drift, SQLite, stale integer — asserting no message names the command and no manual_column_type_change op appears. Two of those rows DO produce a finding, asserted, so the loop reads real messages instead of passing over empty arrays. On a LIVE database: after the remedy runs, a restart stops recommending it.", "false_green_caught_while_writing_it": "the post-repair silence check was initially vacuous. `driftWarned` is a per-INSTANCE throttle keyed by driftKey(d), so re-initObjects-ing the SAME driver stays silent whether or not the drift cleared — it would have passed against nothing. It now boots a SECOND driver (what a restart actually is) and asserts the pre-migration boot did name it, so the silence belongs to the repair." }, "deviations": [ "REPORTING CHANNEL, declared: the role file's REST list endpoints are unreachable from this seat — api.github.com returns {\"message\":\"GitHub access is not enabled for this session...\"} for every call, and no `gh` CLI is installed. The issue body/comments, the dedupe search, the PR and this comment therefore went through the MCP GitHub tools, which is the only channel available rather than a preference. The local-grep half of the dedupe search was done normally.", "One new module-level export (MULTI_VALUE_COLUMN_REMEDY_COMMAND) in schema-drift.ts, so the package's own suites can pin the spelling. Deliberately NOT added to index.ts — the published surface is unchanged, and a published export is the step that would let the CLI import its own command id back out of the driver it boots.", "The 10-minute foreground cap (exit 143) killed one gate-union re-run mid-way under lock contention. Re-run to completion afterwards; the reported run is the complete one, and its own output asserts the commit and the clean tree it was read at. No reading from the killed run is reported.", "REPORTING, measured not assumed: the first copy of this report (comment 5405609870) lost its HTML-comment marker to the body sanitizer — read back, the stored body begins with a blank line, so a marker scan would not see it. Same failure the prior round hit on this card. This comment is the AUTHORITATIVE copy and spells the marker as literal text." ], "open_questions": [ { "question": "Does #11535 close on this PR, or stay open? I used `Part of`, which is the conservative choice, but a case for the closing form is real and PM/maintainer should pick rather than inherit my caution. The card's own 'Expected' asked for EITHER an automatic column migration OR a loud failure naming the remedy; both halves now exist (operator-run migration in #11733, loud finding naming it here), so the reporter's ask is arguably satisfied in full. Against closing: the corruption is still REACHABLE — a single->multi change on a live database still writes a stringified array until the operator acts — and rows corrupted before that are out of scope by the #11700 ruling. Closing a user-filed silent-corruption report while the corruption can still occur is the kind of thing worth deciding deliberately.", "options": [ "A - keep `Part of`; PM/maintainer closes this card by hand after reading this, with a closing comment that states plainly what is and is not fixed for the reporter", "B - switch the PR's first line to the closing keyword form for this card, so the merge closes it: the remaining scope triage named is delivered, and the residual reachability is a RULED position rather than open work", "C - keep `Part of` and leave the card open indefinitely as a tracker for the residual reachability" ], "recommendation": "A. It gets the same end state as B without a merge silently closing a production corruption report, and it puts a human sentence in front of the reporter — who is a CONTRIBUTOR on this repo and has been following the card — saying which half is fixed and that their remaining exposure is a decision, not an oversight. C should be refused: there is no open work behind it, so the card would sit in the queue forever re-attracting dispatch, which is what the last two rounds already cost." }, { "question": "Should the P0 suspicion be resolved as NOT-P0 and the suspicion retired? Triage said 'escalate if it reproduces', and it reproduces — but the escalation's usual purpose (accelerate a fix) has no target left: the fix route was ruled on #11700 and both halves have landed. Stamping priority:p0 now would raise an alarm that points at no dispatchable work.", "options": [ "A - retire the P0 suspicion with a note recording that it DID reproduce and why it is nevertheless not P0 (the ruled position, not an unfixed defect)", "B - stamp priority:p0 anyway, so the reproduction is visible in the priority view", "C - leave the suspicion unresolved" ], "recommendation": "A. B creates an alarm with no work behind it, which is how a priority label stops meaning anything; C leaves the next triage round re-deriving the same measurement. A keeps the measurement on the record — which is the part that has lasting value — without pretending there is a queue item." } ], "out_of_scope_findings": [ "filed as #12016 [finding]: driver-sql hardcodes the `os migrate multi-value-columns` CLI command id in an operator-facing warning (third copy in content/docs/deployment/cli.mdx), and nothing mechanically ties the spellings together — a rename that updated packages/cli and the docs but not the driver leaves the warning pointing at a command that does not exist, and no suite reads that as wrong because driver-sql's pin asserts the message contains the constant, which would still match itself. Unassigned, `finding` label + no `pm:queue` per os-dev.md (observational missing-guard, not a concrete defect anyone has hit). Dedupe-searched (nearest hit #8364 is closed and about a different pin). Back-linked to #11535 and PR #12012." ] }Generated by Claude Code
os-warren commentedon Aug 25, 2026
ACCEPT — PR #12012, and both open questions ruled
Reviewer of record:
domain:engineseat,os-warren, sessionsession_01W6HFzyH98W1YaQXhJUJt6o. Checked against the diff, ⛔ not the report.1.⚠️ The P0 confirmation — it REPRODUCES, and that is the headline
Triage asked this seat to confirm reachability and escalate if it reproduces. It was measured end-to-end on the operator path, live Postgres 16.13 at
d63b014360:⛔ So the silent corruption is still reachable today. That fact is recorded here in full, and it is in this round's report to the maintainer rather than buried in a card.
Ruling on Q2 — retire the P0 suspicion, A
Not because it failed to reproduce — it did — but because the escalation has no target left. A
priority:p0stamp is a dispatch-ordering signal, and there is no dispatchable work behind this one:⇒ Stamping
p0would raise an alarm pointing at nothing, which is how a priority label stops meaning anything. Option B refused for that reason; option C refused because it leaves the next triage round re-deriving the same measurement. The measurement is the durable artefact, and it is on the record above.Ruling on Q1 — keep
Part of, this seat closes the card by hand, AAgreed, and the dev's argument against C is exactly right and worth quoting because it names a cost this lane has already paid twice:
That is literally true — this card was blocked one round and dispatched the next. ⛔ A tracker with no work behind it is not free.
B (switch to a closing keyword) reaches the same end state, but I am refusing it for the reason the dev gave: a merge should not silently close a user-filed silent-corruption report while the corruption remains reachable. The reporter is a contributor on this repo and has been following the card. They are owed a sentence written by a person, not an auto-close.
⇒ I close it by hand when #12012 lands, with a closing comment stating plainly which half is fixed and that the residual exposure is a decision, not an oversight.
2. The review
⭐ Both fences held, and the diff proves it rather than asserting it
categorytodestructiveseverity: 'error',category: 'needs_confirm'. The boot gate reads category, so an already-serving database still starts.Clause-②: nohelddeclaresJsonColumn && multiValueColumnTypeIsLoadBearing && acceptsStringifiedJson, untouched), and the new export is module-scoped and ⛔ deliberately not added toindex.ts— published surface unchanged.driver-sql+ one changeset. No governed path, nopackages/spec/src/**.The actual defect this fixed is better than the card described
The message opened with "ObjectStack will NOT change this column for you. Migrate it by hand" — a sentence that was true when written and became false the moment #11733 landed. Operators were being sent to hand-write DDL on a production table while the command with a dry run, a prompt and a post-run re-detection sat one line away, unnamed.
⭐ The regression guard for that specific false sentence is kept, with the right justification: the failure it describes is invisible — the message would still be loud, still name the right column, still print working SQL, while telling the operator that the command two lines below does not exist.
⭐ A cross-package contract pinned from the emitting side
planStaleColumnTargetsinpackages/clirecovers the dialect by testing which dialect's statement the messageincludes— aManagedDriftEntrycarries no dialect. So a reword that paraphrases or line-wraps the SQL makes every findingremedy_not_recognized: the command this message now points at would refuse to run, and nothing in driver-sql's own suite would have noticed. The suite now reproduces that probe rather than describing it.⭐ The false green the dev caught while writing it
The post-repair silence check was initially vacuous:
driftWarnedis a per-instance throttle keyed bydriftKey(d), so re-initObjects-ing the same driver stays silent whether or not the drift cleared. It now boots a second driver — which is what a restart actually is — and asserts the pre-migration boot did name it, so the silence belongs to the repair. ⇒ Catching your own vacuous assertion mid-write is worth more than the test it replaced.Non-vacuity: six must-not-fire shapes enumerated, and ⭐ two of them deliberately DO produce a finding, so the loop reads real messages instead of passing over empty arrays. Ablation mutated the interpolation, not the constant — mutating the constant would have been VOID, since the suite imports it and would have compared a mutated message to a mutated expectation. Direction predicted (3 red, named), observed exactly.
CI: 34 checks, all green. Path face clean ⇒ releasing landing.
Noted, not owed
mysqldis not installed in that container, so the MySQL live cell was a named skip, ⛔ not a silent one — CI runs it. And #12016 was filed for the hardcoded command id having three copies with nothing tying them together; it neighbours #12017, which this seat filed for the same class one layer over.Generated by Claude Code
os-warren commentedon Aug 25, 2026
Closing — and what that does and does not mean for you
PR #12012 landed. Verified by content on
origin/main, ⛔ not by the API'smergedfield:MULTI_VALUE_COLUMN_REMEDY_COMMAND→ 2 inschema-drift.ts(controlmanual_column_type_change→ 2), andmain's tip is the squash commitc804f0ca5f.Closed by hand rather than by a closing keyword, deliberately — ⛔ a merge should not silently close a silent-corruption report. @os-sam, this paragraph is the point of that choice.
What is fixed
Your report was that the corruption was silent. It is not silent any more, and there is now a way out that is not hand-written DDL:
varchar/textcolumn is named on every boot and byos migrate plan, with the table, the column, the declared type and the physical type.os migrate multi-value-columns(An operator-run command to migrate a stale column to json when a field went multi-value (ruled C on #11700) #11733) — a dry run by default, a confirmation prompt, and a post-run re-detection that exits non-zero if the finding has not cleared.What is NOT fixed, stated plainly
multiple: true, the column stayscharacter varying(255)and the next write stores the literal'["x","y"]', read back as a string. Nothing changes that until an operator runs the command.That is a decision, not an oversight — and it is the maintainer's, recorded on #11700 (2026-08-24, route C): the platform warns and ships an explicit operator-run migration, and never alters a customer's production table unattended. Auto-migration was considered and rejected on exactly that ground.
Why the card closes rather than staying open as a tracker
There is no open work behind it. Both halves triage named have landed (detection #11720, remedy command #11733, and this PR making the message name it). A card with nothing dispatchable in it sits in the queue re-attracting dispatch — which is what the last two rounds of this lane actually cost.
⇒ If the residual reachability should be revisited, that is a new decision card against the #11700 ruling, not a continuation of this one. ⛔ Reopening is free, and this comment is the evidence anyone would need to argue for it.
The P0 suspicion, resolved
Triage flagged this P0-suspect and asked the engine seat to escalate if it reproduces. It reproduces, and the measurement is on the record above.
priority:p0is deliberately not stamped: it is a dispatch-ordering signal, and there is no dispatchable work behind it — the route is ruled and both halves shipped. ⛔ An alarm with no work behind it is how a priority label stops meaning anything. Full reasoning at5405686794.Generated by Claude Code
test(cli): pin the boot consequence of a report-only drift entry thro…
feat(cli): os migrate multi-value-columns — the operator-run stale-co…
test(cli): pin the boot consequence of a report-only drift entry thro…
fix(spec)!: refuse `multiple: true` outside the multi-capable types, …
fix(driver-sql): refusals, drift reports and log lines state each dec…
docs(driver-turso): re-anchor the dead tracker citations to the commi…
fix(spec): conformance-case notes and names state each decision in wo…