diff --git a/.changeset/adr-0104-d3-wave1-file-value-shape.md b/.changeset/adr-0104-d3-wave1-file-value-shape.md new file mode 100644 index 000000000..66d002ba9 --- /dev/null +++ b/.changeset/adr-0104-d3-wave1-file-value-shape.md @@ -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. diff --git a/content/docs/references/data/field-value.mdx b/content/docs/references/data/field-value.mdx index 8db95f264..a283b5378 100644 --- a/content/docs/references/data/field-value.mdx +++ b/content/docs/references/data/field-value.mdx @@ -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); @@ -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 | | + + --- diff --git a/docs/adr/0104-field-runtime-value-shape-contract.md b/docs/adr/0104-field-runtime-value-shape-contract.md index 3da9e3850..59f32ecce 100644 --- a/docs/adr/0104-field-runtime-value-shape-contract.md +++ b/docs/adr/0104-field-runtime-value-shape-contract.md @@ -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 @@ -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. diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 3d0f310de..e10cd9d3c 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -333,6 +333,7 @@ "FieldSchema (const)", "FieldType (type)", "FileLikeValueSchema (const)", + "FileValueSchema (const)", "Filter (type)", "FilterCondition (type)", "FilterConditionSchema (const)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 24efa5d90..1f216ddd5 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -761,6 +761,7 @@ "data/FieldReference", "data/FieldType", "data/FileLikeValue", + "data/FileValue", "data/FilterCondition", "data/FormatValidation", "data/FullTextSearch", diff --git a/packages/spec/src/data/field-value.test.ts b/packages/spec/src/data/field-value.test.ts index ed65457c6..f05816997 100644 --- a/packages/spec/src/data/field-value.test.ts +++ b/packages/spec/src/data/field-value.test.ts @@ -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 diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index 69f930ac6..f7a95c3c4 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -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. */