Skip to content

[finding] An UNDECLARED signature/qrcode column on a pre-existing table stays varchar(255) and no drift op reports it — the #11875 repair reaches new tables only #12121

Description

@os-warren

Filed by the domain:engine PM seat while reviewing PR #12119 (#11875). ⛔ Not a defect in that PR, and not a reason to hold it — #12119 strictly improves the situation and declares this boundary honestly in its own changeset. Recorded so the remaining half is not rediscovered as a surprise.

What #11875 fixes, and where it stops

After #12119, signature / qrcode are in the TEXT family, so a newly created column holds a data URI correctly. The changeset states the boundary in as many words:

Nothing about existing tables changes — createColumn runs on CREATE TABLE and ALTER TABLE ADD COLUMN, so the column it sizes is always empty; a pre-existing signature / qrcode column stays varchar(255) until an operator migrates it, and the additive sync never rewrites a column's type on its own.

That is correct and is standard additive-sync behaviour. The part not stated is what the drift reporter does about it.

The measurement

packages/drivers/driver-sql/src/schema-drift.ts, the varchar differ. declaredMaxLength !== undefined is a required condition of the whole branch:

const declaredMaxLength =
  typeof field.maxLength === 'number' && Number.isInteger(field.maxLength) && field.maxLength > 0
    ? field.maxLength
    : undefined;
if (
  enforcesVarcharLength(dialect) &&
  !declaresJsonColumn &&
  declaredMaxLength !== undefined &&        // <-- the gate
  isCharacterColumn(col.type) &&
  typeof col.maxLength === 'number' &&
  declaredMaxLength !== col.maxLength
) { … widen_varchar / narrow_varchar … }

⇒ Split by whether the author declared a bound, on a pre-existing table:

declaration physical column drift says outcome
Field.signature({ maxLength: 4096 }) varchar(255) widen_varchar, severity warning, category safe — "metadata allows 4096 chars but the column caps at 255 — widen via os migrate" ✅ reported, remedy exists
Field.signature() — no bound varchar(255) nothing — no number on the declared side to compare ⛔ silent

The second row is the common case (the card's own motivating example is field-zoo writing a data-URI PNG into a plain signature), and it is the same declared ≠ enforced shape the whole #11374 / #11431 / #11875 family exists to close — arriving one door further along, at the migration seam rather than the authoring or write seam.

Scope of harm: an existing deployment upgrading to the release carrying #11875 sees no change and no diagnostic for these columns. A 1000-character data URI is still refused by the server (22001 / ER_DATA_TOO_LONG) exactly as the card measured, and nothing anywhere says why the fix did not reach them.

Not claimed

Refs

#11875 (the ruling) · PR #12119 · #11794 (the invariant) · #11374 / #11431 (the family) · #11722 (a neighbouring applyMigrationEntries reporting gap, currently in flight)

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 25, 2026
  2. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 27, 2026
  3. self-assigned this
    on Aug 27, 2026
  4. os-zhuang commented on Aug 27, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 2
    Session: session_01LZbWd2jNV1FErXTPSS4Dry
    Branch: claude/issue-12121-textfamily-drift-visibility
    Worktree: objectstack-issue-12121
    Domain: domain:engine
    File surface: packages/drivers/driver-sql/src/schema-drift.ts and its tests. ⛔ packages/spec READ-ONLY (spec seat); ⛔ content/docs/releases/** is release-owned and off-limits to a code PR (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — a drift report is a diagnostic, not an accept/reject door. ⚠️ If the chosen route changes what the platform accepts, or emits a migration that rewrites a column type, that is a different card — stop and report.
    Serial constraints cleared: ⛔ This card does NOT take the sql-driver.ts serial slot — it lands in schema-drift.ts, a distinct file (verified by filename). The sql-driver.ts slot was freed when PR #12714 (#12017) merged as d29e42f8, and queued sibling #12593 is the next claimant there — ⛔ not dispatched, so no collision either way. Neighbouring card #11722 (an applyMigrationEntries reporting gap) is pm:on-hold, not in flight. No open PR touches schema-drift.ts.

    The defect — measured on origin/main at claim time, controls firing

    The varchar differ's whole branch is gated on the author having declared a bound. Live at packages/drivers/driver-sql/src/schema-drift.ts:833 (⚠️ locate by symbol declaredMaxLength, ⛔ never by that line number):

    declaredMaxLength !== undefined &&   // <-- the gate
    

    Controls: widen_varchar / narrow_varchar → 6 hits in that file; enforcesVarcharLength → 3; the file is present. The probe addresses real code.

    ⇒ On a pre-existing table, split by whether a bound was declared:

    declaration physical column drift says
    Field.signature({ maxLength: 4096 }) varchar(255) ✅ widen_varchar, with a remedy
    Field.signature() — no bound varchar(255) ⛔ nothing — no number on the declared side to compare

    The second row is the common case — the card's own motivating example is a data-URI PNG written into a plain signature. After #11875/#12119 a new column is TEXT and correct; an existing deployment upgrading to that release sees no change and no diagnostic, and the server still refuses at 22001 / ER_DATA_TOO_LONG with nothing anywhere saying why the fix did not reach it.

    ⇒ Same declared ≠ enforced shape as the #11374 / #11431 / #11875 family, arriving one door further along — at the migration seam rather than the authoring or write seam.

    Section — TRIAGE'S ROUTE (⛔ measure-first; this is a route, not a ruling)

    Inventory before remedy. First enumerate how a deployment would even learn it has pre-existing varchar(255) signature/qrcode columns. Then choose.

    ⛔ What the card explicitly does NOT claim — do not manufacture these

    • ⛔ No claim that feat(data): signature and qrcode join the bounded-string family end to end #12119 is wrong or incomplete. It delivered all three seams the maintainer ruled, and declared this boundary honestly in its own changeset. ⛔ This is not a reason to revisit it.
    • ⛔ No frequency measured. How many deployed tables actually carry such a column is unknown — which is precisely why triage put inventory first. ⛔ Do not assert a blast radius you have not measured.
    • ⛔ No opinion on the remedy is on the card. The route above is triage's preference; a better one measured on the tree wins.

    ⛔ Confirm or refute every reading above on the current ref before writing code. premise_still_valid: false with no PR is a legitimate delivery.

    What "done" must show

    A drift op that reports nothing is indistinguishable from no drift. Reproduce the silence first — a pre-existing varchar(255) column with an undeclared signature/qrcode field producing zero drift ops — then show the same case reported. Name a positive control proving the differ can emit an op at all in that same run (the declared-bound row above is the obvious one).


    Generated by Claude Code

  5. os-zhuang commented on Aug 27, 2026

    @os-zhuang
    Contributor

    Dev claim (subagent dispatched from the PM claim in comment 5440308890).

    Session: session_01LZbWd2jNV1FErXTPSS4Dry (same session as the dispatching PM seat — I am the os-dev subagent it launched, not a second independent claimant)
    Branch: claude/issue-12121-textfamily-drift-visibility (pushed empty at claim time as the write-route probe; push returned 0)
    Worktree: /home/user/objectstack-issue-12121
    Base: d29e42f8b1b44b92ea58c1d05e619f27c027c3a9

    Assignee untouched (os-zhuang, set by PM).

    Route acknowledged as a route, not a ruling: inventory first, then choose between the TEXT-family-membership drift op and a report on the manual os migrate step. Fences acknowledged: packages/spec READ-ONLY, content/docs/releases/** off-limits entirely, sql-driver.ts not mine (queued card #12593 owns it), #11722 not addressed here and remains open.

    Deliverable shape: reproduce the silence (undeclared signature/qrcode on a pre-existing varchar(255) column producing zero drift ops) with a positive control in the same run, then the same case reported; at least one ablation with direction and exact count predicted in writing before mutation.


    Generated by Claude Code


    Generated by Claude Code

  6. os-zhuang commented on Aug 27, 2026

    @os-zhuang
    Contributor

    os-dev-report

    {
      "issue": 12121,
      "status": "done",
      "branch": "claude/issue-12121-textfamily-drift-visibility",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12733",
      "head_sha": "1a30fa37fdf9bcc7444690f748a60326eb4920e8",
      "premise_still_valid": true,
      "premise_evidence": "Re-verified on origin/main at d29e42f8 before writing code. The gate `declaredMaxLength !== undefined` is live in diffManagedTable; controls fired (widen_varchar/narrow_varchar present, enforcesVarcharLength present). Direct diffManagedTable calls confirmed the split exactly as the card states.",
      "clause2": "holds — no accept/reject door moved, and no migration that rewrites a column type was emitted. The new op is REPORT-ONLY with no reconciler arm (applyDriftOpInPlace matches no case, so os migrate apply reports it skipped). category 'needs_confirm' keeps the artifact boot gate verdict unchanged (it refuses only 'destructive') and keeps dev auto-reconcile away (it applies only 'safe'). Nothing the platform previously accepted is now rejected.",
      "route_taken": "in-code fix — a drift op keyed on TEXT-family membership rather than on a declared number",
      "route_measurement": "The inventory chose it, not preference. All five drift-reporting roads funnel through ONE function: boot-time [schema-drift] warn and os migrate plan / apply / the artifact-pinned boot gate / os migrate multi-value-columns all reach diffManagedTable via SqlDriver.detectManagedDrift. Measured silence on the pre-fix tree: 8 text-family types with no maxLength over varchar(255) each returned n=0 on postgres, signature also n=0 on mysql. So repairing the one function repairs every road at once, while the documentation candidate cannot tell a specific deployment whether IT has such a column — which is the actionability the card asked the inventory to establish.",
      "inventory_found": "Exactly one channel exists today by which a deployment learns, and it is a production write failure. Its message is a poor substitute for a report: the live probe behind objectql/src/driver-fault-redaction.ts measured pg 22001 as 'value too long for type character varying(20)' — identifier only, naming the TYPE not the column — and mysql 1406 as 'Data too long for column label at row 1'. No drift road, no CLI command, no boot diagnostic named the field, the cause or a remedy. Frequency remains UNMEASURED and is not claimed anywhere.",
      "summary": "Added a report-only drift op `manual_widen_varchar_to_text` in packages/drivers/driver-sql/src/schema-drift.ts, fired on the exact complement of the existing varchar branch (declaredMaxLength === undefined) for a TEXT-family field over a pre-existing character column on an enforcing dialect. Needs no keyed-column input and therefore no sql-driver.ts change: createColumn sizes a text-family column as `keyed ? keyableTextLength(field) : null` and keyableTextLength returns null without a positive-integer bound, so the emitter answers TEXT either way — pinned as such. A distinct op rather than reusing manual_column_type_change because os migrate multi-value-columns selects its ENTIRE population by that op type and would refuse these as remedy_not_recognized while offering a json conversion. The hand-written UNBOUNDED_TEXT_FIELD_TYPES set is held equal in both directions to the driver's own dispatch over every spec FieldType, probed rather than restated.",
      "tests": "All at head 1a30fa37 (the final commit). (1) BEFORE/AFTER measurement, same diffManagedTable calls: 8 text-family types with no bound over varchar(255) on postgres went n=0 -> n=1 manual_widen_varchar_to_text error/needs_confirm expected 'text' actual 'varchar(255)'; signature on mysql n=0 -> n=1; sqlite stayed n=0. POSITIVE CONTROL in the same run: signature maxLength 4096 stayed n=1 widen_varchar warning/safe on both dialects. NEGATIVE CONTROLS unchanged: real TEXT column n=0, mysql TEXT(65535) n=0, string/email/url/phone/password unbounded n=0, multiple:true still exactly the #11535 manual_column_type_change. (2) typecheck: `pnpm --filter @objectstack/driver-sql exec tsc --noEmit --listFiles` -> VERDICT command-exit 0, and --listFiles names BOTH edited files (1 hit each), so the green covers the new test rather than a program that never read it. (3) full package suite: `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2` -> 'Test Files 141 passed | 8 skipped (149)', 'Tests 2179 passed | 129 skipped (2308)', VERDICT command-exit 0; the 8 skips are live-dialect cells needing real PG/MySQL. Everything ran under scripts/pm/os-verify-lock.sh.",
      "ablations": [
        {
          "name": "A — remove 'signature', 'qrcode' from UNBOUNDED_TEXT_FIELD_TYPES",
          "predicted_before_mutation": "RED, exactly 1 of 5 tests fails (the set pin); 4 pass, because the emission loop shrinks with the set — which is precisely why the pin exists",
          "observed": "1 failed | 4 passed (5); failing test named: 'holds the set equal to the driver text-family branch over every FieldType'",
          "mutation_confirmed_on_disk": "deleted-text grep count 1 -> 0, run refused outright if the count had not moved",
          "rebuild": "none needed and MEASURED not assumed — the suite imports ./schema-drift.js in-package and packages/drivers/driver-sql/dist contains ZERO mentions of manual_widen_varchar_to_text while assertions on it pass, so vitest reads source",
          "restore_proof": "git checkout HEAD -- <abs path> under trap EXIT INT TERM with absolute paths; blob 3c521e01d1fcbaffec0ccc2ebf1e3c8b6c34fc0d == HEAD:packages/drivers/driver-sql/src/schema-drift.ts and git diff HEAD empty"
        },
        {
          "name": "B — short-circuit the new branch with 'false &&'",
          "predicted_before_mutation": "RED, exactly 2 of 5 tests fail (the emission test, and the couplings test which destructures undefined); 3 pass as control",
          "observed": "2 failed | 3 passed (5); failing tests named: 'reports every member exactly once, with the declared-bound row as the control in the same run' and 'cannot refuse a boot, and cannot be claimed by os migrate multi-value-columns'",
          "mutation_confirmed_on_disk": "injected-text grep count 0 -> 1, run refused outright if the count had not moved",
          "rebuild": "same as A — source-resolved, no dist involved",
          "restore_proof": "same blob-hash equality and empty git diff HEAD; final control run on the restored tree: 5 passed (5)"
        }
      ],
      "gates": {
        "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-run AFTER the changeset existed (it added 8 changeset-triggered families the first derivation could not name)",
        "green": 26,
        "red": 0,
        "not_measured": 2,
        "exit_capture": "each gate run into its own log with $? captured immediately, before any pipe",
        "green_list": "check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:driver-conformance, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs, check-ci-filter-parity.mjs, check-comment-mask-adoption.mjs, check-cross-package-test-inputs.mjs, check-plugin-teardown-shape.mjs, docs-audit/check-affected-docs.mjs, docs-audit/check-drift-comment.mjs, pm/release-rehearsal-clone.mjs --self-test",
        "not_measured_detail": [
          "node scripts/pm/check-half-states.mjs exit 3 — its own printed verdict: 'PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential ... Nothing was swept ... it is no reading at all'. Exit 3 is the gate's own prerequisite code, not a failure. Its self-test half (pnpm check:pm-half-states) ran green.",
          "pnpm check:type-check-debt exit 1 — self-test half green ('47 semantic case(s) + 65 observation case(s) ... hold') and check-type-check-coverage printed 'OK'; only --re-measure refused: 'cannot run: 50 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk ... Build the closure first'. Exactly the refusal dispatch-gates warned of in advance."
        ]
      },
      "deviations": [
        "DECLARED NARROWING on check:type-check-debt's --re-measure half: not run, because it requires a full workspace build (turbo run build over all packages) that CI performs regardless. The narrowing is PROVEN, not assumed, on three readings taken from the gate's own configuration: (1) population — @objectstack/driver-sql appears in NONE of the gate's DEBT / TEST_DEBT / EXEMPT ledger literals in scripts/check-type-check-coverage.mjs, and --re-measure re-measures only ledgered packages; (2) count — the structural half check:type-check-coverage ran and printed 'OK — 65/78 workspace packages type-checked ... 13 in the DEBT ledger ... 1 exempt', exit 0; (3) invariance — driver-sql's tsconfig.json is include ['src/**/*'] with no test exclusion, so its tests sit inside the very tsc program whose green --listFiles run above named both edited files, and this diff touches no other package.",
        "No repo-wide pnpm lint sweep run locally — CI owns the farm run; the affected package's own typecheck and full suite were run instead.",
        "Commit messages carry no Co-Authored-By / Claude-Session trailers (an early-commit oversight). NOT repaired, because amending pushed commits would require a force-push, which is forbidden. The branch squashes on merge and the PR body carries the session URL footer."
      ],
      "files_changed": [
        "packages/drivers/driver-sql/src/schema-drift.ts",
        "packages/drivers/driver-sql/src/schema-drift.unbounded-text-column.test.ts",
        ".changeset/drift-unbounded-text-column-over-stale-varchar.md"
      ],
      "changeset_grade": "minor on @objectstack/driver-sql — argued, not defaulted. Matches the directly analogous drift-op addition .changeset/drift-detect-multi-value-base-type-mismatch.md (#11535), which shipped minor. Two counts: detectManagedDrift emits a finding on existing deployments where it emitted none, and the exported DriftOp union gains a member (additive for producers, a widening for any consumer switching exhaustively). Not major — nothing removed, renamed or newly rejected.",
      "fences_respected": {
        "packages/spec": "untouched (read only, for BOUNDED_STRING_FIELD_TYPES and the maxLength schema)",
        "content/docs/releases/**": "untouched entirely",
        "sql-driver.ts": "untouched — read only. The branch was deliberately designed to need nothing from it, which is what freed card #12593's serial slot from any collision.",
        "#11722": "not touched, remains open; stated as out of scope in the PR body",
        "#12119": "not revisited, not blamed — the PR body states it delivered its ruling and declared this boundary honestly",
        "enqueue/auto-merge": "neither — PR opened as draft, landing left to PM"
      },
      "mcp_calls": "9 — issue_read get, issue_read get_comments, add_issue_comment (claim), search_issues (one targeted duplicate check), issue_write (file #12732), create_pull_request, pull_request_read (body read-back), add_issue_comment (this report), issue_read get_comments (read-back of this report)",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #12732: the varchar differ's expected width disagrees with what createColumn would emit for a DECLARED bound, measured in two directions — (a) an unkeyed bounded TEXT-family field ({type:'text'|'richtext'|'signature'|'markdown', maxLength:50}) over a legacy varchar(255) reports narrow_varchar at severity error / category DESTRUCTIVE, which refuses the artifact-pinned boot, for a divergence the write seam already forecloses and whose remedy contradicts the emitter (unkeyed, createColumn makes TEXT); (b) a bound past MAX_VARCHAR_CHARS (16383) — authorable, since FieldSchema declares maxLength as z.number().int().min(1) with no upper bound — plans widen_varchar to varchar(100000), DDL MySQL refuses (ERROR 1074) while Postgres accepts, and is categorised 'safe' so dev auto-reconcile is eligible to run it. Filed unassigned, no labels, for PM triage. Deliberately NOT repaired here: it is the declared half (this card is the undeclared half) and the correct fix needs the keyed-column map threaded from sql-driver.ts, a file this card does not hold."
      ]
    }

    Generated by Claude Code


    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 landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions