Skip to content

Commit 5568026

Browse files
committed
feat(migrate): gate the file-as-reference migration per deployment, not per release (#3617)
The ADR-0104 D3 wave 2 acceptance gate was written as an operations runbook — run the backfill on the tenant, watch `verifyFileReferences` stay clean for seven days, then flip the switch. Every clause assumes we run the tenant, we observe it, and we flip. ObjectStack is a development platform. Third-party deployments upgrade on their own schedule and their data is not visible to us, so there is no observation window of ours that anyone else's data can wait inside. A gate phrased as one is not strict, merely unlocatable. R4's substance is unchanged — a ledger may not be given the power to delete bytes until it has been shown to agree with reality. What changes is who produces the evidence: each deployment, for itself. os migrate files-to-references # dry run: reports, writes nothing os migrate files-to-references --apply # converts, verifies, records the flag An apply run whose reconciliation reports zero blocking discrepancies records `sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`. That row — never the platform version — is what may later open released-file collection (#3459 PR-5b) and strict media value-shape enforcement (#3438); both read the same flag through one spec-level predicate so the two gates cannot disagree about the same fact. Not run, or not passed, leaves files retained forever: storage cost, zero data loss. A later failing run clears the verified state, so a database that has drifted closes its own gate. A dry run writes nothing at all — not the conversions, and not the flag either, even when the self-check would pass — so whether a run changed the deployment's posture never depends on what it happened to find. The command also refuses to run when no app metadata is loaded: an empty scan is indistinguishable from a clean one, and this verdict authorises irreversible behaviour. Also fixes a defect the end-to-end run surfaced: on a live kernel the engine's read resolver expanded stored ids to their `{ id, url, … }` form before the scan saw them, so the reconciliation reported every held reference as absent — noisy `stale_owner` findings, and a missed `unowned_reference` would have been a false pass of the very gate this work builds. Reads may now opt out via the spec's `RAW_FILE_VALUES_CONTEXT_KEY`; the storage service's scan and bookkeeping reads do. Verified end-to-end against the showcase app on SQLite: 10 legacy values converted and claimed, reconciliation clean, flag recorded; a dry run against the same database left both the data and the flag untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9uWWyKq6pNPQthwCXL8ma
1 parent 16adb3c commit 5568026

27 files changed

Lines changed: 1468 additions & 24 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/service-storage": minor
5+
"@objectstack/objectql": minor
6+
"@objectstack/cli": minor
7+
---
8+
9+
feat(migrate): `os migrate files-to-references` — a data migration with a self-check, gated per deployment (#3617)
10+
11+
The ADR-0104 file-as-reference migration ships as a command a deployment runs
12+
against its own database, and the deployment-level flag it records is what may
13+
later authorise irreversible behaviour — never the platform version.
14+
15+
```bash
16+
os migrate files-to-references # dry run: reports, writes nothing
17+
os migrate files-to-references --apply # converts, verifies, records the flag
18+
```
19+
20+
The run backfills legacy file-field values (inline metadata blobs, own-resolver
21+
URLs, `data:` URIs) into owned `sys_file` references, reconciles the ownership
22+
ledger against what records actually hold, and — only on an `--apply` run whose
23+
reconciliation reports **zero blocking discrepancies** — records
24+
`sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`.
25+
26+
**Why a flag rather than a release note.** ObjectStack is a development
27+
platform: third-party deployments upgrade on their own schedule and their data
28+
is not observable by anyone else, so no release-side soak can vouch for them.
29+
The evidence has to be produced where the data is. Consequences:
30+
31+
- Installing a new version never starts deleting bytes. Running the migration
32+
and passing its self-check is the consent.
33+
- Not run, or not passed → files are retained forever. Wasted storage, zero
34+
data loss.
35+
- A later failing run **clears** `verified_at`: a deployment whose data has
36+
drifted closes its own gate.
37+
- A dry run writes nothing at all — not the conversions, and not the flag,
38+
even when the self-check would pass.
39+
- External URLs stay advisory. They are not `sys_file`s, so they can never
40+
enter collection; whether to remodel them as a `url` field is the app
41+
author's decision (ADR-0104 R7), not a gate.
42+
43+
Ships alongside:
44+
45+
- `@objectstack/spec``DataMigrationFlagSchema`, `FILE_REFERENCES_MIGRATION_ID`,
46+
and the single `isDataMigrationFlagVerified` predicate both future consumers
47+
(collection #3459, strict value-shape #3438) read, so the two gates cannot
48+
disagree about the same fact.
49+
- `@objectstack/platform-objects` — the `sys_migration` object plus
50+
`readDataMigrationFlag` / `isDataMigrationVerified` / `recordDataMigrationRun`.
51+
Reads fail toward "not verified": a gate that cannot read its evidence stays
52+
closed.
53+
- `@objectstack/objectql` — a read may now opt out of file-reference expansion
54+
via the spec's `RAW_FILE_VALUES_CONTEXT_KEY`, and the storage service's
55+
bookkeeping/scan reads do. Without it the read resolver rewrites stored ids to
56+
their expanded form before the reconciliation sees them, which reports held
57+
references as absent — noisy `stale_owner` findings, and a missed
58+
`unowned_reference` would have been a false pass of the collection gate.

content/docs/deployment/cli.mdx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,48 @@ first. It never drops a table that is absent from your metadata, and on SQLite
517517
it reconciles via a table rebuild (copy → swap) that preserves your data.
518518
</Callout>
519519

520+
#### Data migrations
521+
522+
The commands above reconcile **schema**. A *data* migration rewrites rows, and
523+
whether it is done is a fact about **your** database, not about the platform
524+
version you installed — so each deployment runs it, and its result is recorded
525+
where the data lives.
526+
527+
| Command | Description |
528+
|---------|-------------|
529+
| `os migrate files-to-references` | Convert legacy file-field values to `sys_file` references, verify the ownership ledger, and record the deployment's migration flag |
530+
531+
```bash
532+
os migrate files-to-references # Dry run: full report, writes nothing
533+
os migrate files-to-references --apply # Convert, verify, record the flag (prompts)
534+
os migrate files-to-references --apply --yes --json # CI / scripts
535+
os migrate files-to-references --object product # Restrict to one object (repeatable)
536+
```
537+
538+
A `file` / `image` / `avatar` / `video` / `audio` field value is an opaque
539+
`sys_file` id that the platform owns. Values written before that (an inline
540+
`{url, name, …}` blob, or a URL naming this platform's own
541+
`…/storage/files/:id` resolver) are converted in place; **external URLs are
542+
reported, never re-hosted** — re-hosting third-party content is a licensing and
543+
privacy decision, and the right fix is usually to model the field as a `url`
544+
field instead.
545+
546+
The run then reconciles what records actually hold against what `sys_file`
547+
records as each file's owner. Zero blocking discrepancies is what records the
548+
flag — and that flag, not the version number, is what later enables features
549+
that act irreversibly on migrated data (reclaiming the bytes of released files;
550+
rejecting rather than warning about legacy media values). Never running it is
551+
safe: files are simply retained forever.
552+
553+
Exit status is `0` only when the self-check passes, so CI can gate on it.
554+
555+
<Callout type="warn">
556+
A dry run writes **nothing** — not the conversions, and not the flag either,
557+
even when the self-check would pass. `--apply` is the only writing mode. A
558+
later run that *fails* its self-check clears the flag's verified state, so a
559+
database that has drifted closes its own gate.
560+
</Callout>
561+
520562
### Scaffolding
521563

522564
| Command | Alias | Description |

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

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -591,10 +591,13 @@ legacy values would reject any update that rewrites such a field, breaking
591591
working apps until the backfill catches up. Backfilling first leaves the cutover
592592
nothing legacy to reject.
593593

594-
R4's acceptance gate is now executable rather than aspirational: step 6 may not
595-
merge until step 5 reports **zero blocking discrepancies for ≥7 consecutive
596-
days** on real tenant data. R5 (public-posture inventory) and R6 (sub-key read
597-
scan) stand.
594+
R4's acceptance gate is now executable rather than aspirational: step 5's
595+
reconciliation must report **zero blocking discrepancies** before step 6 may
596+
act. (The original wording — "for ≥7 consecutive days on real tenant data" —
597+
assumed a tenant we operate and observe; see the 2026-07-27 platform-form
598+
addendum below, which relocates the same evidence requirement to the one place
599+
that can satisfy it.) R5 (public-posture inventory) and R6 (sub-key read scan)
600+
stand.
598601

599602
Steps 4 and 6 are the two that can break something. Step 4 is a declared
600603
protocol major and breaks *loudly* — a rejected write, recoverable. Step 6
@@ -604,15 +607,86 @@ adoption that consumes the new form, while step 6 additionally requires the
604607
reconciliation evidence above, and neither the enable nor the migration run
605608
should be performed without an explicit human decision.
606609

610+
## Addendum (2026-07-27, later) — the evidence gate is per deployment, not per release
611+
612+
The gate above was written as an operations runbook: *run the backfill on the
613+
tenant, watch `verifyFileReferences` stay clean for a week, then flip the
614+
switch.* Every clause of that sentence assumes **we** run the tenant, **we**
615+
observe it, and **we** flip.
616+
617+
ObjectStack is a development platform. Third-party developers build metadata
618+
apps on it and ship versions onward; each deployment decides for itself when to
619+
upgrade, and its data is not visible to us — nor should it be. **There is no
620+
observation window of ours that anyone else's data can wait inside.** A gate
621+
phrased as one is not strict, merely unlocatable: it can be satisfied by no
622+
deployment at all, including the deployments whose bytes are at stake.
623+
624+
### What survives, and what moves
625+
626+
R4's substance is unchanged: **a ledger may not be given the power to delete
627+
bytes until it has been shown to agree with reality.** What changes is *who
628+
produces the evidence* — from us, once, to each deployment, for itself.
629+
630+
That rules out a one-shot script (a script whose output nobody must read is not
631+
a gate) and rules in a **data-migration step with a self-check, whose passing
632+
result is recorded on the deployment**:
633+
634+
```
635+
os migrate files-to-references
636+
1. backfill (dry run by default; --apply writes)
637+
2. verifyFileReferences (reconcile the ledger against what records hold)
638+
3. zero blocking findings → record sys_migration { id: 'adr-0104-file-references',
639+
verified_at, blocking: 0 }
640+
4. collection + strict enforcement read THAT ROW, never the version number
641+
```
642+
643+
### The properties this buys
644+
645+
- **Upgrading does not start deleting.** Installing a new version is not
646+
consent; running the migration and passing its self-check is.
647+
- **A third-party developer need not understand R4.** They run one command.
648+
Green means done; red names the record holding a file nobody owns.
649+
- **Not run, or not passed → files live forever.** Wasted storage, zero data
650+
loss: "fail toward retention" holds on the *deployment* axis too. The same
651+
rule applies to a regression — a later failing run clears `verified_at`, so a
652+
deployment whose data has drifted closes its own gate.
653+
- **Each deployment gets its own 30-day tombstone window.** Nobody waits inside
654+
anyone else's soak. The irreversible moment is day 30 after *that* deployment
655+
enabled collection, not release day.
656+
- **A dry run changes nothing** — not the data, and not the flag either, even
657+
when the self-check would pass. `--apply` is the only writing mode, so
658+
"did this run change my posture" never depends on what the run found.
659+
660+
### D1/D2 carry the identical error, and the identical fix
661+
662+
The strict-by-default flip for value-shape and action-param validation (#3438)
663+
was scheduled as "flip in a later minor once telemetry is quiet" — again
664+
*our* telemetry, deciding for *their* deployments. A deployment that has not
665+
finished backfilling would have every media-field update rejected the moment it
666+
upgraded: a working app broken by a decision made on its behalf.
667+
668+
Both therefore read the **same** flag. Strict enforcement becomes effective for
669+
a deployment when that deployment has completed and verified its migration —
670+
not when a version number arrives. One flag, not two gates that can disagree:
671+
they are gating on the same fact.
672+
673+
### What cannot be automated, and does not block
674+
675+
Whether an **external URL** (`https://cdn…`) should become an explicit `url`
676+
field is a modelling decision only the app's author can make (R7). It also
677+
cannot block: an external URL is not a `sys_file` and can never enter
678+
collection. It is reported as advisory, never as a gate failure.
679+
607680
### Why this stays inside ADR-0104 rather than a new ADR
608681

609682
D3 is already this ADR's third phase; the two-wave split and the
610683
enforcement-point principle are a **refinement of the D4 rollout**, not a new
611684
decision, so they live here. Wave 2's migration mechanics (dual-read window,
612685
`os migrate` backfill, the R4/R5/R6 gates) are specified in §D3 above and need
613-
no separate record. Wave 2's implementation did surface one genuinely new
614-
decision — the exclusive-ownership model — and it is recorded as the 2026-07-27
615-
addendum above rather than a separate ADR, because it settles *how* D3's
616-
already-accepted "field values point into `sys_file`" behaves rather than
617-
revisiting whether to do it. A future choice that stands on its own (say, a
618-
chunked-migration protocol, or byte-layer content dedup) would earn its own ADR.
686+
no separate record. Wave 2's implementation did surface two genuinely new
687+
decisions — the exclusive-ownership model, and relocating the evidence gate to
688+
the deployment — and both are recorded as 2026-07-27 addenda above rather than
689+
separate ADRs, because each settles *how* D3's already-accepted "field values
690+
point into `sys_file`" reaches production rather than revisiting whether to do
691+
it. A future choice that stands on its own (say, a chunked-migration protocol,
692+
or byte-layer content dedup) would earn its own ADR.

0 commit comments

Comments
 (0)