Skip to content

Commit 932acc3

Browse files
claude[bot]claude
andauthored
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 (#15041) (#16014)
* 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 * docs(adr): ADR-0104 Status bullet indexes the 2026-09-05 addendum One sentence in the ADR's running index of addenda, in the spelling the existing entries use: the 2026-09-05 addendum rules the media family's physical column — a string column holding the bare sys_file id, switched per deployment on the adr-0104-file-references flag, never per version; driver card #15989 implements it. Seat ruling on the report's open question 1 (B). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent efc5447 commit 932acc3

1 file changed

Lines changed: 292 additions & 1 deletion

File tree

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

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

0 commit comments

Comments
 (0)