Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/adr-0104-d3-wave1-file-value-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/spec": minor
---

feat(spec): declared media value shape — ADR-0104 D3 wave 1 (file/image/avatar/video/audio)

`@objectstack/spec/data` now exports `FileValueSchema` — the declared inline
form the platform stores today for the whole `FILE_REFERENCE_TYPES` class
(`file` / `image` / `avatar` / `video` / `audio`): `{ url, name?, size?,
mimeType?, alt?, duration? }` with `url` required. It replaces D1's loose
transitional union, so `valueSchemaFor(fileField, 'stored')` now catches a
malformed media value (a number, an empty object, a url-less `{ name }`
fragment) that was previously waved through as an opaque payload — while still
admitting the opaque id/url string form for import compatibility.

This is **wave 1** of ADR-0104 D3 (see the 2026-07-24 addendum): the value-shape
contract only. It is single-repo, additive, and carries no migration — the
enforcement rides D1's existing warn-first write-path posture, so deployed
records with a legacy media value are not stranded. `accept` / `maxSize` field
config, the `sys_file` reference storage model, GC, and governed download are
**wave 2** (a protocol-major migration), deliberately not in this change.
23 changes: 20 additions & 3 deletions content/docs/references/data/field-value.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ this contract has (ADR-0104 performance budget).
## TypeScript Usage

```typescript
import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';

// Validate data
const result = AddressValue.parse(data);
Expand Down Expand Up @@ -108,15 +108,32 @@ Type: `string`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **url** | `string` | optional | |
| **url** | `string` | | |
| **name** | `string` | optional | |
| **size** | `number` | optional | |
| **mimeType** | `string` | optional | |
| **alt** | `string` | optional | |
| **duration** | `number` | optional | |

---


---

## FileValue

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **url** | `string` | ✅ | |
| **name** | `string` | optional | |
| **size** | `number` | optional | |
| **mimeType** | `string` | optional | |
| **alt** | `string` | optional | |
| **duration** | `number` | optional | |


---


Expand Down
107 changes: 106 additions & 1 deletion docs/adr/0104-field-runtime-value-shape-contract.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# ADR-0104: Field runtime value-shape as a first-class contract — spec-owned value schemas, typed action handlers, file-as-reference

- **Status**: Accepted (2026-07-22) — implementation staged per D4; phase 1 (D1) tracked on its own PR
- **Status**: Accepted (2026-07-22) — staged per D4. D1 landed (#3429), D2
landed (#3432); D3 refined into two waves by the 2026-07-24 addendum below.
Strict-default flip of D1/D2 tracked in #3438.
- **Date**: 2026-07-22
- **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param
silently stripped); relates #3407 (silently dropped writes), #1878 / #1891
Expand Down Expand Up @@ -385,3 +387,106 @@ acceptance criteria (wired to the #2408 Server-Timing surface where useful):
- The three dead value-schema exports stop lying to readers — deleted or made
true. Per the ADR-0078 discipline, "exported by the spec" once again implies
"enforced somewhere".

## Addendum (2026-07-24) — D3 refined into two waves; the enforcement-point principle

D1 (#3429) and D2 (#3432) landed as single-repo, non-breaking (or
breaking-only-for-already-broken) PRs under this ADR's umbrella. D3
(file-as-reference) is different in kind — breaking, cross-repo, protocol-major,
and the only phase carrying **irreversible** risk (R4, GC deleting file bytes).
A review of D3 against the platform's actual trajectory — an enterprise,
Salesforce-shaped metadata platform whose **authoring surface is increasingly
driven by AI**, where the governing goal is *keep the AI author from silently
producing wrong metadata* — sharpened two things: D3's **value went up** (it
closes the last authoring surface where an AI can write a silently-wrong value
and get a success envelope — the ADR-0078 asymmetry, at the file layer), while
its **irreversible-migration risk is unchanged**. So D3 is not deferred, but
**decomposed** and **re-sequenced by what serves authoring correctness first**.

### The enforcement-point principle (applies to D1, D2, and D3)

For an AI author, the check that prevents a mistake is the one that fires at
**build/validate time** (`os validate` / `os build` rejects, the way #3406
rejects a targetless inline lookup param) — a build failure the AI must fix, not
a runtime warning it never sees. The runtime **warn-first** posture (D1's
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, D2's `OS_ACTION_PARAMS_STRICT_ENABLED`)
exists only to protect **already-deployed data** from stranding — it is not the
authoring gate.

Therefore the target end-state is **two enforcement points, each with one job**:

- **Build/validate time → hard reject.** Net-new metadata (the AI's output)
must fail loudly at authoring. This is the primary defence against AI error.
- **Runtime → warn-first, then flip.** Deployed data keeps working through the
warn window; the flip to strict-by-default (tracked in #3438) closes it once
telemetry is quiet.

This refines D2 (today runtime-only) and reshapes the #3438 flip: the priority
is adding the **build-time** rejection for value shapes and action params, not
just flipping the runtime default.

### The class this covers — the whole media family, not just `file`

`file` is shorthand throughout D3 for the entire `FILE_REFERENCE_TYPES` class
D1 already defined — **`file`, `image`, `avatar`, `video`, `audio`**. All five
store the same inline blob today, all five bypass `sys_file`, and all five move
to the reference model together. There is no separate story for `image`; the
contract is one class.

### D3 wave 1 — the value-shape contract (low-risk, no migration)

Directly serves "keep the AI author correct." Ships independently of the
protocol major, single-repo, no irreversible risk:

- Give the media class a **declared `FileValueSchema`** — the inline form the
platform stores today (`{ url, name?, size?, mimeType?, alt?, duration? }`,
`url` required) — replacing D1's loose transitional union. `valueSchemaFor`
for the class returns the transitional union of *this declared object* and
the opaque id/url string (still accepted for import-compat), so a malformed
file value (a number, an empty object, garbage) is now caught instead of
waved through. Exported so wave 2 and `objectui` consume the one shape.
- Per the enforcement-point principle, this shape should reject at
**build/validate time** for net-new (AI-authored) metadata while staying
warn-first at runtime for deployed data — the same posture D1 established for
every other value type.

Wave 1 is effectively a "D2.5": no `sys_file` rewiring, no migration, no
protocol bump. Note what wave 1 deliberately does **not** do: it does not add
`accept` / `maxSize` to `FieldSchema`. Those govern an actual upload, so
enforcing them authoritatively needs the server to know the real bytes — i.e.
the `sys_file` model. Adding them before that would ship exactly the inert knob
ADR-0078 forbids, so they belong to wave 2.

### D3 wave 2 — the storage-model migration (protocol major, full safeguards)

The reference model + governance, sequenced after wave 1 and carrying the
irreversible risk. Ride a planned breaking window (as ADR-0103's enum split
rode v16/v17) to amortise the migration cost:

- File field value becomes an opaque `sys_file` id; the expanded read form is
the spec-owned `FileValueSchema` (`url` derived via `/files/:fileId`, never
stored) — D1/D2 §D3 as written.
- Field references join the ADR-0057 tombstone/reap GC (leak + GDPR-erasure
fix); governed download reuses the attachments `authorizeFileRead`, with the
anonymous capability URL demoted to an **opt-in** `acl: 'public_read'`.
- `accept` / `maxSize` land on `FieldSchema` here — not in wave 1 — because now
the server owns the file (`sys_file` metadata), so it can enforce declared
MIME/size at upload admission and re-check at record write authoritatively,
rather than trusting a client-supplied blob.
- Non-negotiable acceptance gates (from Risks): **R4** GC frozen during the
migration window until reference-row counting is verified; **R5** a reviewed
public-posture inventory; **R6** a static scan for sub-key reads
(`record.file.url` in formulas/templates/hooks/flows).
- External-URL usage of `type: 'file'` is retired toward a `url` field (R7) —
under AI authoring this is *desirable*: it disambiguates "managed file" from
"external link" so the AI cannot conflate them.

### Why this stays inside ADR-0104 rather than a new ADR

D3 is already this ADR's third phase; the two-wave split and the
enforcement-point principle are a **refinement of the D4 rollout**, not a new
decision, so they live here. Wave 2's migration mechanics (dual-read window,
`os migrate` backfill, the R4/R5/R6 gates) are specified in §D3 above and need
no separate record. Should wave 2's implementation surface a genuinely new
decision (e.g. the reference-table shape, or a chunked-migration protocol), that
specific choice — not file-as-reference as a whole — would earn its own ADR.
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@
"FieldSchema (const)",
"FieldType (type)",
"FileLikeValueSchema (const)",
"FileValueSchema (const)",
"Filter (type)",
"FilterCondition (type)",
"FilterConditionSchema (const)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/json-schema.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,7 @@
"data/FieldReference",
"data/FieldType",
"data/FileLikeValue",
"data/FileValue",
"data/FilterCondition",
"data/FormatValidation",
"data/FullTextSearch",
Expand Down
13 changes: 12 additions & 1 deletion packages/spec/src/data/field-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,26 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => {
ok({ type: 'lookup' }, 'acc_1', 'expanded'); // unresolvable ids stay ids
});

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

it('D3 wave 1: the media object form requires a url — a url-less fragment is no longer waved through', () => {
// The whole FILE_REFERENCE_TYPES class shares the one contract.
for (const type of ['file', 'image', 'avatar', 'video', 'audio']) {
ok({ type }, { url: 'https://cdn/x' });
bad({ type }, { name: 'x' }); // object without url — the tightening
bad({ type }, { size: 10 }); // ditto
}
ok({ type: 'video' }, { url: 'https://cdn/v.mp4', duration: 12 });
});

it('structured JSON types', () => {
ok({ type: 'location' }, { lat: 37.77, lng: -122.42 });
bad({ type: 'location' }, { latitude: 37.77, longitude: -122.42 }); // the retired spec-only shape
Expand Down
36 changes: 25 additions & 11 deletions packages/spec/src/data/field-value.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,20 +174,34 @@ export const LocationValueSchema = lazySchema(() => z.object({
export const AddressValueSchema = AddressSchema;

/**
* Media/attachment stored value — TRANSITIONAL (pre-D3): either an opaque
* file-id/url string, or the legacy inline metadata object. ADR-0104 D3
* narrows this to a `sys_file` id string; new writers should prefer the
* string form now.
* Declared media value (ADR-0104 D3 wave 1) — the inline metadata object the
* platform stores today for a `file` / `image` / `avatar` / `video` / `audio`
* field. `url` is the one required member; the rest are optional descriptors
* renderers read. Extra keys are tolerated (renderers add their own), but a
* value with no `url` — an empty object, a `{ name }` fragment, a number — is
* now rejected instead of waved through as an opaque payload.
*
* Wave 2 (file-as-reference) narrows the STORED form to an opaque `sys_file`
* id and makes THIS the `expanded` read shape, with `url` derived from the
* `/files/:fileId` resolver rather than stored.
*/
export const FileValueSchema = lazySchema(() => z.looseObject({
url: z.string(),
name: z.string().optional(),
size: z.number().optional(),
mimeType: z.string().optional(),
alt: z.string().optional(),
duration: z.number().optional(),
}));

/**
* Media/attachment STORED value — TRANSITIONAL (pre-D3-wave-2): an opaque
* file-id / url string, or the declared inline metadata object
* ({@link FileValueSchema}). Wave 2 narrows this to a `sys_file` id string.
*/
export const FileLikeValueSchema = lazySchema(() => z.union([
z.string().min(1),
z.looseObject({
url: z.string().optional(),
name: z.string().optional(),
size: z.number().optional(),
alt: z.string().optional(),
duration: z.number().optional(),
}).refine((o) => Object.keys(o).length > 0, 'empty file value'),
FileValueSchema,
]));

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