Skip to content

Commit 71f76e1

Browse files
os-zhuangclaude
andauthored
feat(spec): declared media value shape — ADR-0104 D3 wave 1 (#3443)
ADR-0104 addendum (2026-07-24): split D3 (file-as-reference) into two waves and record the enforcement-point principle (build-time hard reject for AI-authored metadata + runtime warn-first for deployed data). Makes explicit that D3 covers the whole FILE_REFERENCE_TYPES media class (file/image/avatar/video/audio), not just `file`. Wave 1 (this change, single-repo, no migration): spec now exports FileValueSchema — the declared inline media form ({url, name?, size?, mimeType?, alt?, duration?}, url required) — tightening D1's loose transitional union so a malformed media value is caught instead of waved through as an opaque payload; the id/url string form stays accepted for import compat. Enforcement rides D1's warn-first write path, so deployed records aren't stranded. Wave 2 (accept/maxSize, sys_file reference model, GC, governed download, protocol-major migration) is deliberately gated — not in this PR. spec suite 6850 green (incl. new media-class cases); api-surface + generated reference docs regenerated. Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2ba560a commit 71f76e1

7 files changed

Lines changed: 186 additions & 16 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declared media value shape — ADR-0104 D3 wave 1 (file/image/avatar/video/audio)
6+
7+
`@objectstack/spec/data` now exports `FileValueSchema` — the declared inline
8+
form the platform stores today for the whole `FILE_REFERENCE_TYPES` class
9+
(`file` / `image` / `avatar` / `video` / `audio`): `{ url, name?, size?,
10+
mimeType?, alt?, duration? }` with `url` required. It replaces D1's loose
11+
transitional union, so `valueSchemaFor(fileField, 'stored')` now catches a
12+
malformed media value (a number, an empty object, a url-less `{ name }`
13+
fragment) that was previously waved through as an opaque payload — while still
14+
admitting the opaque id/url string form for import compatibility.
15+
16+
This is **wave 1** of ADR-0104 D3 (see the 2026-07-24 addendum): the value-shape
17+
contract only. It is single-repo, additive, and carries no migration — the
18+
enforcement rides D1's existing warn-first write-path posture, so deployed
19+
records with a legacy media value are not stranded. `accept` / `maxSize` field
20+
config, the `sys_file` reference storage model, GC, and governed download are
21+
**wave 2** (a protocol-major migration), deliberately not in this change.

content/docs/references/data/field-value.mdx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ this contract has (ADR-0104 performance budget).
5858
## TypeScript Usage
5959

6060
```typescript
61-
import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
62-
import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
61+
import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
62+
import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
6363

6464
// Validate data
6565
const result = AddressValue.parse(data);
@@ -108,15 +108,32 @@ Type: `string`
108108

109109
| Property | Type | Required | Description |
110110
| :--- | :--- | :--- | :--- |
111-
| **url** | `string` | optional | |
111+
| **url** | `string` | | |
112112
| **name** | `string` | optional | |
113113
| **size** | `number` | optional | |
114+
| **mimeType** | `string` | optional | |
114115
| **alt** | `string` | optional | |
115116
| **duration** | `number` | optional | |
116117

117118
---
118119

119120

121+
---
122+
123+
## FileValue
124+
125+
### Properties
126+
127+
| Property | Type | Required | Description |
128+
| :--- | :--- | :--- | :--- |
129+
| **url** | `string` || |
130+
| **name** | `string` | optional | |
131+
| **size** | `number` | optional | |
132+
| **mimeType** | `string` | optional | |
133+
| **alt** | `string` | optional | |
134+
| **duration** | `number` | optional | |
135+
136+
120137
---
121138

122139

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

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# ADR-0104: Field runtime value-shape as a first-class contract — spec-owned value schemas, typed action handlers, file-as-reference
22

3-
- **Status**: Accepted (2026-07-22) — implementation staged per D4; phase 1 (D1) tracked on its own PR
3+
- **Status**: Accepted (2026-07-22) — staged per D4. D1 landed (#3429), D2
4+
landed (#3432); D3 refined into two waves by the 2026-07-24 addendum below.
5+
Strict-default flip of D1/D2 tracked in #3438.
46
- **Date**: 2026-07-22
57
- **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param
68
silently stripped); relates #3407 (silently dropped writes), #1878 / #1891
@@ -385,3 +387,106 @@ acceptance criteria (wired to the #2408 Server-Timing surface where useful):
385387
- The three dead value-schema exports stop lying to readers — deleted or made
386388
true. Per the ADR-0078 discipline, "exported by the spec" once again implies
387389
"enforced somewhere".
390+
391+
## Addendum (2026-07-24) — D3 refined into two waves; the enforcement-point principle
392+
393+
D1 (#3429) and D2 (#3432) landed as single-repo, non-breaking (or
394+
breaking-only-for-already-broken) PRs under this ADR's umbrella. D3
395+
(file-as-reference) is different in kind — breaking, cross-repo, protocol-major,
396+
and the only phase carrying **irreversible** risk (R4, GC deleting file bytes).
397+
A review of D3 against the platform's actual trajectory — an enterprise,
398+
Salesforce-shaped metadata platform whose **authoring surface is increasingly
399+
driven by AI**, where the governing goal is *keep the AI author from silently
400+
producing wrong metadata* — sharpened two things: D3's **value went up** (it
401+
closes the last authoring surface where an AI can write a silently-wrong value
402+
and get a success envelope — the ADR-0078 asymmetry, at the file layer), while
403+
its **irreversible-migration risk is unchanged**. So D3 is not deferred, but
404+
**decomposed** and **re-sequenced by what serves authoring correctness first**.
405+
406+
### The enforcement-point principle (applies to D1, D2, and D3)
407+
408+
For an AI author, the check that prevents a mistake is the one that fires at
409+
**build/validate time** (`os validate` / `os build` rejects, the way #3406
410+
rejects a targetless inline lookup param) — a build failure the AI must fix, not
411+
a runtime warning it never sees. The runtime **warn-first** posture (D1's
412+
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, D2's `OS_ACTION_PARAMS_STRICT_ENABLED`)
413+
exists only to protect **already-deployed data** from stranding — it is not the
414+
authoring gate.
415+
416+
Therefore the target end-state is **two enforcement points, each with one job**:
417+
418+
- **Build/validate time → hard reject.** Net-new metadata (the AI's output)
419+
must fail loudly at authoring. This is the primary defence against AI error.
420+
- **Runtime → warn-first, then flip.** Deployed data keeps working through the
421+
warn window; the flip to strict-by-default (tracked in #3438) closes it once
422+
telemetry is quiet.
423+
424+
This refines D2 (today runtime-only) and reshapes the #3438 flip: the priority
425+
is adding the **build-time** rejection for value shapes and action params, not
426+
just flipping the runtime default.
427+
428+
### The class this covers — the whole media family, not just `file`
429+
430+
`file` is shorthand throughout D3 for the entire `FILE_REFERENCE_TYPES` class
431+
D1 already defined — **`file`, `image`, `avatar`, `video`, `audio`**. All five
432+
store the same inline blob today, all five bypass `sys_file`, and all five move
433+
to the reference model together. There is no separate story for `image`; the
434+
contract is one class.
435+
436+
### D3 wave 1 — the value-shape contract (low-risk, no migration)
437+
438+
Directly serves "keep the AI author correct." Ships independently of the
439+
protocol major, single-repo, no irreversible risk:
440+
441+
- Give the media class a **declared `FileValueSchema`** — the inline form the
442+
platform stores today (`{ url, name?, size?, mimeType?, alt?, duration? }`,
443+
`url` required) — replacing D1's loose transitional union. `valueSchemaFor`
444+
for the class returns the transitional union of *this declared object* and
445+
the opaque id/url string (still accepted for import-compat), so a malformed
446+
file value (a number, an empty object, garbage) is now caught instead of
447+
waved through. Exported so wave 2 and `objectui` consume the one shape.
448+
- Per the enforcement-point principle, this shape should reject at
449+
**build/validate time** for net-new (AI-authored) metadata while staying
450+
warn-first at runtime for deployed data — the same posture D1 established for
451+
every other value type.
452+
453+
Wave 1 is effectively a "D2.5": no `sys_file` rewiring, no migration, no
454+
protocol bump. Note what wave 1 deliberately does **not** do: it does not add
455+
`accept` / `maxSize` to `FieldSchema`. Those govern an actual upload, so
456+
enforcing them authoritatively needs the server to know the real bytes — i.e.
457+
the `sys_file` model. Adding them before that would ship exactly the inert knob
458+
ADR-0078 forbids, so they belong to wave 2.
459+
460+
### D3 wave 2 — the storage-model migration (protocol major, full safeguards)
461+
462+
The reference model + governance, sequenced after wave 1 and carrying the
463+
irreversible risk. Ride a planned breaking window (as ADR-0103's enum split
464+
rode v16/v17) to amortise the migration cost:
465+
466+
- File field value becomes an opaque `sys_file` id; the expanded read form is
467+
the spec-owned `FileValueSchema` (`url` derived via `/files/:fileId`, never
468+
stored) — D1/D2 §D3 as written.
469+
- Field references join the ADR-0057 tombstone/reap GC (leak + GDPR-erasure
470+
fix); governed download reuses the attachments `authorizeFileRead`, with the
471+
anonymous capability URL demoted to an **opt-in** `acl: 'public_read'`.
472+
- `accept` / `maxSize` land on `FieldSchema` here — not in wave 1 — because now
473+
the server owns the file (`sys_file` metadata), so it can enforce declared
474+
MIME/size at upload admission and re-check at record write authoritatively,
475+
rather than trusting a client-supplied blob.
476+
- Non-negotiable acceptance gates (from Risks): **R4** GC frozen during the
477+
migration window until reference-row counting is verified; **R5** a reviewed
478+
public-posture inventory; **R6** a static scan for sub-key reads
479+
(`record.file.url` in formulas/templates/hooks/flows).
480+
- External-URL usage of `type: 'file'` is retired toward a `url` field (R7) —
481+
under AI authoring this is *desirable*: it disambiguates "managed file" from
482+
"external link" so the AI cannot conflate them.
483+
484+
### Why this stays inside ADR-0104 rather than a new ADR
485+
486+
D3 is already this ADR's third phase; the two-wave split and the
487+
enforcement-point principle are a **refinement of the D4 rollout**, not a new
488+
decision, so they live here. Wave 2's migration mechanics (dual-read window,
489+
`os migrate` backfill, the R4/R5/R6 gates) are specified in §D3 above and need
490+
no separate record. Should wave 2's implementation surface a genuinely new
491+
decision (e.g. the reference-table shape, or a chunked-migration protocol), that
492+
specific choice — not file-as-reference as a whole — would earn its own ADR.

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@
333333
"FieldSchema (const)",
334334
"FieldType (type)",
335335
"FileLikeValueSchema (const)",
336+
"FileValueSchema (const)",
336337
"Filter (type)",
337338
"FilterCondition (type)",
338339
"FilterConditionSchema (const)",

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@
761761
"data/FieldReference",
762762
"data/FieldType",
763763
"data/FileLikeValue",
764+
"data/FileValue",
764765
"data/FilterCondition",
765766
"data/FormatValidation",
766767
"data/FullTextSearch",

packages/spec/src/data/field-value.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,26 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => {
154154
ok({ type: 'lookup' }, 'acc_1', 'expanded'); // unresolvable ids stay ids
155155
});
156156

157-
it('file-likes admit the transitional inline object OR an opaque id/url string (pre-D3)', () => {
157+
it('file-likes admit the transitional inline object OR an opaque id/url string (pre-D3-wave-2)', () => {
158158
ok({ type: 'file' }, { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 });
159159
ok({ type: 'image' }, { url: 'https://cdn/i.png', alt: 'i' });
160+
ok({ type: 'file' }, { url: 'https://cdn/f.pdf', mimeType: 'application/pdf' });
160161
ok({ type: 'file' }, 'file_01HXYZ');
161162
ok({ type: 'image', multiple: true }, ['a.png', 'b.png']);
162163
bad({ type: 'file' }, 42);
163164
bad({ type: 'file' }, {});
164165
});
165166

167+
it('D3 wave 1: the media object form requires a url — a url-less fragment is no longer waved through', () => {
168+
// The whole FILE_REFERENCE_TYPES class shares the one contract.
169+
for (const type of ['file', 'image', 'avatar', 'video', 'audio']) {
170+
ok({ type }, { url: 'https://cdn/x' });
171+
bad({ type }, { name: 'x' }); // object without url — the tightening
172+
bad({ type }, { size: 10 }); // ditto
173+
}
174+
ok({ type: 'video' }, { url: 'https://cdn/v.mp4', duration: 12 });
175+
});
176+
166177
it('structured JSON types', () => {
167178
ok({ type: 'location' }, { lat: 37.77, lng: -122.42 });
168179
bad({ type: 'location' }, { latitude: 37.77, longitude: -122.42 }); // the retired spec-only shape

packages/spec/src/data/field-value.zod.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -174,20 +174,34 @@ export const LocationValueSchema = lazySchema(() => z.object({
174174
export const AddressValueSchema = AddressSchema;
175175

176176
/**
177-
* Media/attachment stored value — TRANSITIONAL (pre-D3): either an opaque
178-
* file-id/url string, or the legacy inline metadata object. ADR-0104 D3
179-
* narrows this to a `sys_file` id string; new writers should prefer the
180-
* string form now.
177+
* Declared media value (ADR-0104 D3 wave 1) — the inline metadata object the
178+
* platform stores today for a `file` / `image` / `avatar` / `video` / `audio`
179+
* field. `url` is the one required member; the rest are optional descriptors
180+
* renderers read. Extra keys are tolerated (renderers add their own), but a
181+
* value with no `url` — an empty object, a `{ name }` fragment, a number — is
182+
* now rejected instead of waved through as an opaque payload.
183+
*
184+
* Wave 2 (file-as-reference) narrows the STORED form to an opaque `sys_file`
185+
* id and makes THIS the `expanded` read shape, with `url` derived from the
186+
* `/files/:fileId` resolver rather than stored.
187+
*/
188+
export const FileValueSchema = lazySchema(() => z.looseObject({
189+
url: z.string(),
190+
name: z.string().optional(),
191+
size: z.number().optional(),
192+
mimeType: z.string().optional(),
193+
alt: z.string().optional(),
194+
duration: z.number().optional(),
195+
}));
196+
197+
/**
198+
* Media/attachment STORED value — TRANSITIONAL (pre-D3-wave-2): an opaque
199+
* file-id / url string, or the declared inline metadata object
200+
* ({@link FileValueSchema}). Wave 2 narrows this to a `sys_file` id string.
181201
*/
182202
export const FileLikeValueSchema = lazySchema(() => z.union([
183203
z.string().min(1),
184-
z.looseObject({
185-
url: z.string().optional(),
186-
name: z.string().optional(),
187-
size: z.number().optional(),
188-
alt: z.string().optional(),
189-
duration: z.number().optional(),
190-
}).refine((o) => Object.keys(o).length > 0, 'empty file value'),
204+
FileValueSchema,
191205
]));
192206

193207
/** Record-id string — the stored form of every reference type. */

0 commit comments

Comments
 (0)