diff --git a/.changeset/adr-0104-d3w2-pr5a-write-cutover.md b/.changeset/adr-0104-d3w2-pr5a-write-cutover.md new file mode 100644 index 0000000000..4586ae174f --- /dev/null +++ b/.changeset/adr-0104-d3w2-pr5a-write-cutover.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-storage": minor +--- + +feat(spec)!: media fields declare accept/maxSize, and the stored form is a file reference — ADR-0104 D3 wave 2 (PR-5a) + +**`accept` and `maxSize` are now declared on `FieldSchema`, and enforced on the +server.** Both were already read by the upload widgets — `field.accept`, +`field.maxSize` — while the spec did not declare them, so an author who wrote +them had the keys silently stripped at parse and the constraint simply never +existed. That is exactly the ADR-0104 failure class (a declaration accepted in +source, dropped from the contract, with no feedback). + +Now that the platform owns the file, `sys_file` carries the authoritative MIME +type and byte size, so a record write is re-checked against the declaration +where it actually binds rather than only in the browser — a client-side check is +a convenience, not a control, since any caller talking to the API directly +bypasses it. Violations raise `FileConstraintError` and fail the write. An entry +is only judged against metadata the file actually reports: a file with no +recorded MIME type cannot fail an `accept` test, and one with no recorded size +cannot fail `maxSize` — "we don't know" must not become "not permitted". + +**The stored form of a media field narrows to an opaque `sys_file` id.** +`valueSchemaFor(field, 'stored')` now yields an id for `file`/`image`/`avatar`/ +`video`/`audio`; the inline `{url, name, size, …}` blob becomes the `'expanded'` +read form, which also still admits an unresolved id (storage service absent, +file not committed) exactly as an unexpanded lookup id stays valid. + +Two legacy forms therefore stop conforming, both deliberately: + +- the **inline blob**, which is no longer stored but derived; +- an **external URL**, which was never a managed file — ADR-0104 R7 retires it + toward an explicit `url` field, and under AI authoring that is the point: it + stops "managed file" and "external link" being the same declaration. + +**Not a breaking change today.** Value-shape checking is warn-first +(ADR-0104 R1/R2): a not-yet-backfilled row still writes and the author gets a +warning naming the field. Hard rejection arrives only when a deployment opts +into `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` — which it should do after running the +backfill and confirming reconciliation. The `!` marks the contract change for +the v17 window, not a runtime break on upgrade. diff --git a/content/docs/references/data/field-value.mdx b/content/docs/references/data/field-value.mdx index a283b53789..09c1ae9974 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, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data'; -import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data'; +import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data'; +import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data'; // Validate data const result = AddressValue.parse(data); @@ -118,6 +118,9 @@ Type: `string` --- +--- + + --- ## FileValue diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 0b55359f8c..8eaa3a0365 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -113,6 +113,8 @@ const result = Address.parse(data); | **scale** | `number` | optional | Decimal places | | **min** | `number` | optional | Minimum value | | **max** | `number` | optional | Maximum value | +| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | +| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional | What happens if referenced record is deleted | diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index daf35b7ef4..7c86fbff34 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -372,13 +372,25 @@ describe('validateRecord — ADR-0104 value shapes (warn-first / strict)', () => const data = { account: 'acc_0001', geo: { lat: 37.77, lng: -122.42 }, - doc: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 }, + // The STORED form of a media field is an opaque sys_file id (ADR-0104 D3 + // wave 2); the inline blob is now the expanded READ form. + doc: 'file_01HXYZ', dims: [0.1, 0.2], }; expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow(); withStrict(() => expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow()); }); + it('warn-first keeps a legacy inline blob writable until strict is opted into', () => { + // The migration path that makes narrowing the stored form safe to ship: a + // not-yet-backfilled row still saves and the author gets a value-shape + // warning naming the field, rather than the write failing on data that was + // valid when it was written. + const legacy = { doc: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } }; + expect(() => validateRecord(schema, { ...legacy }, 'update')).not.toThrow(); + withStrict(() => expect(() => validateRecord(schema, { ...legacy }, 'update')).toThrow()); + }); + it('warn-first: malformed shapes pass by default (legacy rows must not strand records)', () => { expect(() => validateRecord(schema, { geo: { latitude: 1, longitude: 2 } }, 'update')).not.toThrow(); expect(() => validateRecord(schema, { account: { id: 'acc_1' } }, 'update')).not.toThrow(); diff --git a/packages/qa/dogfood/test/field-zoo.matrix.ts b/packages/qa/dogfood/test/field-zoo.matrix.ts index 8931479642..7f0911b8d6 100644 --- a/packages/qa/dogfood/test/field-zoo.matrix.ts +++ b/packages/qa/dogfood/test/field-zoo.matrix.ts @@ -73,15 +73,20 @@ export const MATRIX: FieldCase[] = [ { field: 'f_vector', type: 'vector', check: { kind: 'equal', write: [0.1, 0.2, 0.3] } }, // object-valued types that must store/parse as JSON, not stringify to TEXT { field: 'f_record', type: 'record', check: { kind: 'equal', write: { home: '+1', work: '+2' } } }, - { field: 'f_video', type: 'video', check: { kind: 'equal', write: { url: 'https://cdn/v.mp4', duration: 12 } } }, - { field: 'f_audio', type: 'audio', check: { kind: 'equal', write: { url: 'https://cdn/a.mp3', duration: 30 } } }, + // media — the STORED form is an opaque sys_file id (ADR-0104 D3 wave 2); the + // inline `{url, …}` blob is the expanded READ form, derived by the resolver + // rather than written. These ids match no sys_file row here, which is the + // point: the round-trip must return the stored id verbatim when there is + // nothing to expand it into. + { field: 'f_video', type: 'video', check: { kind: 'equal', write: 'file_zoo_video' } }, + { field: 'f_audio', type: 'audio', check: { kind: 'equal', write: 'file_zoo_audio' } }, { field: 'f_composite', type: 'composite', check: { kind: 'equal', write: { label: 'x', n: 1 } } }, { field: 'f_repeater', type: 'repeater', check: { kind: 'equal', write: [{ a: 1 }, { a: 2 }] } }, { field: 'f_location', type: 'location', check: { kind: 'equal', write: { lat: 37.77, lng: -122.42 } } }, { field: 'f_address', type: 'address', check: { kind: 'equal', write: { street: '1 Main', city: 'SF', country: 'US' } } }, - { field: 'f_image', type: 'image', check: { kind: 'equal', write: { url: 'https://cdn/i.png', alt: 'i' } } }, - { field: 'f_file', type: 'file', check: { kind: 'equal', write: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } } }, - { field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: { url: 'https://cdn/a.png' } } }, + { field: 'f_image', type: 'image', check: { kind: 'equal', write: 'file_zoo_image' } }, + { field: 'f_file', type: 'file', check: { kind: 'equal', write: 'file_zoo_doc' } }, + { field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: 'file_zoo_avatar' } }, // relational — store a reference id as a string and read it back verbatim. // FK enforcement is off in this harness, so this asserts value fidelity // (id string → id string), not referential integrity / $expand (covered diff --git a/packages/services/service-storage/src/file-reference-lifecycle.test.ts b/packages/services/service-storage/src/file-reference-lifecycle.test.ts index fec22208e4..fc1d7893cc 100644 --- a/packages/services/service-storage/src/file-reference-lifecycle.test.ts +++ b/packages/services/service-storage/src/file-reference-lifecycle.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; import { installFileReferenceHooks, FileReferenceCopyError, + FileConstraintError, type FileReferenceEngine, } from './file-reference-lifecycle.js'; @@ -494,6 +495,109 @@ describe('File Reference Ownership (ADR-0104 D3 wave 2)', () => { }); }); + // ── Declared media constraints (ADR-0104 D3 wave 2) ────────────── + describe('accept / maxSize enforcement', () => { + const constrained = (over: Record = {}) => ({ + sys_file: REGISTRY.sys_file, + product: { + name: 'product', + fields: { + id: { type: 'text' }, + image: { type: 'image', accept: ['image/*'], maxSize: 1000, ...over }, + gallery: { type: 'image', multiple: true }, + }, + }, + }); + + it('rejects a file larger than the declared maxSize', async () => { + const engine = fakeEngine({ + files: [file({ size: 5000 })], + registry: constrained(), + }); + install(engine); + + await expect(driveInsert(engine, 'product', { image: 'file_a' }, 'p1')).rejects.toThrow( + FileConstraintError, + ); + // The write failed, so nothing was claimed. + expect(engine.tables.sys_file[0].ref_id).toBeUndefined(); + }); + + it('rejects a file whose MIME type is outside the declared accept list', async () => { + const engine = fakeEngine({ + files: [file({ mime_type: 'application/pdf', name: 'a.pdf', size: 10 })], + registry: constrained(), + }); + install(engine); + + await expect(driveInsert(engine, 'product', { image: 'file_a' }, 'p1')).rejects.toThrow( + /not permitted by the accept list/, + ); + }); + + it('accepts a file that satisfies both declarations', async () => { + const engine = fakeEngine({ files: [file({ size: 10 })], registry: constrained() }); + install(engine); + + await driveInsert(engine, 'product', { image: 'file_a' }, 'p1'); + + expect(engine.tables.sys_file[0].ref_id).toBe('p1'); + }); + + it.each([ + [['image/png'], 'image/png', 'a.png', true], + [['image/*'], 'image/jpeg', 'a.jpg', true], + [['.pdf'], 'application/pdf', 'report.pdf', true], + [['.pdf'], 'application/pdf', 'report.txt', false], + [['image/png'], 'image/jpeg', 'a.jpg', false], + [['*/*'], 'anything/at-all', 'x', true], + ])('accept %j vs %s/%s → %s', async (accept, mime, name, allowed) => { + const engine = fakeEngine({ + files: [file({ mime_type: mime, name, size: 10 })], + registry: constrained({ accept, maxSize: undefined }), + }); + install(engine); + + const write = driveInsert(engine, 'product', { image: 'file_a' }, 'p1'); + if (allowed) { + await write; + expect(engine.tables.sys_file[0].ref_id).toBe('p1'); + } else { + await expect(write).rejects.toThrow(FileConstraintError); + } + }); + + /** + * Missing metadata is not evidence of a violation — a sys_file row with no + * recorded size or MIME type must not be rejected by a constraint it + * cannot be tested against. + */ + it('does not reject a file whose size or MIME type is unrecorded', async () => { + const engine = fakeEngine({ + files: [{ id: 'file_a', key: 'user/file_a', name: 'file_a', status: 'committed' }], + registry: constrained(), + }); + install(engine); + + await driveInsert(engine, 'product', { image: 'file_a' }, 'p1'); + + expect(engine.tables.sys_file[0].ref_id).toBe('p1'); + }); + + it('leaves a field with no declared constraints alone', async () => { + const engine = fakeEngine({ + files: [file({ mime_type: 'application/pdf', size: 10_000_000 })], + registry: constrained(), + }); + install(engine); + + // `gallery` declares neither accept nor maxSize. + await driveInsert(engine, 'product', { gallery: ['file_a'] }, 'p1'); + + expect(engine.tables.sys_file[0].ref_field).toBe('gallery'); + }); + }); + // ── Unknown ids ────────────────────────────────────────────────── it('leaves an id that matches no sys_file row untouched', async () => { const engine = fakeEngine({ files: [file()] }); diff --git a/packages/services/service-storage/src/file-reference-lifecycle.ts b/packages/services/service-storage/src/file-reference-lifecycle.ts index ddc088e4bd..23ad98b715 100644 --- a/packages/services/service-storage/src/file-reference-lifecycle.ts +++ b/packages/services/service-storage/src/file-reference-lifecycle.ts @@ -103,6 +103,18 @@ export class FileReferenceCopyError extends Error { } } +/** + * Raised when a referenced file violates its field's declared `accept` / + * `maxSize`. Fails the write: a stored value that breaks its own field's + * declaration is the "declared but not enforced" state ADR-0104 removes. + */ +export class FileConstraintError extends Error { + readonly code = 'ERR_FILE_CONSTRAINT'; + constructor(message: string) { + super(message); + } +} + function asIdList(id: unknown): Array | null { if (typeof id === 'string' || typeof id === 'number') return [id]; if (id && typeof id === 'object' && Array.isArray((id as any).$in)) { @@ -120,6 +132,83 @@ function fileFieldsOf(engine: FileReferenceEngine, objectName: string): string[] .map(([name]) => name); } +/** One field's definition, or undefined. */ +function fieldDefOf(engine: FileReferenceEngine, objectName: string, field: string): any { + return (engine.getObject(objectName) as any)?.fields?.[field]; +} + +/** + * Does `mime` satisfy one `accept` entry? Accepts the same vocabulary the file + * picker does: an exact MIME type, a `type/*` wildcard, or a `.ext` suffix + * (matched against the file NAME, since a browser-supplied extension is what + * the author is describing). + */ +function matchesAcceptEntry(entry: string, mime: string, name: string): boolean { + const e = entry.trim().toLowerCase(); + if (!e) return false; + if (e === '*' || e === '*/*') return true; + if (e.startsWith('.')) return name.toLowerCase().endsWith(e); + if (e.endsWith('/*')) return mime.startsWith(e.slice(0, -1)); + return mime === e; +} + +/** + * Enforce a media field's declared `accept` / `maxSize` against the file the + * record is about to reference. + * + * The upload widget checks both before uploading, but that check is a + * convenience rather than a control — any caller talking to the API directly + * bypasses it. Now that the platform owns the file, `sys_file` carries the + * authoritative MIME type and byte size, so the constraint can be re-checked + * where it actually binds. Throws {@link FileConstraintError}, failing the + * write, because a stored value violating its own field's declaration is + * exactly the "declared but not enforced" state ADR-0104 exists to remove. + * + * Only checks what the file actually reports: a `sys_file` row with no + * `mime_type` cannot fail an `accept` test, and one with no `size` cannot fail + * `maxSize`. Missing metadata is not evidence of a violation. + */ +function assertFileConstraints( + fieldDef: any, + field: string, + file: Record, +): void { + const accept: unknown = fieldDef?.accept; + const maxSize: unknown = fieldDef?.maxSize; + + if (typeof maxSize === 'number' && maxSize > 0 && typeof file.size === 'number') { + if (file.size > maxSize) { + throw new FileConstraintError( + `File exceeds the maximum size declared for '${field}' ` + + `(${file.size} bytes > ${maxSize} bytes)`, + ); + } + } + + if (Array.isArray(accept) && accept.length > 0) { + const mime = typeof file.mime_type === 'string' ? file.mime_type.toLowerCase() : ''; + const name = typeof file.name === 'string' ? file.name : ''; + const hasExtension = name.includes('.'); + + // An entry can only be judged against metadata the file actually reports: + // a MIME entry needs a recorded MIME type, an extension entry needs a name + // carrying an extension. Entries that cannot be evaluated are not failures + // — rejecting on them would turn "we don't know" into "not permitted". + const testable = accept.filter((e): e is string => { + if (typeof e !== 'string' || !e.trim()) return false; + return e.trim().startsWith('.') ? hasExtension : !!mime; + }); + if (testable.length === 0) return; + + if (!testable.some((e) => matchesAcceptEntry(e, mime, name))) { + throw new FileConstraintError( + `File type '${mime || name}' is not permitted by the accept list declared for ` + + `'${field}' (${accept.join(', ')})`, + ); + } + } +} + /** The id tokens a field value carries (a `multiple: true` field holds an * array). Inline blobs and URL-shaped strings yield nothing. */ function idTokensIn(value: unknown): string[] { @@ -207,9 +296,15 @@ async function copyOwnedFile( /** * Rewrite, in place, any id in `data` that is already owned by a different - * slot so it points at a fresh copy instead. Runs in the BEFORE hooks, where - * mutating `input.data` is what the driver goes on to persist — so the record - * never transiently holds a reference it does not own. + * slot so it points at a fresh copy instead, and enforce each referenced + * file's declared `accept` / `maxSize` on the way past. Runs in the BEFORE + * hooks, where mutating `input.data` is what the driver goes on to persist — + * so the record never transiently holds a reference it does not own, and never + * persists one that violates its field's declaration. + * + * The constraint check rides here because this pass already loads every + * referenced `sys_file` row; enforcing it anywhere else would mean a second + * read of the same rows. * * `recordId` is null on insert: a new record can never be the current owner, * so every already-owned id is copied. @@ -238,6 +333,11 @@ async function applyCopyOnClaim( // Unknown id: not a file this platform manages (external/legacy value). // Left verbatim — the read resolver ignores it for the same reason. if (!row) continue; + + // The file is real and about to be referenced by this field, so its + // declared constraints bind now — before ownership, before any copy. + assertFileConstraints(fieldDefOf(engine, object, field), field, row); + // Unclaimed: the after-hook will claim it for this slot. Nothing to copy. if (row.ref_id == null) continue; // Already ours (an update rewriting the same value) — no-op. diff --git a/packages/services/service-storage/src/index.ts b/packages/services/service-storage/src/index.ts index c70ade7f7f..b2ffa76f89 100644 --- a/packages/services/service-storage/src/index.ts +++ b/packages/services/service-storage/src/index.ts @@ -14,7 +14,11 @@ export type { StorageRoutesOptions, FileReadVerdict } from './storage-routes.js' export { SystemFile, SystemUploadSession } from './objects/index.js'; export { installAttachmentLifecycleHooks, createSysFileReapGuard, createUploadSessionReapGuard } from './attachment-lifecycle.js'; export type { AttachmentLifecycleEngine, AttachmentLifecycleLogger } from './attachment-lifecycle.js'; -export { installFileReferenceHooks, FileReferenceCopyError } from './file-reference-lifecycle.js'; +export { + installFileReferenceHooks, + FileReferenceCopyError, + FileConstraintError, +} from './file-reference-lifecycle.js'; export type { FileReferenceEngine, FileReferenceLogger } from './file-reference-lifecycle.js'; export { verifyFileReferences, diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 78e8e1d617..549b07d36d 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -341,6 +341,7 @@ "FieldSchema (const)", "FieldType (type)", "FileLikeValueSchema (const)", + "FileReferenceIdValueSchema (const)", "FileValueSchema (const)", "Filter (type)", "FilterCondition (type)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 14a2035397..2bdc1e632e 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -760,6 +760,7 @@ "data/FieldReference", "data/FieldType", "data/FileLikeValue", + "data/FileReferenceIdValue", "data/FileValue", "data/FilterCondition", "data/FormatValidation", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index f327f17172..8098ae0fa2 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -251,6 +251,16 @@ "status": "live", "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts", "note": "ADR-0052 §5b — the audit-writer renders tracked-field diffs as human-readable activity summaries (renderTrackedChangeSummary + getFieldDefs read field.trackHistory). Reintroduces the pruned auditTrail concept WITH a runtime consumer, satisfying enforce-or-remove (ADR-0049)." + }, + "accept": { + "status": "live", + "evidence": "packages/services/service-storage/src/file-reference-lifecycle.ts (assertFileConstraints)", + "note": "ADR-0104 D3 wave 2. Offered to the file picker AND enforced server-side on record write: once a media field references a sys_file, the stored mime_type/name is re-checked against the declared list, because a client-side check is bypassed by any caller talking to the API directly. Entries are only judged against metadata the file actually reports — an unrecorded MIME type cannot fail the test." + }, + "maxSize": { + "status": "live", + "evidence": "packages/services/service-storage/src/file-reference-lifecycle.ts (assertFileConstraints)", + "note": "ADR-0104 D3 wave 2. Enforced server-side on record write against sys_file.size, not merely checked in the browser. A file with no recorded size cannot fail it." } } } diff --git a/packages/spec/src/data/field-value.test.ts b/packages/spec/src/data/field-value.test.ts index f05816997a..ff5344b8c5 100644 --- a/packages/spec/src/data/field-value.test.ts +++ b/packages/spec/src/data/field-value.test.ts @@ -154,24 +154,41 @@ 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-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' }); + it('D3 wave 2: the STORED media form is an opaque sys_file id', () => { ok({ type: 'file' }, 'file_01HXYZ'); - ok({ type: 'image', multiple: true }, ['a.png', 'b.png']); + ok({ type: 'file' }, '0e2f4c1a-9b7d-4e3f-8a1b-2c3d4e5f6a7b'); + ok({ type: 'image', multiple: true }, ['file_a', 'file_b']); bad({ type: 'file' }, 42); bad({ type: 'file' }, {}); + // The inline blob is no longer STORED — it is the expanded read form. + bad({ type: 'file' }, { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 }); + // An external URL was never a managed file; ADR-0104 R7 retires it toward + // an explicit `url` field. Both of these reach authors as warn-first + // value-shape warnings, not hard failures, until strict is opted into. + bad({ type: 'file' }, 'https://cdn/f.pdf'); + bad({ type: 'image' }, '/api/v1/storage/files/file_a'); + bad({ type: 'image' }, 'data:image/png;base64,aGk='); + }); + + it('D3 wave 2: the EXPANDED media form is the resolved object, or a still-unresolved id', () => { + ok({ type: 'file' }, { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 }, 'expanded'); + ok({ type: 'image' }, { url: 'https://cdn/i.png', alt: 'i' }, 'expanded'); + ok({ type: 'file' }, { url: 'https://cdn/f.pdf', mimeType: 'application/pdf' }, 'expanded'); + // Expansion may not have happened — storage service absent, file not + // committed — exactly as an unexpanded lookup id stays valid. + ok({ type: 'file' }, 'file_01HXYZ', 'expanded'); + bad({ type: 'file' }, 42, 'expanded'); }); 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. + // The whole FILE_REFERENCE_TYPES class shares the one contract, now carried + // by the expanded form (wave 2 moved the object out of `stored`). 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 }, { url: 'https://cdn/x' }, 'expanded'); + bad({ type }, { name: 'x' }, 'expanded'); // object without url — the tightening + bad({ type }, { size: 10 }, 'expanded'); // ditto } - ok({ type: 'video' }, { url: 'https://cdn/v.mp4', duration: 12 }); + ok({ type: 'video' }, { url: 'https://cdn/v.mp4', duration: 12 }, 'expanded'); }); it('structured JSON types', () => { diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index 80e97c3d7b..f640887cf6 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -214,9 +214,34 @@ export const FileValueSchema = lazySchema(() => z.looseObject({ })); /** - * 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. + * Media/attachment STORED value (ADR-0104 D3 wave 2) — an opaque `sys_file` id. + * + * Deliberately id-SHAPED rather than any non-empty string. The two legacy forms + * a file field used to hold are both strings-or-objects that this rejects, and + * rejecting them is the point: + * + * - an **inline metadata blob** is no longer the stored form; it is the + * `expanded` READ form ({@link FileValueSchema}), derived rather than stored; + * - an **external URL** was never a managed file. ADR-0104 R7 retires it toward + * an explicit `url` field, which under AI authoring is what stops "managed + * file" and "external link" from being the same declaration. + * + * Both surface through the warn-first value-shape rollout (R1/R2) rather than + * as hard failures, so a deployment sees exactly which values still need the + * backfill before it opts into strict enforcement. + */ +export const FileReferenceIdValueSchema = lazySchema(() => + z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'Expected an opaque sys_file id'), +); + +/** + * Media/attachment value in either form — the TRANSITIONAL union that was the + * stored contract before wave 2. + * + * @deprecated The stored form is {@link FileReferenceIdValueSchema}; the + * expanded read form is {@link FileValueSchema}. Retained for consumers that + * genuinely need to accept both during the migration window, so they say so + * explicitly rather than by default. */ export const FileLikeValueSchema = lazySchema(() => z.union([ z.string().min(1), @@ -267,7 +292,15 @@ export function valueSchemaFor(def: ValueShapeFieldDef, form: ValueForm = 'store ? z.union([ReferenceIdValueSchema, z.record(z.string(), z.unknown())]) : ReferenceIdValueSchema; } - if (FILE_REFERENCE_TYPES.has(t)) return FileLikeValueSchema; + if (FILE_REFERENCE_TYPES.has(t)) { + // Expanded form: the read path replaces a stored id in place with the + // resolved `{ id, name, size, mimeType, url }` — same polymorphism the + // reference types have, and for the same reason, so an unresolved id + // (storage service absent, file not committed) stays valid. + return form === 'expanded' + ? z.union([FileReferenceIdValueSchema, FileValueSchema]) + : FileReferenceIdValueSchema; + } if (t === 'location') return LocationValueSchema; if (t === 'address') return AddressValueSchema; if (t === 'composite') return z.record(z.string(), z.unknown()); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 339689fca2..63c8abbfff 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -288,6 +288,32 @@ export const FieldSchema = lazySchema(() => z.object({ min: z.number().optional().describe('Minimum value'), max: z.number().optional().describe('Maximum value'), + /** + * Media Constraints (ADR-0104 D3 wave 2) + * + * Apply to the media family — `file`, `image`, `avatar`, `video`, `audio`. + * Declared here rather than only in the upload widget because the client's + * check is a convenience, not a control: it can be bypassed by any caller + * that talks to the API directly. Once the platform owns the file (`sys_file` + * carries its MIME type and byte size), the server re-checks a record write + * against these declarations authoritatively. + * + * Both were read by the upload widgets long before this — `field.accept`, + * `field.maxSize` — while `FieldSchema` did not declare them, so an author + * writing them had them silently stripped at parse and the constraint simply + * never existed. That is the ADR-0104 failure class (a declaration accepted + * in source, dropped in the contract, with no feedback); declaring them here + * and enforcing them server-side is what closes it. + */ + accept: z.array(z.string()).optional().describe( + 'Permitted upload types for media fields, as MIME types or extensions ' + + '(e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write.', + ), + maxSize: z.number().int().positive().optional().describe( + 'Maximum permitted file size in BYTES for media fields. Enforced on write against ' + + 'the stored file size, not just checked in the browser.', + ), + /** Selection Options */ options: z.array(SelectOptionSchema).optional().describe('Static options for select/multiselect'),