Skip to content

Commit ba69672

Browse files
committed
docs(adr): ADR-0104 addendum — the file family's stored column holds the bare sys_file id, switched per deployment on the adr-0104-file-references flag
Records the maintainer's decision-batch #49 item 1 ruling (Option A) as a 2026-09-05 addendum to ADR-0104: the media family's (image / file / avatar / video / audio) single-value physical column is a string column holding the bare sys_file id — the generator's VARCHAR(2048) / table.string is the ruled end-state and the SQL driver moves to it; the encoding switch is per deployment, keyed on the existing adr-0104-file-references sys_migration row and never on a version, with the column move as a further step of `os migrate files-to-references --apply` after zero blocking findings; the dual-encoding window this implies, its invariant (column type and write encoding never disagree on one deployment), the three populations it must hold over, and its end (the first protocol major after the driver lands, with a loud boot refusal for un-moved deployments); the two confidence gaps carried from the measurement stated as gaps with what closes them; and the sequencing behind the driver card. Governed surface (docs/adr). No code, schema, generated artifact or changeset moves with this commit. Anchors are symbol / file anchors only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
1 parent f7db8f4 commit ba69672

1 file changed

Lines changed: 288 additions & 0 deletions

File tree

docs/adr/0104-field-runtime-value-shape-contract.md

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,3 +1020,291 @@ carries the aliased vocabulary's *coverage* across with its names.
10201020
`FileRef` scoped to `file`/`image`/`video`/`audio` would leave `avatar` on
10211021
the legacy inline shape, which is precisely the per-type carve-out that
10221022
addendum rejected.
1023+
1024+
## Addendum (2026-09-05) — the media family's physical column holds the bare `sys_file` id, switched per deployment on the `adr-0104-file-references` flag
1025+
1026+
**Provenance.** Maintainer ruling, decision batch #49 item 1, recorded on
1027+
#15041 (comment `5551135629`) by the director seat from the maintainer's live
1028+
reply, verbatim: 「15041 应该改为实际 id 保存。选A,其他同意」. The measurement it
1029+
ruled on is #15041's `os-dev-report` (comment `5550175673`) and its H1 table
1030+
(comment `5550175730`), taken on `origin/main` `8e500f23e` on 2026-09-05. This
1031+
addendum is step 1 of the ruling's execution order: it records the decisions
1032+
about the column and the switch; no code moves with it.
1033+
1034+
### The fork it closes
1035+
1036+
D1 handed the column to the driver in one clause — "DDL column choice remains
1037+
the driver's decision; the *classification* moves to the spec" — and D3 then
1038+
narrowed the media family's **stored value** to an opaque `sys_file` id. Every
1039+
declared D3 wave has landed (spec `17.0.0`); none narrowed the column, because
1040+
nothing had scheduled that. So two readings of "the value is an id" coexisted
1041+
on `main`, each internally consistent:
1042+
1043+
- **The driver: a JSON-quoted id in a JSON column.**
1044+
`packages/drivers/driver-sql/src/sql-driver.ts#JSON_COLUMN_TYPES` seeds the
1045+
family by spreading `FILE_REFERENCE_TYPES`, so
1046+
`packages/drivers/driver-sql/src/sql-driver.ts#isJsonField` answers true for
1047+
all five, `packages/drivers/driver-sql/src/sql-driver.ts#formatInput`
1048+
JSON-stringifies the id on every dialect (the #12380 canonical encoding), and
1049+
the column is `packages/drivers/driver-sql/src/sql-driver.ts#jsonColumn`
1050+
`json` on Postgres and MySQL, TEXT on SQLite. Measured on SQLite (two
1051+
in-memory cells, 2026-09-05): an id written through the driver lands on disk
1052+
as the quoted text `"file_01HXYZ"` and reads back as the bare string; an
1053+
inline object written through the driver reads back as an object; a raw
1054+
bare id inserted by hand makes the SQLite read arm's `JSON.parse` throw, the
1055+
catch keeps the raw string, and it reads back as `file_01HXYZ` — no fault.
1056+
The same four rows in a hand-created `VARCHAR(2048)` column read back
1057+
byte-identical, and `initObjects` over that column left it `VARCHAR(2048)`:
1058+
additive sync never alters a column's type. Postgres and MySQL were **not**
1059+
measured (no live cell in the container); reasoned from the source: the
1060+
write quotes identically, the read path has no parse arm because those
1061+
clients parse a native `json` column, so a varchar there returns the quoted
1062+
text verbatim, `isFileIdToken` refuses it, nothing expands it and nothing
1063+
claims ownership. The reachable harm of a hand-run generated migration on
1064+
those dialects is therefore write-side quoting into a varchar — silent — and
1065+
`packages/drivers/driver-sql/src/schema-drift.ts#declaresJsonColumn` keys
1066+
the json-vs-text finding on `multiple` alone, so the drift report never
1067+
names it (#15771).
1068+
- **The generator: a bare id in a string column.**
1069+
`packages/cli/src/commands/generate.ts#FIELD_TYPE_SQL_MAP` gives all five
1070+
members `VARCHAR(2048)`; the TypeScript form emits `table.string(...)` from
1071+
one switch arm — #14657's reading of the spec contract as DDL. The pin
1072+
`packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts`
1073+
records the disagreement under a block labelled "Recorded divergence, NOT
1074+
coverage", so it cannot change shape unnoticed and no reader mistakes it
1075+
for a ruling.
1076+
1077+
The spec seat recommended that the generator copy the driver (B), on the
1078+
ground that the value contract and the physical column are different things
1079+
and the column the driver creates is the one every SQL deployment has. The
1080+
maintainer overruled that on exactly the point the seat had flagged as the
1081+
maintainer's to decide: **the physical column follows the contract.** The
1082+
ruling as the director seat recorded it: "Option A. The physical column for
1083+
`file` / `image` / `avatar` / `video` / `audio` holds the actual id — a bare
1084+
`sys_file` id string in a string column — not a JSON-quoted id in a JSON
1085+
column. The driver is the side that moves; the generator's `VARCHAR(2048)`
1086+
already states the ruled end-state and stands." B and C (status quo) are
1087+
rejected: the id is the value, and the column should say so.
1088+
1089+
### The column: a string column holding the bare id
1090+
1091+
For every member of `FILE_REFERENCE_TYPES` (`image`, `file`, `avatar`,
1092+
`video`, `audio`), the single-value physical column is a **string column
1093+
holding the bare `sys_file` id** — the value `FileReferenceIdValueSchema`
1094+
admits, stored as itself. The generator's `VARCHAR(2048)` / `table.string`
1095+
is the ruled end-state and stands; the driver moves to it: the family leaves
1096+
`JSON_COLUMN_TYPES`, and `isJsonField` / `formatInput` /
1097+
`packages/drivers/driver-sql/src/sql-driver.ts#formatOutput` stop treating it
1098+
as JSON. The driver's width mirror
1099+
(`packages/drivers/driver-sql/src/sql-driver.ts#varcharColumnChars`) answers
1100+
for the family what the generator answers, so the pin that sweeps the other
1101+
string classes can sweep this one — a width the two sides disagree on would be
1102+
this fork again, one level down.
1103+
1104+
What this does *not* touch: `multiple: true` media fields keep the JSON array
1105+
column every multi-value field has (the `multiple` half of `isJsonField`); the
1106+
family loses only its own membership. `STRUCTURED_JSON_TYPES` and
1107+
`MULTI_OPTION_TYPES` stay JSON columns. The stored **value** contract does not
1108+
move — `valueSchemaFor(def, 'stored')` already returns
1109+
`FileReferenceIdValueSchema` for the family; this addendum aligns the column
1110+
with the contract, not the contract with the column. Everything above the
1111+
driver (the validator, the read resolver, the ownership hooks, `objectui`)
1112+
sees the same bare id it sees today: the wire shape is unchanged.
1113+
1114+
This narrows D1's "DDL column choice remains the driver's decision" for one
1115+
class: the driver still owns DDL for every other class, and for this one it
1116+
implements a ruled column. It is the last step of D3's "same shape discipline
1117+
as `lookup`" — a reference column holds the target's id as a string, and now
1118+
the media family's column does too.
1119+
1120+
### The switch: per deployment, on the existing flag, never per version
1121+
1122+
The key is the deployment-level `sys_migration` row
1123+
`packages/spec/src/system/migration.zod.ts#FILE_REFERENCES_MIGRATION_ID`
1124+
`adr-0104-file-references` — and nothing else. It is written by
1125+
`os migrate files-to-references --apply`
1126+
(`packages/cli/src/commands/migrate/files-to-references.ts#MigrateFilesToReferences`
1127+
running
1128+
`packages/services/service-storage/src/files-to-references-migration.ts#runFilesToReferencesMigration`)
1129+
only when backfill and reconciliation report zero blocking discrepancies —
1130+
`sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`
1131+
or at creation for a datastore born empty
1132+
(`packages/platform-objects/src/system/migration-flag.ts#attestFreshDatastore`).
1133+
The engine already reads it, memoized, to open strict media value-shape
1134+
enforcement and released-file collection
1135+
(`packages/objectql/src/engine.ts#isFileReferencesMigrationVerified`). The
1136+
column encoding now reads the same row. One flag, not a second gate that can
1137+
disagree (the 2026-07-27 principle) — and never a version number: installing a
1138+
release changes no deployment's column, exactly as it starts no deletion.
1139+
1140+
The same flag is the right key because the fact it attests is precisely the
1141+
precondition under which the column move is lossless. A JSON column whose every
1142+
cell is a JSON *string* rewrites to a string column with nothing lost; a cell
1143+
still holding an inline object cannot be rewritten, and such a cell is exactly
1144+
a finding the reconciliation refuses to record the flag over. So the column
1145+
move is a further step of `--apply`, after the reconciliation and before the
1146+
row is recorded, and it aborts on any blocking finding — the R4 shape:
1147+
1148+
```
1149+
os migrate files-to-references --apply
1150+
1. backfill (dry run by default; --apply writes)
1151+
2. verifyFileReferences (reconcile the ledger against what records hold)
1152+
3. zero blocking findings → move this datastore's media columns:
1153+
unquote every cell, retype the column to the
1154+
string column — per dialect, transactional
1155+
where the dialect allows it, aborting on the
1156+
first cell that is not a JSON string
1157+
4. record sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }
1158+
5. the driver's encoding, strict enforcement and collection read THAT ROW
1159+
```
1160+
1161+
The per-dialect sketch, carried from the measurement and **unrehearsed** (see
1162+
the gaps below): SQLite rewrites each cell in place with `json_extract` where
1163+
`json_type` is `'text'` (the column keeps TEXT affinity; only the encoding
1164+
changes); Postgres retypes the column to a varchar with a `USING` clause that
1165+
unquotes the JSON string; MySQL retypes with `MODIFY COLUMN` and unquotes with
1166+
`JSON_UNQUOTE` — the order of those two is one of the things the rehearsal
1167+
settles. A dry run prints the statements it would execute and writes nothing,
1168+
as #3617 already requires of every mode but `--apply`.
1169+
1170+
### The window: two encodings, one invariant, one end
1171+
1172+
Two encodings will exist across deployments at once, and the driver carries
1173+
both arms until the window closes:
1174+
1175+
- A deployment **without** the flag keeps today's encoding — a JSON column
1176+
(`json` on Postgres and MySQL, TEXT on SQLite) holding the JSON-quoted id
1177+
and, on the default warn-first posture, possibly an inline object the
1178+
backfill has not yet converted. The driver writes and reads it exactly as
1179+
it does today.
1180+
- A deployment **with** the flag, after step 3 above, holds the bare id in a
1181+
string column; the driver writes the bare id and reads it with no JSON
1182+
codec.
1183+
- Throughout the window the driver **reads both encodings on every dialect**:
1184+
a JSON-quoted string in a media column decodes to the bare id, a bare id is
1185+
returned as it is, an inline object still decodes to the object (the
1186+
dual-read of §D3, unchanged). SQLite's parse-and-catch read arm already
1187+
behaves this way; Postgres and MySQL return a native `json` column parsed
1188+
and a varchar verbatim, so the decode there keys on what arrives, not on
1189+
the dialect.
1190+
1191+
**The invariant the window rests on: on one deployment, the media column's
1192+
type and the driver's write encoding never disagree.** A quoted write into a
1193+
string column is #15771's silent corruption on Postgres and MySQL; a bare
1194+
write into a native `json` column is a loud `22P02` on Postgres. They are one
1195+
mistake — the key and the column drifting apart — and the driver card's pins,
1196+
per dialect and for both encodings, exist to make that mistake unreachable.
1197+
Three populations the invariant must hold over (the implementing card's
1198+
checklist; the mechanism is that card's decision and is stated in its PR):
1199+
1200+
1. **Un-flagged deployments** upgrading to a driver that carries the change:
1201+
nothing moves until they run `--apply`. A media column that additive sync
1202+
creates on such a deployment during the window takes today's JSON form,
1203+
because that deployment's encoding is still JSON.
1204+
2. **Deployments flagged before step 3 existed** — every datastore
1205+
creation-attested since 17.0, the dogfood boots among them, and every
1206+
`--apply` run before the driver card lands. They hold the flag *and*
1207+
JSON-quoted ids in a JSON column. The flag row alone cannot tell such a
1208+
deployment from one whose columns have moved, so the driver may not open
1209+
the bare-id write arm on the flag alone: step 3 must run on them
1210+
(re-running `--apply` is idempotent and re-records the row), and until it
1211+
has, the deployment is on the JSON arm whatever the row says. Whether the
1212+
step's completion is recorded on the same row or observed from the column
1213+
itself is the driver card's to settle — it must be one answer, pinned, and
1214+
it must fail toward the JSON arm.
1215+
3. **Datastores born after the driver card lands** are attested at
1216+
`kernel:ready`, after schema sync has already created their columns. A
1217+
store the driver is itself creating from empty carries no legacy encoding,
1218+
so nothing stops its media columns taking the ruled form from birth — but
1219+
the seam that makes column and encoding agree across the gap between
1220+
creation and attestation is the driver card's to choose. The born-strict
1221+
dogfood boots are the standing canary for this population, as they are
1222+
for R2.
1223+
1224+
**Where the window ends.** "Every deployment has moved" is not a fact anyone
1225+
can observe — the same reason the evidence gate went per deployment — so the
1226+
end is located where the code is and made safe where the data is. The JSON
1227+
arm leaves in the **first protocol major after the driver card lands**:
1228+
removing a storage encoding the driver reads is a breaking change and rides a
1229+
major under ADR-0087, with its disposition recorded. From that major the
1230+
driver has one encoding, and a deployment that reaches it without its own
1231+
flag-and-column step is **refused loudly at boot for its media fields**,
1232+
naming `os migrate files-to-references --apply` — never read through a codec
1233+
the driver no longer has, never silently corrupted. This is not the
1234+
per-version switch the previous section forbids: the switch of a deployment's
1235+
encoding stays that deployment's own `--apply`; the major only deletes the
1236+
fallback and makes the missing step loud, and installing it converts nothing
1237+
and deletes nothing ("installing a new version is not consent", 2026-07-27).
1238+
One major and no longer: a standing two-arm codec is the staged transition
1239+
the 2026-08-27 ruling refuses.
1240+
1241+
### What does not change
1242+
1243+
- **`syncSchema` / `initObjects`** stay additive and never alter an existing
1244+
column's type — measured above — so the column move is step 3's alone,
1245+
never an upgrade's side effect. During the window, sync creates a new media
1246+
column in the form of the arm its deployment is on (the invariant); it
1247+
does not consult the flag to retype anything.
1248+
- **The generator** changes nothing: `FIELD_TYPE_SQL_MAP`'s `VARCHAR(2048)`
1249+
and the TypeScript `table.string` for all five members are the end-state.
1250+
Its pin block labelled "Recorded divergence, NOT coverage" stays as written
1251+
until the driver card's PR lands and retires it to coverage in the same
1252+
change — the divergence is true on every deployment until the driver
1253+
moves, and the triage on #15041 asked for that retirement to be deliberate.
1254+
- **The evidence table** of the 2026-07-27 amendment gains no row: the media
1255+
line's evidence is still the `adr-0104-file-references` flag; the fact it
1256+
attests widens to include the column.
1257+
- **Consumers above the driver** and the `objectui` wire shape: unchanged, as
1258+
the column section says.
1259+
1260+
### Confidence gaps — stated, not assumed
1261+
1262+
1. **Postgres and MySQL are reasoned, not measured.** Only SQLite was measured
1263+
(two in-memory cells, four rows each, the driver's own TEXT column against
1264+
a hand-created `VARCHAR(2048)` column, byte-identical reads). The
1265+
Postgres/MySQL statements above — identical write-side quoting, no read
1266+
parse arm, a varchar returning quoted text verbatim, `22P02` on a bare
1267+
write into `json` — are read from the driver source. What closes it: the
1268+
driver card's per-dialect pins for both encodings run against the live
1269+
services of the `Temporal Conformance (live PG + MySQL)` job
1270+
(`postgres:16`, `mysql:8.0`), replacing this paragraph's reasoning with a
1271+
measurement before `--apply` gains step 3.
1272+
2. **The migration sketch is unrehearsed.** No datastore, copy or fixture has
1273+
had the unquote-and-retype step run against it; the per-dialect statement
1274+
order is unsettled (MySQL in particular: a `JSON` column refuses a bare
1275+
`file_x` as invalid JSON, so the retype must precede the unquote or the two
1276+
must be one statement). What closes it: a rehearsal on a copy of a real
1277+
datastore per dialect — the showcase dogfood store is the in-repo
1278+
candidate — with the dry run printing every statement it would execute and
1279+
the apply run proving every media cell reads back equal before and after.
1280+
1281+
Until both close, the window section's Postgres/MySQL descriptions are the
1282+
ruling's intent, not a measurement, and the driver card carries that caveat
1283+
into its PR body.
1284+
1285+
### Sequencing
1286+
1287+
1. **This addendum**#15041 is its carrier; governed `docs/adr/**`, draft
1288+
PR, human merge.
1289+
2. **The driver card, #15989** (`domain:engine`, `pm:blocked` on this
1290+
addendum) implements the three sections above: the family leaves
1291+
`JSON_COLUMN_TYPES`; `isJsonField` / `formatInput` / `formatOutput` stop
1292+
treating it as JSON, keyed on the flag through the window; step 3 in
1293+
`--apply`; the `varcharColumnChars` mirror; schema-drift's single-value
1294+
JSON-class finding (#15771 folded in or left adjacent — the driver card
1295+
decides); pins per dialect for both encodings across the window; a
1296+
changeset with its ADR-0087 disposition.
1297+
3. **The generator pin block** retires to coverage only in the driver card's
1298+
PR. Adjacent and unblocked: #15769 (stale class-doc prose over
1299+
`FILE_REFERENCE_TYPES`) and objectui#7699 (the legacy blob still submitted
1300+
when no `fileId` surfaced) proceed on their own.
1301+
1302+
### Why this stays inside ADR-0104
1303+
1304+
For the reason the 2026-07-27 addenda gave: it settles *how* D3's accepted
1305+
"field values point into `sys_file`" reaches the physical column, and amends
1306+
one D1 clause for one class rather than revisiting a decision. It is recorded
1307+
here so that a grep for `JSON_COLUMN_TYPES`, `VARCHAR(2048)` or "bare id"
1308+
lands on the decision (Prime Directive #13), and so that the driver card
1309+
implements a ruling rather than a changeset that quietly does the opposite of
1310+
D1's clause.

0 commit comments

Comments
 (0)