Skip to content

driver-sql: store the file family (file / image / avatar / video / audio) as the bare sys_file id in a string column — drop FILE_REFERENCE_TYPES from JSON_COLUMN_TYPES, per-deployment switch on the adr-0104-file-references flag (ruling on #15041, step 2) #15989

Description

@claude

Blocked-by: #15041
Related: #15771 · #15769 · objectui#7699 · ADR-0104

Filed by the domain:spec execution seat (session_01M59rPZZFzqhfMUPFqqZTkf, 2026-09-05T17:37Z) as execution step 2 of the maintainer ruling on #15041 (5551135629, director seat, decision batch #49 item 1, maintainer verbatim 「15041 应该改为实际 id 保存。选A,其他同意」). The ruling's step 2, quoted verbatim:

Driver card (domain:engine, filed by the spec seat with the migration sketch from report 5550175673 / H1 table 5550175730, pm:blocked on the addendum): drop FILE_REFERENCE_TYPES from JSON_COLUMN_TYPES; isJsonField / formatInput / formatOutput stop treating the family as JSON, keyed on the deployment flag until every deployment is past it; the per-dialect unquote migration as a further step of os migrate files-to-references --apply (SQLite json_extract unquote; PG ALTER … USING (col #>> '{}'); MySQL JSON_UNQUOTE + MODIFY) — abort unless backfill + verify report zero blocking; the varcharColumnChars mirror; schema-drift (fold #15771 in or leave it adjacent, the driver card decides); pins per dialect for both encodings across the window. Clause-②: yes (published storage behaviour of @objectstack/driver-sql changes). Changeset per the repo's launch-window rule: BREAKING described under a banner at minor with an ADR-0087 disposition — the driver card's dev derives the exact level from the diff.

What is ruled

The physical column for the file family (file / image / avatar / video / audio, FILE_REFERENCE_TYPES in packages/spec/src/data/field-value.zod.ts) holds the actual sys_file id — a bare id string in a string column — not a JSON-quoted id in a JSON column. The driver is the side that moves; the generator's VARCHAR(2048) (packages/cli/src/commands/generate.ts) already states the ruled end-state and does not change. Options B (generator copies the driver's JSON column) and C (status quo) were rejected by the maintainer.

Measured state to start from (readings on origin/main 8e500f23e, 2026-09-05T06:58Z — re-measure on today's main before editing)

  • The family enters JSON_COLUMN_TYPES only by the spread at packages/drivers/driver-sql/src/sql-driver.ts:237 (docblock at :228); isJsonField / formatInput / formatOutput are the three readers.
  • SQLite (measured): a driver-written id is stored JSON-quoted ("file_01HXYZ") in a text column and reads back as the bare id; a raw bare id in the same column is JSON.parsed and fails. PG / MySQL read and write behaviour was reasoned from sql-driver.ts, not measured on a live cell — the H1 table (5550175730, row (c)) records the confidence gap; the dev measures before relying on it.
  • The generator (generateMigrationSql → VARCHAR(2048); generateMigrationTs → table.string) and syncSchema on SQLite (text) already produce string columns; the driver's non-SQLite jsonColumn path is the only producer of a JSON column for the family (H1 row (d)).
  • Every ADR-0104 D3 wave has landed in spec 17.0.0; the stored-value contract is id-only in spec and in the validator (field-value.zod.ts:515-522); an inline object is still admitted on deployments that have not run os migrate files-to-references --apply (H1 rows (b) / H3) — which is why the switch is per deployment.

Shape of the change (the addendum on #15041 is the contract; this card executes it)

  1. sql-driver.ts: remove the FILE_REFERENCE_TYPES spread from JSON_COLUMN_TYPES; the three readers treat the family as a plain string column when the deployment carries the adr-0104-file-references flag, and as today's JSON column otherwise (the dual-encoding window the addendum declares, with its end condition).
  2. os migrate files-to-references --apply gains a further step: the per-dialect unquote migration (SQLite json_extract(col, '$'); PG ALTER TABLE … ALTER COLUMN … TYPE varchar USING (col #>> '{}'); MySQL JSON_UNQUOTE + MODIFY), run only after backfill + verify report zero blocking rows — abort otherwise.
  3. varcharColumnChars mirror for the family; schema-drift: fold driver-sql schema-drift: the json-vs-text type_mismatch finding is keyed to field.multiple only, so a SINGLE-value JSON-class column (file family, STRUCTURED_JSON_TYPES) on a char/text column is never reported — the column a hand-run generated migration creates today #15771 (the json-vs-text type_mismatch finding keyed to field.multiple only) in, or leave it adjacent — this card's dev decides and says which in the PR body.
  4. Pins per dialect for both encodings across the window; a reverse verification that a bare id written under the flag reads back unchanged and a legacy JSON-quoted id still reads on an un-flagged deployment.
  5. Changeset: @objectstack/driver-sql, launch-window **BREAKING** banner at minor, ADR-0087 disposition derived from the diff.

Sequencing and readers

Labels applied by the filing seat per the ruling's own routing (domain:engine, pm:blocked, priority:p2, bug); triage corrects if the landing point differs.


Generated by Claude Code

Activity

  1. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    Carried from the addendum lap (PR #16014, ADR-0104 addendum 2026-09-05; domain:spec seat, 18:48Z) — a population the driver card must handle explicitly. Deployments that already hold the adr-0104-file-references sys_migration row from BEFORE the column step exists — every creation-attested store since 17.0 (attestFreshDatastore, including every dogfood boot) and any earlier os migrate files-to-references --apply run — hold the flag AND JSON-quoted ids in a JSON column, so the flag alone cannot key the driver's write arm on them. The addendum's hard rule: they stay on the JSON arm until step 3 (the per-dialect unquote + retype) runs on them, whatever the row says; the distinguishing mechanism (re-record the same row after the step vs observe the column's actual type) is THIS card's decision, must fail toward JSON, must be pinned in this card's PR, and must not be a second gate that can disagree with the row (the ADR's 2026-07-27 principle) — the details diagnostics column is not a contract key. Re-running --apply is idempotent and re-records the row. The addendum also fixes the window's end on the release axis (the JSON arm leaves in the first protocol major after this card's PR lands, with a loud boot refusal naming the command for a deployment that has not run its own step) — read the addendum before dispatch; it is under human review on #16014.


    Generated by Claude Code

  2. huangyiirene commented on Sep 6, 2026

    @huangyiirene
    Collaborator

    Unlock scan — Blocked-by: #15041 is discharged. pm:blocked → pm:queue. ⛔ Not a claim; this card is the domain:engine seat's.

    Posted by the domain:spec seat (#6017), session_01T6HeZvT9wdSJD1ZxJb5Eno, R1, 2026-09-06T03:12Z — the seat that filed this card and adopted #15041's linger item. This is the standing unlock scan (上游关单放回解锁卡), ⛔ not a cross-lane claim: no assignee is set, no dispatch is made, and domain:engine is left untouched.

    放行双查

    Check Reading
    ① Release only against the condition in the most recent conversion comment This card was filed pm:blocked and states its own release condition in the body: "dispatchable when #15041 closes with the addendum merged — the unlock scan returns it to pm:queue; the domain:engine seat re-verifies the file face on the merged ref before dispatching." Both halves are now true. The later comment 5554009842 is a carried requirement, not a conversion, and does not move the condition.
    ② Refuse if the card carries a MERGED PR newer than that comment closed_by_pull_requests = 0; this card has no PR. Clean.

    Upstream state: #15041 closed completed 2026-09-06T02:45:19Z; PR #16014 merged 02:45:18Z by os-zhuang (human merge on a governed docs/adr/** surface, after an authorised APPROVED review). Addendum verified on origin/main 932acc3df at docs/adr/0104-field-runtime-value-shape-contract.md:1027, absent at the pre-merge tip 2e357650, with the file's ## count moving 11 → 12 as the control. Full probe on #15041.

    ⚠️ Three things the engine seat should not have to rediscover

    1. Re-verify the file face on the merged ref before dispatching. This card's "measured state to start from" was taken on origin/main 8e500f23e (2026-09-05T06:58Z) and the card itself says to re-measure. main is now 932acc3df.
    2. The addendum is the contract — read it, not this card's summary of it. It is now on main at the anchor above. It fixes the per-deployment keying, the dual-encoding invariant ("on one deployment the column's type and the driver's write encoding never disagree") and the window's end condition on the release axis.
    3. ⭐ The hardest population is already named for you in comment 5554009842: deployments carrying the adr-0104-file-references row from before the column step existed — every creation-attested store since 17.0 and any earlier --apply run — hold the flag and JSON-quoted ids. The flag alone cannot key the write arm on them. The addendum's hard rule is that they stay on the JSON arm until step 3 runs, whatever the row says; the distinguishing mechanism is this card's decision, must fail toward JSON, must be pinned, and ⛔ must not become a second gate that can disagree with the row.

    Clause-②: yes (published storage behaviour of @objectstack/driver-sql changes) ⇒ contract-review tier at dispatch, and the enqueue gate will read the actual diff.

    Step 3 of the ruling (retiring the generate-field-type-vocabulary.pin.test.ts block at :574-593 to coverage) waits on this card's PR and is the domain:spec seat's to do once it lands.


    Generated by Claude Code

  3. claude commented on Sep 6, 2026

    @claude
    ContributorAuthor

    Dispatch · domain:engine execution round

    PM session session_01ARYe3yQTQCUFm5qPYNgKaJ. Assignee set at dispatch time.
    ⛔ The assignee field is not proof of who holds this card — several seats run under one shared GitHub identity, so this comment is the claim record.

    ⚠️ This is the largest card this seat has dispatched tonight: a ruled, BREAKING-banner storage change across three dialects with a migration step and a dual-encoding window. Read Zone 1.5 before you start — it tells you what to do if you cannot finish it.

    Zone 1 — binding, do not re-litigate

    1. This card EXECUTES a maintainer ruling ([finding] FILE_REFERENCE_TYPES disagree about their column: driver-sql puts file/image/avatar/video/audio in JSON_COLUMN_TYPES, packages/cli generate.ts gives them VARCHAR(2048) — and neither side is obviously the one that should move #15041, decision batch Add granular query operation capabilities to driver schema #49 item 1, maintainer verbatim 「15041 应该改为实际 id 保存。选A,其他同意」). ⛔ Options B (generator copies the driver's JSON column) and C (status quo) were rejected. ⛔ Do not re-open the choice.
    2. The contract is the ADR-0104 addendum, not this card. ⛔ docs/adr/** is a governed surface: read it, never edit it. It declares the stored column, the per-deployment flag keying, the dual-encoding window and its end condition. ⭐ Read it before you write anything.
    3. ⛔⛔ A population exists that the flag alone CANNOT key, and the addendum's rule on it is hard. Deployments holding the adr-0104-file-references sys_migration row from before the column step existed — every creation-attested store since 17.0, including every dogfood boot, and any earlier --apply run — hold the flag AND JSON-quoted ids in a JSON column. They stay on the JSON arm until the per-dialect unquote + retype runs on them, whatever the row says. ⇒ Your distinguishing mechanism:
      • is this card's decision to make;
      • ⛔ must fail toward JSON;
      • must be pinned in this PR;
      • ⛔ must not be a second gate that can disagree with the row (the ADR's 2026-07-27 principle);
      • ⛔ the details diagnostics column is not a contract key.
    4. The generator does not change. packages/cli/src/commands/generate.ts's VARCHAR(2048) already states the ruled end-state. The driver is the side that moves.
    5. Changeset: @objectstack/driver-sql, launch-window BREAKING banner at minor, with an ADR-0087 disposition, and the exact level derived from your diff. Clause-② is yes — published storage behaviour changes. ⇒ Hang needs:contract-review on both carriers with a comparative read-back, and expect to be held for review.
    6. You decide and must SAY which: fold driver-sql schema-drift: the json-vs-text type_mismatch finding is keyed to field.multiple only, so a SINGLE-value JSON-class column (file family, STRUCTURED_JSON_TYPES) on a char/text column is never reported — the column a hand-run generated migration creates today #15771 (the json-vs-text type_mismatch finding keyed to field.multiple only) into this PR, or leave it adjacent.

    Zone 1.5 — ⛔ what to do if you cannot finish

    ⚠️ A half-landed storage migration is worse than none. If the scope cannot be completed to the standard below in this round, ⛔ land nothing — push no partial migration step, open no PR that implements arm 1 without its pins. Report what you completed, what you measured, and where you stopped. ⭐ That is a good outcome and I will dispatch the remainder; a partially-migrated storage format is not.

    ⚠️ Live PG / MySQL may not be available to you. The card is explicit that PG and MySQL behaviour was reasoned from sql-driver.ts, not measured on a live cell — and that the dev measures before relying on it. If you cannot reach live PG/MySQL: measure SQLite, state plainly which dialect claims are unmeasured, ⛔ do not present reasoning as measurement, and say whether CI's Temporal Conformance (live PG + MySQL) job can carry those pins instead.

    Zone 2 — PM readings, falsifiable, ⛔ NOT binding — re-derive and re-declare

    1. The block is discharged. [finding] FILE_REFERENCE_TYPES disagree about their column: driver-sql puts file/image/avatar/video/audio in JSON_COLUMN_TYPES, packages/cli generate.ts gives them VARCHAR(2048) — and neither side is obviously the one that should move #15041 closed with the addendum merged; the domain:spec seat's unlock scan (2026-09-06T03:12Z) ran both release checks — the card's own stated condition is satisfied, and closed_by_pull_requests = 0 so no merged PR post-dates it. ⭐ That scan explicitly says it is not a claim and left domain:engine untouched. ⚠️ The card also requires that the domain:engine seat re-verifies the file face on the merged ref before dispatching — I have not done that; you do it first, and if the file face has moved, say so before writing.
    2. Anchors measured on origin/main 8e500f23e (2026-09-05T06:58Z) — the card itself says re-measure on today's main: the family enters JSON_COLUMN_TYPES only by the spread at packages/drivers/driver-sql/src/sql-driver.ts:237 (docblock :228); the three readers are isJsonField / formatInput / formatOutput; the stored-value contract is id-only in packages/spec/src/data/field-value.zod.ts:515-522. ⛔ Re-locate by text and publish your own line numbers.
    3. One thing WAS measured and one was not — keep them apart: on SQLite, a driver-written id is stored JSON-quoted ("file_01HXYZ") in a text column and reads back bare, while a raw bare id in the same column is JSON.parsed and fails. PG / MySQL were not measured on a live cell.
    4. Clause-② yes is the ruling's own starting answer — ⚠️ re-derive from your delivered diff anyway, with the corrected instrument (diff every declaration file under files[]; ⛔ exports names entry points, not the surface; classify each hunk). ⭐ Limb 2 is the interesting one here and you must argue it: is any request newly accepted or rejected? A deployment on the JSON arm must still accept everything it accepts today.

    Zone 3 — suggested route (⛔ not binding)

    Do the reading and the measurement before any edit: the addendum, the merged file face, today's anchors, and SQLite's actual encoding both ways. Then decide the distinguishing mechanism first, because everything else keys off it — and it is the one decision the addendum hands you rather than settles. The reverse verification the card names is the spine of the pins: a bare id written under the flag reads back unchanged, and a legacy JSON-quoted id still reads on an un-flagged deployment — per dialect, for both encodings, across the window.

    ⚠️ ⛔ Never skip, disable or quarantine a test; ⛔ never edit docs/adr/** or content/docs/releases/**; ⛔ worktree-first and ⛔ never git stash.


    Generated by Claude Code

  4. claude commented on Sep 6, 2026

    @claude
    ContributorAuthor

    Claim: domain:engine execution seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, worked #15989 (ruling on #15041 step 2 — store the file family as the bare sys_file id in a string column). CLOSED OUT WITH NOTHING LANDED, deliberately, per the dispatch's Zone 1.5.

    Branch: claude/issue-15989-file-family-bare-id

    Base: 81b426f77 (origin/main at claim time, 2026-09-06). The branch carries zero commits and is byte-identical to its base; there is no PR. Nothing was landed and nothing partial was pushed. git status --porcelain empty, git diff --stat HEAD empty, dispatch-gates.mjs --repo objectstack-ai/objectstack reports "this branch changes nothing against origin/main".

    What this round DID deliver is measurement, on the two dialects reachable from this container, plus the distinguishing-mechanism decision. The full reading is in the os-dev-report comment that follows this one. Four things it establishes that were not known before:

    1. Live PostgreSQL 16.13 was brought up locally, so the addendum's confidence gap 1 is now half closed by measurement rather than reasoning: the 22P02 on a bare write into a json column, the JSON-quoted write into a generator-shaped varchar, and the ALTER ... USING (col hash-gt-gt '{}') retype were all run for real. MySQL is not reachable here (no server, no docker daemon) and stays reasoned.
    2. The PG retype does not abort on a non-string cell — it accepts an inline-object row and flattens it to text. The addendum's step-3 sketch needs an explicit pre-check; the USING clause does not provide the abort the addendum requires.
    3. The window's "the driver reads both encodings on every dialect" is not true today on PG for a JSON-quoted id sitting in a string column: it reads back with its quotes. A new decode arm is required, and it did not exist to be keyed.
    4. driver-sql schema-drift: the json-vs-text type_mismatch finding is keyed to field.multiple only, so a SINGLE-value JSON-class column (file family, STRUCTURED_JSON_TYPES) on a char/text column is never reported — the column a hand-run generated migration creates today #15771 is already closed and landed (PR fix(driver-sql): schema drift reports a single-value JSON-class field on a stale text column #16073, 33e939ff3), so the card's "fold it in or leave it adjacent" is moot — and its landed widening now makes the ruled end-state report as an error-severity type_mismatch drift entry prescribing the reversal of the ruling. That is an obligation this card acquires, not a choice it makes.

    The clause-two verdict is re-derived independently below and declared here as the required closing step. It governs the PR a later round opens on this scope; it is a judgement about the ruled change, not about a delivered diff, and the report says so in those words. Limb 1: the ruled change moves published storage behaviour of @objectstack/driver-sql and, under the mechanism decided here, adds a contract field to DataMigrationFlagSchema in @objectstack/spec — an added interface MEMBER, which check:api-surface cannot see, so a green there would not have been a no. Limb 2 fires on measurement, in both directions: a bare id written to a moved deployment's column is newly ACCEPTED where the identical write is 22P02 today on PG, and a JSON-quoted id in a string column newly reads back bare where today it reads back quoted. Both limbs are yes.

    ⛔ needs:contract-review is deliberately NOT hung by this round. The gate is a dual carrier hung in one stroke, and with no PR there is only one carrier — hanging it on the card alone would manufacture exactly the C1/C2 single-carrier split check-clause2-carriers reports as a finding. The round that opens the PR hangs it on both carriers in one stroke, with the comparative read-back.

    Clause-②: yes


    Generated by Claude Code

  5. claude commented on Sep 6, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 15989,
      "status": "blocked",
      "branch": "claude/issue-15989-file-family-bare-id",
      "pr": null,
      "premise_still_valid": true,
      "summary": "LANDED NOTHING, deliberately, per the dispatch's Zone 1.5 — the branch carries zero commits, is byte-identical to base 81b426f77, and there is no PR (git status --porcelain empty; dispatch-gates.mjs --repo objectstack-ai/objectstack answers 'this branch changes nothing against origin/main'). The card's central premise HOLDS: the file family still enters JSON_COLUMN_TYPES only by the spread, the maintainer ruling stands, and the generator already states the end-state. What this round delivered instead is measurement and the one decision the addendum hands the card. FILE FACE RE-VERIFIED on the merged ref (81b426f77, which is PAST the 932acc3df the unlock scan named): the spread is still packages/drivers/driver-sql/src/sql-driver.ts:237, but its docblock opens at :222 (card said :228); isJsonField :16318, formatInput :16324, formatOutput :16481, varcharColumnChars :15445, jsonColumn :15771, the DDL switch :16103; spec FILE_REFERENCE_TYPES packages/spec/src/data/field-value.zod.ts:182 with the stored contract at :515-522 (unmoved); and the generator pin block labelled 'Recorded divergence, NOT coverage' has MOVED from :574-593 to packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts:620-640. DISTINGUISHING MECHANISM DECIDED: record the column step's completion on the SAME sys_migration row, as a NEW CONTRACT FIELD on DataMigrationFlagSchema (working name columns_migrated_at), written by step 3 inside the same --apply that records the row; the driver's arm is 'bare' only when isDataMigrationFlagVerified(flag) AND that field is non-null, and 'json' otherwise. It fails toward JSON because absence is the default: every row that exists in the world today lacks the field, so population 2 (every creation-attested store since 17.0 and every earlier --apply run) reads 'json' with no extra logic, and an engine that cannot read the row at all also reads 'json'. It is not a second gate: it is one row, one predicate, written in the same act that moves the columns, so it cannot disagree with itself; it is not the details diagnostics column; and it is not a version. I REJECTED the addendum's other offered option (observe the column type) on MEASUREMENT, not taste: a generator-created VARCHAR(2048) column already exists on un-flagged deployments holding JSON-quoted ids (that is exactly #15771's population), so column type does not imply encoding, and keying on it would flip population 1 onto the bare arm without their --apply — which the addendum forbids. WHY NOTHING LANDED, in one line each: (a) MySQL is physically unreachable here and its statement ORDER is the thing the addendum names as unsettled, so step 3's MySQL arm would be landed unrehearsed; (b) I MEASURED that the PG retype does NOT abort on a non-string cell, so the addendum's sketch does not implement the abort it requires and step 3 needs an explicit pre-check that no one has written; (c) I MEASURED that the window's 'reads both encodings on every dialect' is FALSE on PG today for a quoted id in a string column, so arm 1 needs a NEW decode arm on PG/MySQL that does not exist to be keyed; (d) the mechanism requires widening a published spec contract (DataMigrationFlagSchema plus the sys_migration object plus recordDataMigrationRun plus attestFreshDatastore) AND a driver-to-flag channel that SqlDriverConfig does not have, so the change spans spec + platform-objects + service-storage + cli + driver-sql + objectql; (e) isJsonField's answer is FROZEN into jsonFields[object] at registration time by registerManagedObjectMetadata (:9509) and registerExternalObject (:9440), and formatInput/formatOutput read that registry, not the predicate — so 'key the three readers on the flag' is not a three-line edit, it is a new async-resolved, memoized, fail-to-JSON arm plus a separate media-column registry. Each of (a)-(e) is a piece the PM can dispatch; together they are more than one round can land to the standard the card sets, and a partially-migrated storage format is the one outcome Zone 1.5 rules out.",
      "tests": "NO CODE CHANGED, so there is no gate run to report and no ablation of a delivered diff — the tree is pristine and dispatch-gates.mjs derives an EMPTY family from it (REAL_EXIT=2, 'this branch changes nothing against origin/main (merge base 81b426f77)'). What follows is MEASUREMENT, run through scripts/pm/os-verify-lock.sh (slot issue-15989-measure) on throwaway probe files that were DELETED afterwards (git status --porcelain empty, verified). Dependency closure built first: pnpm --workspace-concurrency=2 --filter '@objectstack/driver-sql^...' build, VERDICT command-exit 0. === LIVE DIALECTS: SQLite measured; PostgreSQL 16.13 measured LIVE (I started the container's own /usr/lib/postgresql/16 cluster and wired it as OS_TEST_POSTGRES_URL, then dropped the database and stopped the server again); MySQL NOT MEASURED AT ALL — no mysqld/mariadb binary on the box and the docker daemon is absent (dial unix /var/run/docker.sock: no such file or directory), so every MySQL statement below stays REASONED, exactly as the addendum left it. === SQLITE (2 test files, both exit 0): single-value image/file columns are declared TEXT (PRAGMA table_info: 'cover:TEXT'); a driver-written bare id lands JSON-QUOTED on disk (hex 2266696C655F30314858595A22, i.e. 0x22-delimited) and reads back bare. CORRECTION TO THE CARD AND TO THE DISPATCH: a raw BARE id in the same column does NOT fail on read — measured read-back 'file_RAWBARE', no fault. The JSON.parse throws and the catch keeps the raw string, which is what the ADR itself says; the card body and the dispatch brief both compress this to 'is JSON.parsed and FAILS', and that compression is FALSE about the read outcome. SQLITE STEP-3 REHEARSAL (first one anywhere, per the addendum's confidence gap 2): update ... set cover = json_extract(cover,'$') where json_valid(cover) and json_type(cover)='text' converts the quoted cell and LEAVES an already-bare cell untouched (json_valid on a bare id measured 0), and is IDEMPOTENT on re-run — measured before/after/after-rerun. json_type of an un-backfilled inline-object cell is 'object', so it is the correct abort discriminator on SQLite. ORDERING FACT: after step 3 a JSON-arm driver still READS the migrated column correctly ('file_A') but its WRITE re-quotes ('\"file_C\"') — so the column move and the driver's arm flip must be one act, not two. === LIVE PG 16.13 (3 test files, all exit 0) — everything here was REASONED in the ADR and is now MEASURED: single-value image/file column data_type is 'json'; a driver-written bare id is stored as json '\"file_01HXYZ\"' and reads back bare; a hand-written bare id into the json column raises exactly the claimed 22P02 'invalid input syntax for type json'; a legacy JSON-quoted cell reads back bare. #15771's silent corruption REPRODUCED on live PG: the driver writing into a generator-shaped varchar(2048) stores '\"file_VARCHAR\"' and READS IT BACK WITH ITS QUOTES. The retype ALTER TABLE ... ALTER COLUMN ... TYPE varchar(2048) USING (cover #>> '{}') WORKS (rows 'file_01HXYZ' / 'file_QUOTED', column becomes character varying(2048)) and is TRANSACTIONAL — rolled back inside a transaction, data_type measured back at 'json'. ⛔ NEW MEASURED DEFECT IN THE ADR'S SKETCH: that same retype DOES NOT ABORT on a non-string cell. With one inline-object row present the statement was ACCEPTED and flattened the object to the literal text '{\"url\":\"https://x/y.png\"}' in a varchar column. The addendum requires step 3 to abort 'on the first cell that is not a JSON string'; the USING clause does not provide that, because #>>'{}' extracts ANY json type as text. Step 3 needs an explicit pre-check — select count(*) from t where json_typeof(col) IS DISTINCT FROM 'string' — measured returning 1 on that fixture. Landing the sketch as written would silently destroy exactly the rows the backfill has not converted. ⛔ NEW MEASURED GAP IN THE WINDOW: 'throughout the window the driver reads both encodings on every dialect' is FALSE on PG today. A JSON-quoted id in a varchar column reads back as '\"file_QUOTED\"', quotes included, because the parse arm is isSqlite-gated. Arm 1 therefore has to ADD a media decode arm on PG (and by the same reasoning MySQL) before the flag can key anything. ADR CLAIM CONFIRMED ON PG (it had only been measured on SQLite): additive sync never retypes — after step 3 a fresh initObjects with the same metadata left the column at character varying(2048). ⛔ AND THE CONSEQUENCE NOBODY HAS RECORDED: with that column migrated, detectTableDrift on live PG now reports kind='type_mismatch', severity='error', category='needs_confirm', op=manual_column_type_change to:'json' — i.e. the drift detector prescribes REVERSING the ruling on every deployment that runs step 3. That is #15771's own widened finding (landed as #16073) firing against the ruled end-state. === CI CARRIAGE (the dispatch's explicit question): YES. The 'Temporal Conformance (live PG + MySQL)' job at .github/workflows/ci.yml:871 provisions postgres:16 and mysql:8.0 and runs the WHOLE driver-sql suite against both (step 'Run driver-sql suite against both live servers', OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL / OS_EXPECT_LIVE_DIALECT_MATRIX=1). Any pin file added under packages/drivers/driver-sql/src/ that iterates DIALECT_CELLS is carried by it, so the MySQL pins CAN be authored here and measured there. What CI canNOT carry is the REHEARSAL — the addendum's confidence gap 2 wants the unquote+retype run against a copy of a real datastore per dialect, and a green pin is not that. === CLAUSE-② CARRIER: check-clause2-carriers.mjs was run from a FRESH worktree detached at origin/main (/home/user/objectstack-c2fresh-15989), and the checker is NOT stale — git hash-object 751b4a6e4fbdec025f597303164d6c2ff7c5e94a equals git rev-parse origin/main:scripts/pm/check-clause2-carriers.mjs. --self-test REAL_EXIT=0, 190 cases. ⚠️ --pair COULD NOT BE RUN and REAL_EXIT is therefore UNAVAILABLE for this card: --pair judges a card/PR PAIR and this card has no PR. The survey run it does instead derived 34 pairs from 33 open PRs and does not name #15989, which is correct. What I DID measure is the declaration limb directly: importing the checker's own exported readClause2Line and feeding it the POSTED body of my claim comment returns {kind:'declared', value:'yes'}, with two controls — deleting the bare line drops the reading to null, and the known-good literal 'Clause-②: no' reads 'no'. ⚠️ Hazard caught and removed en route: my first posted draft had a PROSE sentence beginning 'Clause-② re-derived…', and with the bare line removed that same control returned kind:'near-miss' rather than null — i.e. a prose sentence opening with the key is a MALFORMED candidate. I reworded it to 'The clause-two verdict is…' and re-verified; the control now returns null.",
      "mcp_calls": "0 — every GitHub read and write this round went over REST through node fetch with NODE_USE_ENV_PROXY=1; no MCP GitHub tool was called at all.",
      "open_questions": [
        {
          "question": "The distinguishing mechanism I decided widens a PUBLISHED spec contract (a new field on DataMigrationFlagSchema plus the sys_migration platform object). The addendum delegates the CHOICE to this card, but not obviously the right to add a contract field to the shared flag row. Confirm the field, or name the alternative.",
          "options": [
            "A — a new nullable datetime contract field on DataMigrationFlagSchema (working name columns_migrated_at), written by step 3 in the same --apply that records the row. Absent means the JSON arm, so every existing row on earth fails toward JSON with no extra logic, and one row still carries one answer.",
            "B — observe the physical column type per media column and key the arm on that. MEASURED UNWORKABLE: a generator-created VARCHAR(2048) holding JSON-quoted ids already exists on un-flagged deployments (#15771's population), so column type does not imply encoding, and this would move population 1 onto the bare arm without their --apply, which the addendum forbids.",
            "C — a SECOND sys_migration id for the column step. Rejected up front: Zone 1 forbids a second gate that can disagree with the row, and this is literally that."
          ],
          "recommendation": "A, because it is the only candidate that is one row, one predicate, written in the same act that moves the columns — so it cannot disagree with itself — and because its failure mode is absence, which is the JSON arm. B fails on measurement and C fails on the ruling. If A's spec widening is judged out of scope for this card, that is a spec-lane sub-card, not a reason to key the arm on something weaker."
        },
        {
          "question": "The addendum's step-3 sketch for Postgres does not implement the abort it requires — MEASURED: ALTER ... USING (col #>> '{}') accepts an inline-object cell and flattens it to text. Does the correction (an explicit json_typeof pre-check inside step 3, aborting before any DDL) sit inside this card, or does it amend the ADR?",
          "options": [
            "A — implementation detail: step 3 pre-checks select count(*) where json_typeof(col) IS DISTINCT FROM 'string' and aborts, which is what the addendum ALREADY requires in prose. The ADR's per-dialect sketch is explicitly labelled 'unrehearsed', so refining it is what the rehearsal was for.",
            "B — the sketch is part of the contract, so the correction is an ADR erratum first (governed docs/adr/**, human merge) and this card executes it after."
          ],
          "recommendation": "A. The addendum's REQUIREMENT ('aborting on the first cell that is not a JSON string') is unchanged and already correct; only the statement list under it was optimistic, and it is the paragraph the ADR itself flags as unrehearsed. But the measurement should be carried into the ADR's confidence-gap section by whoever next touches it, because right now the ADR reads as though the USING clause does the aborting."
        },
        {
          "question": "The driver has NO channel to the deployment flag: SqlDriverConfig (sql-driver.ts:4121) carries schemaMode / autoMigrate / sqliteJournalMode / sqliteAbsentFile and nothing else, and isFileReferencesMigrationVerified lives on the objectql engine (engine.ts:7847), which the driver never sees. Which seam carries the arm into the driver?",
          "options": [
            "A — a new SqlDriverConfig option (an async resolver the kernel injects), memoized in the driver, defaulting to the JSON arm when absent or when it throws.",
            "B — the driver reads sys_migration itself, lazily, over its own knex handle. No new published surface, but it re-implements the engine's memoized predicate one layer down, which is the two-readers-of-one-fact shape the ADR keeps warning about.",
            "C — the arm is resolved above and pushed in at initObjects time as part of the object metadata."
          ],
          "recommendation": "A, with the resolver optional and its absence meaning JSON. It keeps one reader of the flag (the engine's), it is the only option that works for the skipSchemaSync / registerObjectMetadata deployments (they simply never get a resolver and stay on JSON, which is the correct fail-toward), and unlike C it does not smuggle a deployment-level fact into per-object metadata. This is a published-surface addition to @objectstack/driver-sql and wants saying out loud in the changeset."
        },
        {
          "question": "Population 3 (the addendum's own third population): a datastore born after this card lands is attested at kernel:ready, AFTER schema sync has already created its media columns — on the JSON arm, because at sync time no flag exists. Does a born-fresh store start on the JSON arm and stay there until someone runs --apply, or does attestFreshDatastore also record the column step it can trivially satisfy?",
          "options": [
            "A — attestFreshDatastore records the column field too, and the born-fresh path creates the columns in the ruled form. Born-strict is preserved and the dogfood boots stay the canary the addendum says they are.",
            "B — a born-fresh store starts on the JSON arm; nothing is wrong, it simply needs one --apply. Safest, but every dogfood boot then exercises only the legacy arm and the bare arm has no standing canary."
          ],
          "recommendation": "A, but only once the seam between column creation and attestation is closed in the same change — otherwise it records a column step that did not happen, which is the one thing the mechanism must never do. If that seam cannot be closed in the round that does arm 1, take B explicitly and say so, rather than letting it happen by omission."
        }
      ],
      "out_of_scope_findings": [
        "NOT filed as new issues, deliberately, and this is a decision not an omission: all four of the round's findings land INSIDE #15989's own five deliverables, so filing them separately would duplicate an open card's scope rather than add to the backlog. They are recorded on #15989 in the os-dev-report comment: (1) the PG retype does not abort on a non-string cell; (2) the window's dual-read is false on PG for a quoted id in a string column; (3) a step-3-migrated column reports as an error-severity type_mismatch prescribing the reversal of the ruling; (4) isJsonField's answer is frozen into a registry, so keying the readers on a flag is an architecture change rather than a predicate edit.",
        "CARD CORRECTION, no issue needed: #15771 is CLOSED completed (2026-09-06T00:53:09Z, PR #16073, 33e939ff3 on origin/main before my base). The card's and the dispatch's shared statement that it is open and 'keyed to field.multiple only' is FALSE — schema-drift.ts:682 JSON_COLUMN_FIELD_TYPES already spreads FILE_REFERENCE_TYPES and :1030 declaresJsonColumn is 'JSON_COLUMN_FIELD_TYPES.has(declaredType) || field.multiple === true'. So deliverable 3's 'fold in or leave adjacent' is MOOT; the real obligation runs the other way, see finding (3).",
        "CARD CORRECTION, no issue needed: the card's and the dispatch's 'a raw bare id in the same column is JSON.parsed and FAILS' is FALSE about the outcome — measured read-back is the bare id, no fault, because the catch keeps the raw string. The ADR states this correctly; only the card's and the dispatch's compression of it is wrong. It matters: SQLite's read arm ALREADY reads both encodings, which is why the dual-read gap is a PG/MySQL problem and not a three-dialect one.",
        "ANCHOR DRIFT, no issue needed: the generator pin block labelled 'Recorded divergence, NOT coverage' has moved from the ruling's :574-593 to generate-field-type-vocabulary.pin.test.ts:620-640, and the JSON_COLUMN_TYPES docblock from :228 to :222. Also worth the next round knowing: that pin asserts the driver source CONTAINS the literal string '...STRUCTURED_JSON_TYPES, ...FILE_REFERENCE_TYPES, ...MULTI_OPTION_TYPES,', so the very first edit of deliverable 1 reddens a test in @objectstack/cli by design — the retirement is not optional cleanup, it is the same commit."
      ]
    }

    Generated by Claude Code

  6. 14 remaining items

  7. claude commented on Sep 10, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 15989,
      "status": "rework",
      "branch": "claude/issue-15989-file-family-bare-id-column",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17403",
      "premise_still_valid": true,
      "summary": "Landed the ruled encoding change and everything that reads it; did NOT land the column step, deliberately and per this card's own PM sequencing (comment 5556979386: the PG/MySQL decode arm, then the registry-freeze rework of arm 1, then step 3 with its measured abort — this PR is the first two). WHAT LANDED: the file family leaves JSON_COLUMN_TYPES; a single-value media field is a json column on a deployment that has not moved its columns and a varchar(2048) — the SQL generator's own width — on one that has; isJsonField / createColumn / varcharColumnChars / the drift detector all ask one question (mediaColumnIsJson). The arm reaches the driver as the new published SqlDriverConfig.fileColumnsMoved (boolean or async resolver, resolved once at initObjects, memoized), keyed on sys_migration.columns_moved_at which #16185 landed — never on the adr-0104-file-references flag alone, which the addendum forbids. EVERY way of not knowing answers 'not moved', so every deployment that exists today is byte-for-byte on today's JSON arm. formatOutput now reads BOTH encodings on all three dialects and on both arms, which repairs #15771's measured corruption (a JSON-quoted id in a generator-shaped varchar read back WITH ITS QUOTES on live PG); the decode engages only on a leading double-quote, brace or bracket, none of which can begin a sys_file id, a resolver URL or a data: URI. schema-drift's JSON_COLUMN_FIELD_TYPES drops the family and diffManagedTable takes a fileColumnsMoved input whose OMISSION reproduces the previous verdicts exactly. The generator pin block labelled 'recorded divergence, NOT coverage' is retired to coverage in the same commit (the ruling's step 3) and now pins the retirement direction with an anti-vacuity control. THE TWO DELEGATED DECISIONS, DECIDED: (1) schema-drift — FOLDED IN, and it was not a free choice: #15771 is closed (PR #16073) and its widening already spreads FILE_REFERENCE_TYPES into JSON_COLUMN_FIELD_TYPES, so the ruling's 'fold in or leave adjacent' is moot as written; schema-drift.json-column-parity.test.ts holds the differ's predicate equal to the writer's, so the moment the writer's answer became per-deployment the pin reds by name on those five types — leaving schema-drift adjacent would have meant weakening a good pin. The pin now asserts the agreement on BOTH arms. This answers the question #16184 carries; I left that card open for its own seat rather than closing it from here. (2) born-fresh stores — OPTION B, EXPLICITLY: a born-fresh store starts on the JSON arm and needs one --apply, because attestation happens at kernel:ready AFTER schema sync created the columns and the seam cannot be closed here; recording a column step that did not happen is the one thing the mechanism must never do. The cost is named in the PR body: the bare arm has no standing dogfood canary, so its coverage is this PR's dialect matrix. WHAT DID NOT LAND, and why: os migrate files-to-references --apply still does not retype or rewrite media columns, and nothing here moves any deployment's storage. Two reasons: (a) MEASURED on SQLite, after the columns are converted a JSON-arm driver still READS the migrated column correctly but its next WRITE re-quotes, so the column move and the arm flip must be ONE act — and the arm flip needs a kernel-to-driver wiring point for fileColumnsMoved that does not exist yet; landing the migration without it is exactly the half-migrated storage format Zone 1.5 rules out. (b) MySQL is physically unreachable in this container and its statement ORDER is what the addendum itself leaves unsettled. The dispatch's assignee (os-sam) was already set and I posted no second claim. DOCS (asked mid-round by the dispatching seat): content/docs/protocol/objectql/types.mdx:1148 needs NO edit, and the reasoning is measured rather than asserted. The sentence 'Stored value: an opaque sys_file id string' is the VALUE CONTRACT — 'Stored value' / 'expanded read form' is the valueSchemaFor(def, form) pair in packages/spec/src/data/field-value.zod.ts, id-only since 17.0 and untouched by this diff — not a physical-column claim. Measured with a firing control: parsing the page into its 12 '###' type sections, `location` (:1130) and `address` (:1090) carry a '**Database mapping:**' block while `file` and `image` carry NONE, and the Type Conversion Matrix has no row naming file/image/avatar/video/audio while the control row `json` / `location` / `address` is present — so the page makes no claim this PR could falsify. And what this PR does to that sentence is REPAIR it: before the change it was FALSE on the generator-shaped varchar population (live PG handed back the quoted string); after it, every dialect and both arms hand back the id. Adding a flag-window sentence would introduce a physical-column claim the section deliberately does not make and would need a second edit when the window closes; the legacy direction is already carried conditionally by the callout directly below. The spot-check of the two pages an emitter-only diff can never list: kernel/contracts/storage-service.mdx is CLEAN (zero hits for column/VARCHAR/stored value, control fires 72x on types.mdx); permissions/attachments-access.mdx is NOT — filed, see out_of_scope_findings. I did not touch content/docs at all, so Build Docs does not enter this PR's check set.",
      "tests": "ALL exit codes captured by redirect-then-$?, never across a pipe. === DIALECTS === SQLite measured throughout. PostgreSQL 16.13 measured LIVE — I started the container's own /usr/lib/postgresql/16 cluster on port 54329 with an explicit config_file, created role+database os15989, wired OS_TEST_POSTGRES_URL, and tore both down afterwards. MySQL 8.x is NOT MEASURED — no mysqld/mariadb binary and no docker daemon (docker info REAL_EXIT=1, 'dial unix /var/run/docker.sock: no such file or directory'). It is a NAMED SKIP, never a pass and never a fail: the new pin iterates DIALECT_CELLS, so CI's 'Temporal Conformance (live PG + MySQL)' job carries that cell, and declareDialectCell turns a missing URL there into a failure under OS_EXPECT_LIVE_DIALECT_MATRIX=1. === RED BEFORE GREEN, real direction === (1) schema-drift.json-column-parity.test.ts REAL_EXIT=1, 'AssertionError: expected [ address, audio, avatar, …(12) ] to deeply equal [ address, checkboxes, …(8) ]' — 1 failed | 2 passed. (2) generate-field-type-vocabulary.pin.test.ts REAL_EXIT=1, the #15041 record case: \"expected '// Copyright (c) 2025 ObjectStack. Li…' to contain '...STRUCTURED_JSON_TYPES, ...FILE_REF…'\" — the by-design red the previous round predicted. (3) schema-drift.base-type-mismatch.test.ts REAL_EXIT=1 on the every-JSON-class sweep. All three green after their updates. === ABLATIONS — 2, each with an on-disk proof and a byte-verified restore === Both ran under a `trap` on EXIT INT TERM whose handler restores the file, with REPO_ROOT-absolute paths, restored via `git checkout HEAD --` naming the file (never a bare checkout), and were verified by `git diff HEAD` EMPTY plus `git hash-object` EQUAL to the HEAD blob (2206882e9e8b7d0b66be657862133ee259f35aeb both times), with a fixed-string control grep that FIRES. No dist mediation: the pin imports ./sql-driver.js inside its own package, so vitest transforms source and no rebuild sits between the mutation and the verdict. A · DELETE the formatOutput media read repair. Predicted §3 and §4 red, rest green. Observed exactly: 3 failed | 11 passed | 1 skipped; §3 red on BOTH cells, §4 red on live PG ONLY — which is the recorded fact that the dual-read gap is a server-dialect one, since SQLite's json arm already kept the raw string on a parse failure. B · PLANT — the driver ignores the arm (mediaColumnIsJson() always answers json, i.e. the shape the driver has today). Predicted the moved-arm cases red across both pin files. Observed 11 failed | 10 passed | 1 skipped, including all three arm-aware parity cases and §2/§6 on both cells. Grep readings 1→0 on the original line and 0→1 on the plant, with grep -F. ⚠️ DISCLOSED rather than quietly re-run: ablation A's own before/after marker grep was written with UNESCAPED BRACKETS (this.mediaFields[object] — a bracket expression to grep), so both readings were 0 and THAT reading is VOID in both directions. The mutation's presence on disk rests on the blob-hash comparison (2206882e… → dd0f1cb6…) and on the injected marker's grep, which fired (1). Ablation B used grep -F throughout. === REVERSE VERIFICATION of the cross-package type change === An additive optional field is exactly the shape a cached .d.ts answers the same way twice, so both legs were run in @objectstack/cli's own diffManagedTable call: planting fileColumnsMoved: false typechecks (LEG1_EXIT=0, 0 errors); planting the one-character typo fileColumnsMovedX goes red (LEG2_EXIT=2) with 'TS2561 … Did you mean to write fileColumnsMoved?' and the error text printing the WHOLE new args type. Both legs restored, git diff HEAD empty, blob equal (c5b6e92f45…). === GATES === All 65 families dispatch-gates.mjs --repo objectstack-ai/objectstack derives for this diff were run, each exit code recorded, then reconciled: 'dispatch-gates --ran: 65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3)'. The derivation was taken twice: once before the changeset (58 commands) and again after it, which added 7 (adr-0087-registration, empty-changeset, objectui-changeset, pm-changeset-deadline-census, release-rehearsal-clone self-test). Three hit PREREQUISITE NOT MET (exit 3) first and were re-run green after the prerequisite: check:dual-build-cjs-loads and check:i18n-coverage after a full `pnpm build`; check:type-check-debt after raising the heap past an OOM (it then reported 'OK — 5 ledger entr(ies) re-measured, 55 raw tsc error(s), none above its recorded number'). One real finding: check:query-options-erasure went RED (exit 1, 'test surface grew 236 → 237 site(s)') on the new pin's `as any` options bag — typed as DriverOptions, gate now green at the 236 ceiling with 'baseline key set verified against 59db8a0: no files added'. check-adr-0087-registration: '1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … not-required (no-migration-prescription)'. === LINT === Not a narrowing — the whole-repo scan itself, exactly as CI spells it: `pnpm lint` = `node --stack-size=4000 eslint . --no-inline-config`, REAL_EXIT=0 under the shared lock at final commit d76b8ef8c3, over 6491 files with 0 errors and 0 warnings (file count and counts read from a companion --format json run, not guessed). === SUITES === @objectstack/driver-sql `pnpm test`: REAL_EXIT=0, 'Test Files 168 passed | 11 skipped (179) · Tests 2491 passed | 154 skipped (2645)'. @objectstack/cli --project unit: 192 files / 2667 tests green — 190 in the first pass plus 2 that hit PREREQUISITE NOT MET ('packages/cli is not built') and went green after `pnpm --filter @objectstack/cli build`. typecheck REAL_EXIT=0 on both packages (cli's first run was a false red from an unbuilt dependency closure — built it, re-ran, 0 errors). The three media pins against LIVE PG: REAL_EXIT=0, 'Test Files 3 passed (3) · Tests 54 passed | 3 skipped (57)'. cli integration tier NOT run locally and declared to CI: the diff touches no integration-layer file, no spawn entry point and no driver/kernel start path. === CARRIERS === check-clause2-carriers.mjs --pair 17403 REAL_EXIT=0: 'PR #17403 / card #15989 — the clause-② declaration is readable in the fixed spelling and both carriers agree.' needs:contract-review hung on BOTH carriers via the additive REST endpoint (HTTP 200 each), with a comparative read-back: #15989 read-back equals union(read, target) exactly (5 labels, nothing stripped); #17403 read-back is {needs:contract-review, size/l} — size/l was added concurrently by the size labeler, an ADDITION, and nothing from the union is missing. === BYTES === PR body read back in full over REST after every write and compared to what was sent. On CREATE: IDENTICAL, 12916 sent vs 12915 stored (a trailing newline), session-URL footer intact. On the later UPDATE that added the docs section, the platform did NOT recognise that same footer block and appended a second, BARE one — repaired by carrying the session URL in prose instead, then re-read: exactly 1 footer block, body otherwise byte-identical to what was sent, and 0 angle-bracket fragments stored. The filed card #17406 was read back too: footer block intact, one blank line added ahead of the rule line. Control-byte self-scan over all 8 changed files: zero hits with a control that FIRES on an in-class byte (0x0B); pnpm check:nul-bytes REAL_EXIT=0.",
      "mcp_calls": "0 — every GitHub read and write went over REST with curl, plus the zero-quota embedded-JSON payload channel for the card body; no MCP GitHub tool was called.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #17406: content/docs/permissions/attachments-access.mdx:19-21 says `Field.file` / `Field.image` 'store a file URL in the record's own column'. ADR-0104 D3 retired that stored form — `valueSchemaFor` gives the family `FileReferenceIdValueSchema` in its stored form, and `isFileIdToken`'s own docblock says a URL 'can never match'. It is an ERROR not an omission (the same fact stated with the opposite default from types.mdx's correctly-scoped callout), and it is load-bearing on a page whose whole subject is access: a reader auditing an access rule on 'that column holds a URL' is wrong on every migrated deployment, including every store created since 17.0. Dedup ran before filing — one targeted REST read of the 76 open `documentation` issues, keyword scan for attachments-access / Field.file / file URL, one incidental hit (#17332, an OAuth-scope card), with control term `docs` firing 51 times so the zero is a reading. NOT folded into this PR: touching content/docs pulls the Build Docs job into a check set this diff otherwise does not have, and ④ of the bounded in-place-fix exemption (no new verification surface) therefore fails.",
        "noted, not filed: the TS and SQL halves of the generator disagree on this family's width — generateMigrationSql emits VARCHAR(2048) while generateMigrationTs emits table.string('f_file'), knex's varchar(255). The driver now matches the SQL half because that is the width the ruling calls the end-state. Harmless for a ~26-char id, not for a legacy inline blob on a lax deployment. Successor: the seat that lands the column step, which must decide the width it retypes to; the file itself (packages/cli/src/commands/generate.ts) is frozen for this card by the ruling.",
        "noted, not filed: the drift finding's op.to says 'json' while the driver's own jsonColumn emits 'jsonb' on Postgres, and op.from renders 'character varying' with the width dropped. Carried from the #16184 round's measurement, still true, and it lives in the arm #16184 rewrites. Successor: #16184.",
        "noted, not filed: `os migrate plan` prose for a media column still describes the JSON end-state. It is generated from the drift entry's message, correct for an unmoved deployment and wrong once a deployment can be moved. Successor: the column-step PR, which is the first change that can produce a moved deployment.",
        "CARD/ORDER CORRECTION, no issue needed: the dispatch order's tree reading holds — FILE_REFERENCE_TYPES is at packages/spec/src/data/field-value.zod.ts:216 on my base 59db8a02cb, and JSON_COLUMN_TYPES is NOT in packages/spec (it is sql-driver.ts:236, docblock at :225). The serial-queue fence was respected: packages/spec/src/data/field-value.zod.ts was READ and never edited; packages/spec is untouched by this diff.",
        "TIER NOTICE vs my standing rule, declared rather than chosen silently: comment 5612081812 lifts the contract-review-TIER requirement on this card but says in its own words 'This comment changes no label, assignee or claim.' My standing rule ties the needs:contract-review LABEL to the Clause-② declaration, which the notice leaves unchanged. I therefore hung the label on both carriers and am flagging the reading rather than dropping it — if the PM's intent is that a lifted tier also drops the label, that is a one-line removal on two carriers.",
        "noted, not filed: the PR body's UPDATE channel and its CREATE channel disagree about the signature footer. The create accepted my session-URL footer and stored it byte-identically; the first PATCH did not recognise the same block and appended a second, BARE footer, leaving two. Repaired by carrying the session URL in prose and letting the platform's own appended footer be the single one — verified by read-back (1 footer, body otherwise byte-identical). Recorded because it is a channel-behaviour reading, not a defect in anything this card owns. Successor: any seat that edits a PR body it created with the session-URL footer.",
        "⛔ NOT touched, and I agree with the bot: content/docs/releases/v17/17-0.mdx is release-owned. No opinion filed and no rider on this PR."
      ]
    }

    Generated by Claude Code

  8. os-litant commented on Sep 10, 2026

    @os-litant
    Collaborator

    Re-grade, as requested: finding-less half-state healed to needs-user-decision. domain:engine and priority:p2 unchanged.

    The delivering seat stripped pm:dispatched and the assignee because that dispatch is over, and asked for a re-grade rather than asserting one (5618900154). ⛔ That was the right call and this is the answer.

    It does not go back to pm:queue, and the reason is one of the three things the seat measured.

    Why the decision box

    ⭐⭐ The ruling's own step-3 SQL is measured unsafe. The addendum requires aborting "on the first cell that is not a JSON string", and prescribes USING (col #>> '{}') — which cannot implement that requirement, because #>> '{}' extracts any json type as text. Landing the prescribed clause silently destroys exactly the rows the backfill has not converted.

    ⇒ A data migration that can destroy rows is a 破坏性/难回滚 action, and departing from a maintainer ruling's prescribed action is the maintainer's call — even when the dev is demonstrably right, and here they are. ⛔ Triage does not authorise a deviation from ruled text, and ⛔ a dev must not silently substitute better SQL for ruled SQL.

    ⭐ The seat's own framing is the sentence worth keeping: "an Execution paragraph is authoritative about intent, not immune to being wrong about SQL."

    维护者速读

    这张卡要把媒体字段的存储格式换一种写法。已经落地的那一步是安全的,而且顺手修好了一个真实的数据读取故障(#15771,在真的 PostgreSQL 16.13 上量过)。⇒ 今天所有部署都还在旧格式上,一个都没动过,没有半迁移状态。

    剩下的是真正去改数据库列的那一步,它卡住了,原因不是没人干,而是:

    ⚠️ 当初裁决里写下的那一句 SQL,实测会毁数据。 裁决要求「遇到第一个不是 JSON 字符串的格子就中止」——这个要求是对的;但它同时给出的那句写法做不到这件事,它会把任何类型都硬转成文本 ⇒ 没来得及转换的行会被静默销毁。开发席量出来了,没有照着写,也没有自作主张改掉,而是停下来上报。⛔ 这是对的做法。

    现在需要您的一句话:

    • A —— 采纳实测出的那句正确写法(它真的会在遇到坏格子时报错中止),替换裁决原文里那句;MySQL 因为在容器里根本连不上、且裁决本身没写清它的语句顺序,单独立卡等有环境再做,先让 PostgreSQL 与 SQLite 走完。
    • B —— 就按裁决原文那句写。⛔ 已实测会毁数据,列在这里是为了让您看到它被考虑过,不是建议。
    • C —— 整步都先不做,等 MySQL 能连上了三种数据库一起来。⚠️ 但没有人排过这个环境什么时候有。

    A / B / C?


    os-decision-facets

    • ① 项目长远合理性:A 实现的是裁决自己写下的要求(遇非字符串即中止),只是换了一句真的能做到的 SQL —— 要求是裁决,那句子句是笔误。C 让三种方言齐步走,代价是整步吊在一个没人排期的环境上。B 落地一个已实测会毁数据的迁移,长远上还留下一个先例:裁决正文里的 SQL 即使被证伪也照抄。
    • ② 实际业务拉动:⚠️ 今天为零,且是被测出来的零 —— 「每一种不知道的方式都答『未迁移』」(选项缺失、resolver 抛错、resolver 没跑、宿主没调 initObjects),所以现存每个部署都逐字节在旧格式上,没有任何一个卡在半迁移。真正有拉动的那一半(driver-sql schema-drift: the json-vs-text type_mismatch finding is keyed to field.multiple only, so a SINGLE-value JSON-class column (file family, STRUCTURED_JSON_TYPES) on a char/text column is never reported — the column a hand-run generated migration creates today #15771 的读取损坏修复)已经随这一步落地了。⇒ 列迁移这一步是面向未来的,⛔ 不流血,可以等一句裁决。
    • ③ 防 AI 犯错:B 是本卡最危险的一格 —— 一句写在裁决正文里、读起来完全正确、执行起来静默毁数据的 SQL。一个被反复教育「照 Execution 段执行」的 dev 正应该把它落下去,而本席这一整轮都在强化那条纪律。⇒ A 把它换成会大声失败的写法。⚠️ 若选 C,裁决正文里那句仍然留在原地,下一个读到的人照样会照抄 —— C 也必须同时改掉那句正文,否则陷阱只是被推迟。
    • ④ 创业阶段不扩散:A = 一句子句 + 一张 MySQL 卡。C = 现在零工作量,但整步无限期挂起。B 最省且不可接受。

    推荐:A。 它不是推翻裁决,而是让裁决的要求第一次真正成立;而 B 的代价是不可逆的数据损失,C 的代价是把一个已知陷阱留在正文里等下一个人。

    本分析看不见什么:⚠️ 我完全没有 MySQL 的读数 —— 执行席报告它在容器里物理不可达,本席同样够不到。所以我不知道 MySQL 的语句顺序只是「没写清」,还是会引出第四个问题。若 MySQL 需要的是一种完全不同的列迁移形状,那么 A 里的「MySQL 单独立卡」就不是一张卡,而是一次更大的拆分,推荐的成本估计要重估。

    ⚠️ Two more things the ruling needs to cover, both measured

    1. The column move and the arm flip must be ONE act. Measured on SQLite: after conversion a JSON-arm driver still reads the migrated column correctly but its next write re-quotes. The flip needs a kernel→driver wiring point for fileColumnsMoved that does not exist yet ⇒ whichever option is ruled, that wiring is part of the dispatch, ⛔ not a follow-up.
    2. MySQL statement ORDER is left unsettled by the addendum itself ⇒ it is not a gap the dev can close by reading harder.

    ⭐ Preserved from the landed step, because it is the reason nothing is bleeding

    Every way of not knowing answers "not moved" — option omitted, resolver throws, resolver never runs, host never calls initObjects. ⇒ A driver that guessed the other way would have written bare ids into a JSON column. The arm is keyed on sys_migration.columns_moved_at and ⛔ never on the adr-0104-file-references flag alone, because every creation-attested store since 17.0 carries that flag and JSON-quoted ids.

    ⛔ Do not re-derive any of this at dispatch; it is measured and on the card.

    Triage seat · session_017VGfRocA8VjczSe84fgjY3 · R+168 · 2026-09-10T16:17Z (timestamp taken in the same tool call that posts) · comment from the triage seat


    Generated by Claude Code

  9. added theissue type on Sep 10, 2026
  10. os-tesla commented on Sep 12, 2026

    @os-tesla
    Collaborator

    Ruling recorded — A: adopt the measured-correct abort-on-non-string clause in place of the ruling text's SQL; PostgreSQL and SQLite complete the column step now; MySQL deferred to #17788 (director seat, decision batch #120 item 1, 2026-09-12)

    Maintainer, verbatim (live PM chat, 2026-09-12T04:2xZ), to decision batch #120 presented as 1A·2A·3D·4B·5C′: 「17508 A AI负责翻译就行。其他同意」.

    Derived first from the long-term axis: the #15041 ruling's REQUIREMENT — abort on the first cell that is not a JSON string — is the contract; the clause written beside it was a typo that, measured, casts every type to text and silently destroys unconverted rows. A makes the requirement true for the first time; it does not overturn the ruling. B is excluded (measured data loss); C would leave the false clause in the ruling text for the next reader.

    What is ruled

    1. The PostgreSQL (ALTER … USING) and SQLite (json_extract) legs use the clause the dev seat measured to abort loudly on a non-JSON-string cell (5618311630 / 5618553806); the sentence in the [finding] FILE_REFERENCE_TYPES disagree about their column: driver-sql puts file/image/avatar/video/audio in JSON_COLUMN_TYPES, packages/cli generate.ts gives them VARCHAR(2048) — and neither side is obviously the one that should move #15041 addendum that prescribed the destructive form is superseded by this comment — whoever executes reads THIS, ⛔ not the original clause.
    2. The column move and the driver arm flip are ONE act: the kernel→driver wiring for fileColumnsMoved (keyed on sys_migration.columns_moved_at, ⛔ never on the adr-0104-file-references flag alone) is part of this dispatch, not a follow-up — measured on SQLite that a JSON-arm driver re-quotes on its next write after the move.
    3. MySQL: driver-sql: MySQL leg of the file-family column migration (JSON_UNQUOTE + MODIFY, abort-on-non-string, arm flip in the same act) — deferred from #15989 until a MySQL is reachable #17788, pm:on-hold, Restart-when: a MySQL 8.x instance is reachable from the dispatch environment; its statement order is settled there, on a real instance.
    4. Clause-②: yes stands (published storage behaviour of @objectstack/driver-sql); changeset per the launch-window rule with the ADR-0087 disposition.

    State

    needs-user-decision → pm:queue; bug / domain:engine / priority:p2 kept. Blocked-by: #15041 stays as written on the body (the addendum it points at is the one this ruling amends).


    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

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions