From 8523c22f4fc4917aa7c925b5d039379674faba14 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 16:15:27 +0000 Subject: [PATCH 1/3] docs(spec): three prose carriers now state what the tree does, not a premise it falsifies `field-value.zod.ts`'s `FILE_REFERENCE_TYPES` docblock still said the stored schema "deliberately admits both until D3 lands" while the classifier has returned `FileReferenceIdValueSchema` alone for `form === 'stored'` since ADR-0104 D3 wave 2. `protocol.zod.ts`'s `CreateManyDataResponseSchema.droppedFields` justified its aggregated shape with "schema-uniform, so every row drops the same set", a premise ruling C (#14147) falsifies by exempting keys a `beforeInsert` hook wrote, tracked per row. `filter.zod.ts`'s `FieldReferenceSchema` `@example` spelled the #14104 shape as the relation path `duty.grace_days`, 19 lines above the prose in the same block saying a dotted path is refused. Each sentence is rewritten to what the code does today, citing the landing that made it true rather than a schedule. No predicate, schema arm, key or export moves. Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x Co-authored-by: Claude --- packages/spec/src/api/protocol.zod.ts | 10 +++++++--- packages/spec/src/data/field-value.zod.ts | 17 +++++++++++++---- packages/spec/src/data/filter.zod.ts | 6 ++++-- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 1b3d1e969a..7253f97767 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2357,9 +2357,13 @@ export const CreateManyDataResponseSchema = lazySchema(() => z.object({ droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( 'Write-observability: caller-supplied `readonly` fields the in-engine create-side ' + 'strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch ' + - '(one event per object/reason with the union of dropped field names) rather than per-row, ' + - 'because the insert-time strip is static-`readonly` only — schema-uniform, so every row ' + - 'drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded ' + + '(one event per object/reason with the UNION of dropped field names) rather than per-row, ' + + 'because this response is `{ object, records, count }` and has no per-row slot to hang a ' + + 'drop set on — a union is the only view it can represent. So read a name here as "at least ' + + 'one row dropped this field", NOT "every row dropped the same set": since ruling C (#14147) ' + + 'the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a ' + + 'hook itself wrote, tracked per row, so rows a hook stamped differently drop different ' + + 'sets. Present ONLY when ≥1 field was dropped; the creates still succeeded ' + 'without them (count/success unchanged). Optional — omit-when-empty keeps the shape ' + 'backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` ' + 'on each result instead — see BatchOperationResultSchema.)' diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index 3de81a3089..249fab9b8b 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -174,10 +174,19 @@ export function referenceTargetOf(def: unknown): string | undefined { } /** - * Media/attachment types. Stored form TODAY is the legacy inline metadata - * object (`{url, name?, size?, ...}`) or an opaque file-id/url string; - * ADR-0104 D3 (file-as-reference) narrows this to a `sys_file` id. The stored - * schema below deliberately admits both until D3 lands. + * Media/attachment types. The STORED value of every member is an opaque + * `sys_file` id ({@link FileReferenceIdValueSchema}); the inline metadata + * object (`{url, name?, size?, ...}`) is the `expanded` READ form + * ({@link FileValueSchema}), derived rather than stored. ADR-0104 D3 wave 2 + * (file-as-reference) narrowed it, and the classifier below is where that + * landed: `valueSchemaFor` returns the id ALONE for `form === 'stored'` and + * the id-or-object union only for `'expanded'`. Both directions are pinned in + * `field-value.test.ts`. + * + * Legacy values already in storage — an inline blob, an external URL — are + * admitted by the RUNTIME, never by this schema: they surface as warn-first + * ADR-0104 value-shape warnings from `@objectstack/objectql`'s record + * validator until a deployment opts into strict enforcement. */ export const FILE_REFERENCE_TYPES: ReadonlySet = new Set([ 'image', 'file', 'avatar', 'video', 'audio', diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 75ed958278..866ae925ab 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -37,8 +37,10 @@ import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './da * { "$eq": { "$field": "order.owner_id" } } * * @example - * // completed_at <= due_date + duty.grace_days (#14104) - * { "$lte": { "$field": "due_date", "addDays": { "$field": "duty.grace_days" } } } + * // completed_at <= due_date + grace_days (#14104 — a SAME-TABLE offset + * // column, the spelling both execution paths compile; "Execution support" + * // below states what a dotted path does instead) + * { "$lte": { "$field": "due_date", "addDays": { "$field": "grace_days" } } } * * ## Execution support (#5041 → #5222 → #14104) * From a0a8878174ad72f6c2a19784e6776ea2242472d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 16:24:48 +0000 Subject: [PATCH 2/3] docs(spec): regenerate the reference page and add the changeset The `droppedFields` correction is a `.describe()`, so it lands in the JSON Schema and `content/docs/references/api/protocol.mdx`; `check:generated` proved exactly that one artifact stale and `--fix` regenerated only it. Changeset is `patch` on `@objectstack/spec`: all three edited files ship in the package's published `files[]` via `src/**/*.zod.ts`, measured with `npm pack --dry-run`. Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x Co-authored-by: Claude --- .../spec-prose-carriers-state-the-shipped-contract.md | 11 +++++++++++ content/docs/references/api/protocol.mdx | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/spec-prose-carriers-state-the-shipped-contract.md diff --git a/.changeset/spec-prose-carriers-state-the-shipped-contract.md b/.changeset/spec-prose-carriers-state-the-shipped-contract.md new file mode 100644 index 0000000000..9145bc2e3a --- /dev/null +++ b/.changeset/spec-prose-carriers-state-the-shipped-contract.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": patch +--- + +Three published prose carriers in `packages/spec` now state what the tree does, instead of a premise the same package's own code contradicts. No predicate, schema arm, key or export moves; every accept set is byte-identical. + +All three reach consumers: `@objectstack/spec` ships `src/**/*.zod.ts` in its published `files[]`, so a TSDoc comment in one of these files is a published byte, and one of the three is a `.describe()` that additionally lands in the JSON Schema and the generated reference page. + +- **`FILE_REFERENCE_TYPES` said the stored schema "deliberately admits both until D3 lands".** ADR-0104 D3 wave 2 landed: `valueSchemaFor` returns `FileReferenceIdValueSchema` ALONE for `form === 'stored'` and the id-or-object union only for `'expanded'`, and `field-value.test.ts` pins both directions. The docblock now states the shipped contract — the stored value is an opaque `sys_file` id, the inline metadata object is the expanded READ form — and names where legacy stored values are actually admitted: the warn-first ADR-0104 value-shape path in `@objectstack/objectql`'s record validator, never this schema. The sentence promised a future that had already arrived, which is exactly how it kept reading as current. +- **`CreateManyDataResponseSchema.droppedFields` justified its aggregated shape with "schema-uniform, so every row drops the same set".** Ruling C moved the static-`readonly` strip INSIDE `engine.insert`, after the `beforeInsert` hooks, and exempts keys a hook itself wrote — tracked per row (`rowHookWrittenKeys`). A hook that stamps a protected key on some rows and not others therefore makes those rows drop different sets, so the premise is false. The shape is unchanged and stays right for the reason the producer already gives: this response is `{ object, records, count }` and has no per-row slot to hang a drop set on, so a union is the only view it can represent. The description now says that, and tells a reader how to read a name in the array — "at least one row dropped this field", not "every row did". Callers needing row precision still use the per-row `insertMany`/`batch` results. +- **`FieldReferenceSchema`'s `@example` spelled the #14104 shape as the relation path `duty.grace_days`.** Nineteen lines below it, in the same block, the Execution support prose states that SQL push-down compiles same-table columns only and refuses a dotted path with `INVALID_FILTER`. An author copying the example gets a filter that passes in memory — `matchesFilter` walks dot paths — and 400s on SQL. The example is now the same-table spelling both execution paths compile, matching the wording the hand-written query-syntax guide already publishes; the block's own prose remains the statement of what a dotted path does. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 38f24ad2ad..9909c300cb 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -569,7 +569,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **object** | `string` | ✅ | Object name | | **records** | `Record[]` | ✅ | Created records | | **count** | `number` | ✅ | Number of records created | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the union of dropped field names) rather than per-row, because the insert-time strip is static-`readonly` only — schema-uniform, so every row drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the UNION of dropped field names) rather than per-row, because this response is `{ object, records, count }` and has no per-row slot to hang a drop set on — a union is the only view it can represent. So read a name here as "at least one row dropped this field", NOT "every row dropped the same set": since ruling C (#14147) the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, tracked per row, so rows a hook stamped differently drop different sets. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | ### Nested Shape: `CreateManyDataResponse.droppedFields[number]` From 7aec120c327a4bfcd67db6cc8ff0b88b9fa1c6fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 17:00:11 +0000 Subject: [PATCH 3/3] docs(spec): keep the ruling id out of the customer-facing droppedFields string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:doc-authoring` refuses an internal issue id inside customer-facing spec text, and the rewritten `.describe()` had put one there: a `.describe()` string projects into `content/docs/references/**` and the generated skill artifacts, whose readers have no tracker to resolve `#NNNN` against. The id moves to an adjacent `//` comment — the placement the gate itself prescribes for a reference that is load-bearing for an internal reader — and the customer-facing sentence now states the mechanism directly instead of citing the ruling that produced it, which is what a caller reading the field can act on. Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- packages/spec/src/api/protocol.zod.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 9909c300cb..a1cc755e3a 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -569,7 +569,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **object** | `string` | ✅ | Object name | | **records** | `Record[]` | ✅ | Created records | | **count** | `number` | ✅ | Number of records created | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the UNION of dropped field names) rather than per-row, because this response is `{ object, records, count }` and has no per-row slot to hang a drop set on — a union is the only view it can represent. So read a name here as "at least one row dropped this field", NOT "every row dropped the same set": since ruling C (#14147) the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, tracked per row, so rows a hook stamped differently drop different sets. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the UNION of dropped field names) rather than per-row, because this response is `{ object, records, count }` and has no per-row slot to hang a drop set on — a union is the only view it can represent. So read a name here as "at least one row dropped this field", NOT "every row dropped the same set": the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, tracked per row: rows where a hook stamped a protected key drop a different set from rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | ### Nested Shape: `CreateManyDataResponse.droppedFields[number]` diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 7253f97767..34019bf779 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2354,16 +2354,20 @@ export const CreateManyDataResponseSchema = lazySchema(() => z.object({ object: z.string().describe('Object name'), records: z.array(z.record(z.string(), z.unknown())).describe('Created records'), count: z.number().describe('Number of records created'), + // The per-row read below is maintainer ruling C (#14147): the static-`readonly` strip + // moved into `engine.insert`, after `beforeInsert`, exempting keys a hook wrote + // (`rowHookWrittenKeys`). The id stays in this comment, never in the `.describe()` — + // that string is printed AT the customer, who has no tracker to resolve it. droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( 'Write-observability: caller-supplied `readonly` fields the in-engine create-side ' + 'strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch ' + '(one event per object/reason with the UNION of dropped field names) rather than per-row, ' + 'because this response is `{ object, records, count }` and has no per-row slot to hang a ' + 'drop set on — a union is the only view it can represent. So read a name here as "at least ' + - 'one row dropped this field", NOT "every row dropped the same set": since ruling C (#14147) ' + - 'the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a ' + - 'hook itself wrote, tracked per row, so rows a hook stamped differently drop different ' + - 'sets. Present ONLY when ≥1 field was dropped; the creates still succeeded ' + + 'one row dropped this field", NOT "every row dropped the same set": the strip runs INSIDE ' + + '`engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, ' + + 'tracked per row: rows where a hook stamped a protected key drop a different set from ' + + 'rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded ' + 'without them (count/success unchanged). Optional — omit-when-empty keeps the shape ' + 'backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` ' + 'on each result instead — see BatchOperationResultSchema.)'