Skip to content

Commit dafa3ca

Browse files
committed
feat(objectql)!: media value shapes enforce per deployment, once its file migration verified (#3438)
#3438 scheduled the strict flip for "a later minor once telemetry is quiet" — our telemetry, deciding for their deployments. #3617 built the mechanism that replaces that: a deployment's own migration flag. This lands the half that flag actually covers. A malformed media value now rejects with `invalid_type` instead of warning, but only where `os migrate files-to-references --apply` has run and passed its self-check. The legacy forms this rejects — inline `{url, name, …}` blobs, bare URLs — are exactly what that migration converts, so a verified deployment has been shown to hold none, while an unmigrated one is untouched rather than having every media update start failing on upgrade. ## Only media, deliberately #3438 packaged three things as one flip, and the flag covers one of them. It asserts "this deployment's FILE values are migrated and reconciled". It says nothing about whether a `lookup` id or a `location` payload is well formed, and nothing at all about D2's action parameters. Gating those on it would be borrowing evidence for a fact it does not cover — an authority answering a question it was not asked, which is the same shape as the read-resolver defect #3617 fixed one layer down. Reference and structured-JSON classes keep their own warn-first rollout until something can vouch for them. `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` is unchanged (still opts every class in, still forces media strict on an unmigrated deployment). New escape hatch `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens media on a verified deployment; when both are set the lenient one wins, because a warning nobody reads costs less than an app that stops writing. ## Cost The fact lives in the database while the validator is synchronous and per-write, so the engine reads it once and memoizes — and stays dormant unless the written object declares a media field, mirroring the rule the storage module already states. One query per process for apps that store files, zero for those that do not. Every way of not knowing (no storage service, no row, unreadable table, malformed row) answers "not verified": a deployment whose evidence cannot be read keeps writing rather than starting to reject. Verified end-to-end on showcase + SQLite against one database: the same write was accepted before the migration, rejected with `invalid_type` after it, and accepted again under OS_ALLOW_LAX_MEDIA_VALUES. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9uWWyKq6pNPQthwCXL8ma
1 parent 43754d7 commit dafa3ca

8 files changed

Lines changed: 504 additions & 23 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
feat(objectql)!: media value shapes enforce once THIS deployment has verified its file migration (#3438 D1 media half, gated by #3617)
6+
7+
A `file` / `image` / `avatar` / `video` / `audio` value that does not match the
8+
stored contract (an opaque `sys_file` id) now **rejects with `invalid_type`**
9+
instead of warning — but only on a deployment that has run
10+
`os migrate files-to-references --apply` and passed its self-check.
11+
12+
**Why this is not a version-wide flip.** The legacy media values this rejects —
13+
inline `{url, name, …}` blobs, bare URLs — are exactly what that migration
14+
converts. A deployment that has run it has been *shown* to hold none; a
15+
deployment that has not would have every media-field update start failing the
16+
moment it upgraded. So the enforcement follows the evidence, per deployment,
17+
rather than the release. Nothing changes for a deployment until it migrates.
18+
19+
**Upgrading:**
20+
21+
```bash
22+
os migrate files-to-references # dry run: reports what would convert
23+
os migrate files-to-references --apply # convert, verify, record the flag
24+
```
25+
26+
If a write starts failing after you migrate, the value genuinely does not match
27+
the contract — the error names the field. `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens
28+
media leniency while you diagnose.
29+
30+
**Scope — deliberately only media.** `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` is
31+
unchanged and still opts every class into strict (and still forces media strict
32+
on a deployment that has not migrated). Reference types (`lookup`, `user`, …)
33+
and structured JSON (`location`, `address`, `repeater`, …) stay warn-first: the
34+
file migration is evidence about file values and says nothing about whether a
35+
`location` is well formed, so gating them on its flag would be borrowing
36+
evidence for a fact it does not cover. They flip when something can vouch for
37+
them — see #3438.
38+
39+
**Cost.** Dormant unless the written object declares a media field, and the
40+
flag read is memoized, so this is one query per process for apps that store
41+
files and zero for those that do not. A running server picks up a
42+
newly-recorded migration on restart, or via `engine.invalidateDataMigrationFlags()`.

content/docs/deployment/cli.mdx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -545,18 +545,31 @@ field instead.
545545

546546
The run then reconciles what records actually hold against what `sys_file`
547547
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.
548+
flag — and that flag, not the version number, is what enables behaviour that
549+
depends on the data actually being migrated. Never running it is safe: files
550+
are simply retained forever, and media values keep warning instead of failing.
552551

553552
Exit status is `0` only when the self-check passes, so CI can gate on it.
554553

554+
#### What the flag turns on
555+
556+
| Once verified | Effect |
557+
| :--- | :--- |
558+
| **Media value shapes** | A malformed `file` / `image` / `avatar` / `video` / `audio` value is **rejected** (`400 invalid_type`) instead of warned about. Set `OS_ALLOW_LAX_MEDIA_VALUES=1` to re-open leniency while diagnosing. |
559+
| **Released-file collection** | Not yet shipped — the flag is the gate it will read. |
560+
561+
Other value classes are unaffected: a `lookup` or `location` value keeps its own
562+
warn-first rollout, because this migration is evidence about *file* values and
563+
says nothing about theirs.
564+
555565
<Callout type="warn">
556566
A dry run writes **nothing** — not the conversions, and not the flag either,
557567
even when the self-check would pass. `--apply` is the only writing mode. A
558568
later run that *fails* its self-check clears the flag's verified state, so a
559569
database that has drifted closes its own gate.
570+
571+
A running server reads the flag once; after migrating, **restart it** for
572+
enforcement to take effect.
560573
</Callout>
561574

562575
### Scaffolding

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,12 @@ Therefore the target end-state is **two enforcement points, each with one job**:
419419
- **Build/validate time → hard reject.** Net-new metadata (the AI's output)
420420
must fail loudly at authoring. This is the primary defence against AI error.
421421
- **Runtime → warn-first, then flip.** Deployed data keeps working through the
422-
warn window; the flip to strict-by-default (tracked in #3438) closes it once
423-
telemetry is quiet.
422+
warn window; the flip to strict-by-default (tracked in #3438) closes it.
423+
*(Amended by the second 2026-07-27 addendum: "once telemetry is quiet" was
424+
our telemetry deciding for their deployments. The **media** half now flips
425+
per deployment, when that deployment's own file-as-reference migration has
426+
verified. The reference and structured-JSON halves still await an evidence
427+
source of their own — the file migration does not vouch for them.)*
424428

425429
This refines D2 (today runtime-only) and reshapes the #3438 flip: the priority
426430
is adding the **build-time** rejection for value shapes and action params, not
@@ -670,6 +674,28 @@ a deployment when that deployment has completed and verified its migration —
670674
not when a version number arrives. One flag, not two gates that can disagree:
671675
they are gating on the same fact.
672676

677+
**Amendment while implementing this (2026-07-27, later still).** "#3438 reads
678+
the flag" was too broad: #3438 is three things, and the flag only covers one.
679+
The flag asserts *this deployment's file-field values have been migrated and
680+
reconciled*. That is evidence about **media** value shapes and nothing else —
681+
it says nothing about whether a `lookup` id or a `location` payload is well
682+
formed, and nothing at all about D2's action parameters. Gating those on it
683+
would be borrowing evidence for a fact it does not cover, which is the same
684+
error one layer down: an authority answering a question it was not asked.
685+
686+
So the split is:
687+
688+
| | evidence | status |
689+
|---|---|---|
690+
| D1 media (`file`/`image`/`avatar`/`video`/`audio`) | the `adr-0104-file-references` flag | flips per deployment |
691+
| D1 references + structured JSON | none yet — needs its own | stays warn-first |
692+
| D2 action params | unrelated to any data migration | stays warn-first |
693+
694+
The last two are not blocked on the mechanism — it exists and works. They are
695+
blocked on someone deciding what would constitute evidence that a deployment's
696+
`location` values are sound, which is a different question from the one this
697+
addendum answers.
698+
673699
### What cannot be automated, and does not block
674700

675701
Whether an **external URL** (`https://cdn…`) should become an explicit `url`

packages/cli/src/commands/migrate/files-to-references.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ export default class MigrateFilesToReferences extends Command {
253253
if (apply) {
254254
printSuccess(
255255
'Self-check passed — deployment flag recorded (adr-0104-file-references). ' +
256-
'This deployment is now eligible for strict media enforcement and, once shipped, file collection.',
256+
'Media value shapes are now ENFORCED on this deployment: a malformed ' +
257+
'file/image value is rejected rather than warned about. ' +
258+
'(Set OS_ALLOW_LAX_MEDIA_VALUES=1 to re-open leniency while diagnosing.)',
257259
);
258260
} else if (result.backfill.converted > 0) {
259261
printInfo(

packages/objectql/src/engine.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,3 +2075,132 @@ describe('ObjectQL Engine', () => {
20752075
});
20762076
});
20772077
});
2078+
2079+
/**
2080+
* #3617 / #3438 — the engine carries the deployment's own migration evidence
2081+
* into the (synchronous, per-write) value-shape validator.
2082+
*
2083+
* The fact lives in `sys_migration`, so it needs a database read; the write
2084+
* path needs it on every insert/update. These prove the read happens once,
2085+
* and that every way of NOT knowing lands on lenient — a deployment whose
2086+
* evidence cannot be read keeps writing rather than starting to reject.
2087+
*/
2088+
describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
2089+
let engine: ObjectQL;
2090+
let driver: IDataDriver;
2091+
2092+
const verifiedRow = {
2093+
id: 'adr-0104-file-references',
2094+
last_run_at: '2026-07-27T00:00:00.000Z',
2095+
verified_at: '2026-07-27T00:00:00.000Z',
2096+
blocking: 0,
2097+
};
2098+
2099+
beforeEach(() => {
2100+
vi.clearAllMocks();
2101+
driver = {
2102+
name: 'default-driver',
2103+
connect: vi.fn().mockResolvedValue(undefined),
2104+
disconnect: vi.fn().mockResolvedValue(undefined),
2105+
find: vi.fn().mockResolvedValue([]),
2106+
findOne: vi.fn(),
2107+
create: vi.fn().mockResolvedValue({ id: '1' }),
2108+
update: vi.fn().mockResolvedValue({ id: '1' }),
2109+
delete: vi.fn(),
2110+
count: vi.fn(),
2111+
capabilities: {} as any,
2112+
} as unknown as IDataDriver;
2113+
engine = new ObjectQL();
2114+
engine.registerDriver(driver, true);
2115+
});
2116+
2117+
/** `doc` holds a legacy inline blob — exactly what the migration converts. */
2118+
const withMediaObject = () => {
2119+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name: string) => {
2120+
if (name === 'note') return { name: 'note', fields: { doc: { type: 'file' } } } as any;
2121+
if (name === 'sys_migration') return { name: 'sys_migration', fields: { id: { type: 'text' } } } as any;
2122+
return undefined;
2123+
});
2124+
};
2125+
const legacyBlob = { doc: { url: 'https://cdn/f.pdf', name: 'f.pdf' } };
2126+
2127+
it('a verified flag makes a malformed media value reject', async () => {
2128+
withMediaObject();
2129+
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2130+
2131+
await expect(engine.insert('note', { ...legacyBlob })).rejects.toThrow(/invalid file value/i);
2132+
});
2133+
2134+
it('no flag row → lenient (the write goes through)', async () => {
2135+
withMediaObject();
2136+
vi.mocked(driver.find).mockResolvedValue([]);
2137+
2138+
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
2139+
});
2140+
2141+
it('a failed run (verified_at cleared) → lenient', async () => {
2142+
withMediaObject();
2143+
vi.mocked(driver.find).mockResolvedValue([{ ...verifiedRow, verified_at: null, blocking: 3 }] as any);
2144+
2145+
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
2146+
});
2147+
2148+
it('an unreadable sys_migration → lenient, and the write is not failed by the probe', async () => {
2149+
withMediaObject();
2150+
vi.mocked(driver.find).mockRejectedValue(new Error('no such table: sys_migration'));
2151+
2152+
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
2153+
});
2154+
2155+
/**
2156+
* Dormancy, mirroring the storage module's own rule: an object with no
2157+
* file-class field can hold no malformed media value, so it must not pay
2158+
* even one query to learn that. Nearly every object is in this case.
2159+
*/
2160+
it('costs no query for an object that declares no media field', async () => {
2161+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name: string) => {
2162+
if (name === 'invoice') return { name: 'invoice', fields: { amount: { type: 'number' } } } as any;
2163+
if (name === 'sys_migration') return { name: 'sys_migration', fields: { id: { type: 'text' } } } as any;
2164+
return undefined;
2165+
});
2166+
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2167+
2168+
await expect(engine.insert('invoice', { amount: 10 })).resolves.toBeDefined();
2169+
expect(vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration')).toHaveLength(0);
2170+
});
2171+
2172+
/** No storage service → no sys_migration object → not even a query. */
2173+
it('costs no query on a kernel without the storage objects', async () => {
2174+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name: string) =>
2175+
name === 'note' ? ({ name: 'note', fields: { doc: { type: 'file' } } } as any) : undefined,
2176+
);
2177+
vi.mocked(driver.find).mockResolvedValue([]);
2178+
2179+
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
2180+
expect(driver.find).not.toHaveBeenCalled();
2181+
});
2182+
2183+
it('reads the flag once per process, not once per write', async () => {
2184+
withMediaObject();
2185+
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2186+
2187+
await expect(engine.insert('note', { doc: 'file_01' })).resolves.toBeDefined();
2188+
await expect(engine.insert('note', { doc: 'file_02' })).resolves.toBeDefined();
2189+
await expect(engine.insert('note', { doc: 'file_03' })).resolves.toBeDefined();
2190+
2191+
const flagReads = vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration');
2192+
expect(flagReads).toHaveLength(1);
2193+
});
2194+
2195+
it('invalidateDataMigrationFlags forces a re-read', async () => {
2196+
withMediaObject();
2197+
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2198+
2199+
await engine.insert('note', { doc: 'file_01' });
2200+
engine.invalidateDataMigrationFlags();
2201+
await engine.insert('note', { doc: 'file_02' });
2202+
2203+
const flagReads = vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration');
2204+
expect(flagReads).toHaveLength(2);
2205+
});
2206+
});

0 commit comments

Comments
 (0)