|
9 | 9 | **errata only** — it registers the outside vocabulary (`fieldRuntimeType`, |
10 | 10 | `FileRef`, `RecordRef`, "read-time expansion") against this ADR's names so a |
11 | 11 | 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. |
13 | 16 | - **Date**: 2026-07-22 |
14 | 17 | - **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param |
15 | 18 | silently stripped); relates #3407 (silently dropped writes), #1878 / #1891 |
@@ -1020,3 +1023,291 @@ carries the aliased vocabulary's *coverage* across with its names. |
1020 | 1023 | `FileRef` scoped to `file`/`image`/`video`/`audio` would leave `avatar` on |
1021 | 1024 | the legacy inline shape, which is precisely the per-type carve-out that |
1022 | 1025 | 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