From 19378c397f23fb3c57f19c4179779e6daea8be48 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 04:24:01 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec)!:=20media=20accept/maxSize=20dec?= =?UTF-8?q?lared=20+=20enforced,=20stored=20form=20is=20a=20reference=20?= =?UTF-8?q?=E2=80=94=20ADR-0104=20D3=20wave=202=20(PR-5a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 this ADR exists to remove: a declaration accepted in source, dropped from the contract, with no feedback anywhere. Now that the platform owns the file, sys_file carries the authoritative MIME type and byte size, so a record write is re-checked where the constraint 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. The check rides the pass that already loads each referenced sys_file row, so it costs no extra read, and an entry is only judged against metadata the file actually reports: no recorded MIME type cannot fail an accept test, no recorded size cannot fail maxSize. "We don't know" must not become "not permitted" — a test caught that distinction being got wrong. The stored form of a media field narrows to an opaque sys_file id. valueSchemaFor(field, 'stored') now yields an id for the whole media family; 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 stop conforming, both deliberately: the inline blob, which is no longer stored but derived; and an external URL, which was never a managed file — R7 retires those toward an explicit `url` field, and under AI authoring that IS the point, since it stops "managed file" and "external link" being the same declaration. Not a breaking change today. Value-shape checking is warn-first (R1/R2): a not-yet-backfilled row still writes and the author gets a warning naming the field. Hard rejection arrives only under OS_DATA_VALUE_SHAPE_STRICT_ENABLED, which a deployment opts into after running the backfill and confirming reconciliation. The `!` marks the contract change for the v17 window, not a runtime break on upgrade. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --- .../adr-0104-d3w2-pr5a-write-cutover.md | 42 +++++++ content/docs/references/data/field-value.mdx | 7 +- content/docs/references/data/field.mdx | 2 + .../src/validation/record-validator.test.ts | 14 ++- packages/qa/dogfood/test/field-zoo.matrix.ts | 15 ++- .../src/file-reference-lifecycle.test.ts | 104 +++++++++++++++++ .../src/file-reference-lifecycle.ts | 106 +++++++++++++++++- .../services/service-storage/src/index.ts | 6 +- packages/spec/api-surface.json | 1 + packages/spec/json-schema.manifest.json | 1 + packages/spec/src/data/field-value.test.ts | 37 ++++-- packages/spec/src/data/field-value.zod.ts | 41 ++++++- packages/spec/src/data/field.zod.ts | 26 +++++ 13 files changed, 376 insertions(+), 26 deletions(-) create mode 100644 .changeset/adr-0104-d3w2-pr5a-write-cutover.md 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/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'), From ddeccc672269ebb42dda5df11dfa847e8ee93290 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 04:36:03 +0000 Subject: [PATCH 2/2] chore(spec): register accept/maxSize in the field liveness ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec-property liveness gate (ADR-0049, declared != enforced) flagged both new properties as UNCLASSIFIED — which is the gate doing exactly its job, since the whole reason these two are being added is that they were read by widgets while nothing in the contract declared or enforced them. Both are registered live, with the server-side enforcement site as evidence rather than the widget that merely offers them to the file picker: a client-side check is bypassed by any caller talking to the API directly, so it is not what makes the property live. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --- packages/spec/liveness/field.json | 10 ++++++++++ 1 file changed, 10 insertions(+) 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." } } }