From 639e986f988b5ac2ff46addfa048f3e15b937b41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:41:06 +0000 Subject: [PATCH 01/14] feat(spec): one MetadataAuthoringSurfaceSchema for the save's surface and draftChanges.by Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- packages/spec/src/api/protocol.zod.ts | 60 ++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 2d64babedb4..fe0db3aafa3 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -479,6 +479,21 @@ const MetadataProtectionEnvelopeFields = { ), } as const; +/** + * [#24395] The authoring surface of a metadata save: the one closed set both + * {@link SaveMetaItemRequestSchema}'s `surface` (what a save declares) and + * {@link GetMetaItemResponseSchema}'s `draftChanges[].by` (what the draft read + * reports a key was last changed through) are spelled in, so the value a save + * states and the value a read serves cannot drift apart. + */ +export const MetadataAuthoringSurfaceSchema = z.enum(['copilot', 'studio', 'api']).describe( + 'The authoring surface of a metadata write. Closed: `copilot` is an AI assistant\'s tool writing on a ' + + 'person\'s behalf (Studio\'s AI copilot); `studio` is a person editing in Studio; `api` is any other ' + + 'caller of the save door. It names the surface, not the person: a copilot write and a Studio write of ' + + 'the same user carry the same `actor`.', +); +export type MetadataAuthoringSurface = z.infer; + /** * Get Metadata Item Response * @@ -582,8 +597,15 @@ const MetadataProtectionEnvelopeFields = { * and the array's entry says who last changed it and when. Attribution is * per array, never per element. * - **Optional, never defaulted to `[]`.** Absent means "not recorded" (a - * server with no writer, or a draft row saved before one); an empty-array - * default would answer "nothing changed" for exactly those rows. + * draft row saved before the writer, or one whose saves changed no key + * path); an empty-array default would answer "nothing changed" for exactly + * those rows. + * + * [#24395] The writer is the draft save (`saveMetaItem` with `mode: 'draft'`, + * in `@objectstack/metadata-protocol`). It records on the draft row, beside the + * body and never inside it, so publishing or discarding the draft removes the + * entries with the row. Each save names its surface on + * {@link SaveMetaItemRequestSchema}'s `surface`. * * The history log's per-event `source` (see `HistoryMetaItemResponseSchema`) * is not this vocabulary: it is an open label of the code path that wrote the @@ -643,8 +665,9 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ + 'published body\'s array; the entry answers only who last changed the array as a whole. ' + '`[]` is the whole body: the write that created the draft of an item with no published body.', ), - by: z.enum(['copilot', 'studio', 'api']).describe( - 'The authoring surface of the write that last changed this path. Closed: `copilot` is an AI ' + by: MetadataAuthoringSurfaceSchema.describe( + 'The authoring surface of the write that last changed this path: the `surface` that save declared ' + + '(`SaveMetaItemRequestSchema.surface`), `api` when it declared none. Closed: `copilot` is an AI ' + 'assistant\'s tool writing on a person\'s behalf (Studio\'s AI copilot); `studio` is a person ' + 'editing in Studio; `api` is any other caller of the save door. It names the surface, not the ' + 'person: a copilot write and a Studio write of the same user carry the same `actor`.', @@ -660,13 +683,14 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ }).describe('One changed key path of the pending draft, and the last write that changed it.')) .optional().describe( 'Per-key provenance of the pending draft: which authoring surface last changed each key path of ' - + '`item`, under which actor, and when. No server writes the key yet: until the engine\'s draft ' - + 'save records it, every draft read is one whose saves recorded none, and the key is absent. ' - + 'The contract a writer must meet: it compares each draft save with the body that save replaced ' - + '(the previous draft, or the published body for the draft\'s first save); it keeps the entries ' + + '`item`, under which actor, and when. The draft save records it: each draft save is compared ' + + 'with the body that save replaced (the previous draft, or the stored published row for the ' + + 'draft\'s first save; a draft of an item with no stored published row is recorded as `[]`, the ' + + 'whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not ' + + 'authoring. The entries are kept ' + 'from the first recorded save until the draft is published or discarded, which remove the draft ' - + 'row and the entries with it; and it keeps no two entries on one path, replacing every entry at ' - + 'or under a path a save changes, so the last write to change a key is the entry whose `path` is ' + + 'row and the entries with it; and no two entries share a path: a save replaces every entry at ' + + 'or under each path it changes, so the last write to change a key is the entry whose `path` is ' + 'the longest prefix of that key\'s path, and entries under a key\'s path are changes inside it. ' + 'The entries name which writes changed what; they are not a list of every difference from the ' + 'published body (compare the two bodies for that). Served only on a `?state=draft` read that ' @@ -866,6 +890,12 @@ export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({ * it — declaring it here would advertise a wire-authorable provenance * channel the REST layer deliberately never reads. * + * [#24395] `surface` is the save's authoring surface, the closed set + * `draftChanges[].by` reports (`MetadataAuthoringSurfaceSchema`). Unlike + * `writeFace` it IS caller-stated, and that is why the HTTP doors read it + * with a narrower set than this schema's: a remote caller may say `studio` + * or `api`, and only an in-process tool may say `copilot`. + * * `writeFace` IS declared, and the distinction with `source` is the point: * both are server-stated, but `writeFace` is sent by three doors (the two * metadata write doors and the external-table import) and the @@ -959,6 +989,16 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ + 'message adapts to whether a structured `issues[]` channel reaches ' + 'the consumer beside it. Absent = the conservative default wording.', ), + surface: MetadataAuthoringSurfaceSchema.optional().describe( + 'The authoring surface this save is made through (see `MetadataAuthoringSurfaceSchema`). A draft ' + + 'save (`mode: \'draft\'`) stamps it as `by` on every key path it changes, the entries the strict ' + + 'draft read serves as `draftChanges`; an active save records no per-key provenance. ABSENT stamps ' + + '`api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which ' + + 'takes `studio` or `api` and refuses every other value `400 VALIDATION_ERROR`, `copilot` ' + + 'included: `copilot` is stated only by an in-process AI tool calling the save, never by a remote ' + + 'caller, so an HTTP request cannot mark its own write as the copilot\'s. A value outside the set ' + + 'in-process is refused `400 INVALID_REQUEST`, before anything is written.', + ), })); /** From 39a80fb489ffaf8009e49d9d595956f64cc61809 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:41:07 +0000 Subject: [PATCH 02/14] feat(metadata-protocol): the draft put records draftChanges on the draft row Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- .../src/objects/sys-metadata.object.ts | 18 +++ .../metadata-protocol/src/draft-changes.ts | 141 ++++++++++++++++++ .../src/sys-metadata-repository.ts | 76 +++++++++- 3 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 packages/metadata-protocol/src/draft-changes.ts diff --git a/packages/metadata-core/src/objects/sys-metadata.object.ts b/packages/metadata-core/src/objects/sys-metadata.object.ts index 81f9d8d8bc0..8fa900fdf5f 100644 --- a/packages/metadata-core/src/objects/sys-metadata.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata.object.ts @@ -121,6 +121,24 @@ export const SysMetadataObject = ObjectSchema.create({ description: 'JSON-serialized metadata payload', }), + /** + * [#24395] A draft row's per-key provenance — the entries the strict draft + * read serves as `draftChanges` (`GetMetaItemResponseSchema` in + * `@objectstack/spec/api`): which authoring surface, actor and instant last + * changed each key path of the draft body. JSON-serialized array, written + * by the overlay repository's draft save, never authored. + * + * On the row, ⛔ never inside `metadata`: publishing and discarding a draft + * both delete its row, so the entries go with it. NULL on every active row + * and on a draft row whose saves recorded none. + */ + draft_changes: Field.textarea({ + label: 'Draft Changes', + required: false, + description: + 'JSON-serialized per-key provenance of a pending draft: which surface (copilot, studio, api), actor and time last changed each key path. Null on active rows.', + }), + /** Parent metadata name for extension/override */ extends: Field.text({ label: 'Extends', diff --git a/packages/metadata-protocol/src/draft-changes.ts b/packages/metadata-protocol/src/draft-changes.ts new file mode 100644 index 00000000000..119e99b9e12 --- /dev/null +++ b/packages/metadata-protocol/src/draft-changes.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24395] The writer of `GetMetaItemResponseSchema.draftChanges` — a pending + * draft's per-key provenance — as three pure functions the overlay repository + * calls inside its draft `put` and the strict draft read calls to serve it. + * The contract they meet is the key's describe in `@objectstack/spec/api` + * (`protocol.zod.ts`); this header restates none of it beyond what the code + * below decides. + * + * - {@link draftChangedPaths} — which key paths a draft save changed, from the + * body it replaced to the body it writes. A path stops at the deepest key + * whose value differs; an array, or any non-object value, is one leaf + * compared whole. `[[]]` when nothing was replaced (the save created the + * draft of an item with no stored published body). + * - {@link mergeDraftChanges} — the keep rule: every entry at or under a path + * the save changed is replaced by one entry at that path, so no two entries + * share a path and the longest prefix of a key's path answers for it. + * - {@link storedDraftChanges} — the entries a draft row stores + * (`sys_metadata.draft_changes`), `undefined` when it stores none, so the + * read never answers `[]` for a row whose saves recorded nothing. + * + * The entries live on the draft row, ⛔ never inside the stored body: publish + * and discard both delete that row, so the entries go with it. + */ + +import { canonicalize, orderedMapKeys } from '@objectstack/metadata-core'; +import type { MetadataAuthoringSurface } from '@objectstack/spec/api'; + +/** One entry of `draftChanges`, as stored and as served. */ +export interface DraftChangeEntry { + readonly path: readonly string[]; + readonly by: MetadataAuthoringSurface; + readonly actor: string | null; + readonly at: string; +} + +/** The write a {@link mergeDraftChanges} stamps on each path it changed. */ +export interface DraftChangeStamp { + readonly by: MetadataAuthoringSurface; + readonly actor: string | null; + readonly at: string; +} + +const SURFACES: ReadonlySet = new Set(['copilot', 'studio', 'api']); + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The key paths a draft save changed. `before` is the body the save replaced + * (the draft row's, else the published row's), `undefined` when there was + * none; `type` names the metadata type, whose declared ordered maps + * (`orderedMapKeys`, an object's `fields`) make a pure reorder a change, as + * the hash does. + * + * A key whose segment would be empty (`""`) cannot be a path segment + * (`z.string().min(1)`), so a change under it is reported at its parent. + */ +export function draftChangedPaths(before: unknown, after: unknown, type?: string): string[][] { + if (before === undefined) return [[]]; + const out: string[][] = []; + const ordered = new Set(orderedMapKeys(type)); + const walk = (a: unknown, b: unknown, path: string[]): void => { + if (!isPlainObject(a) || !isPlainObject(b)) { + if (canonicalize(a) !== canonicalize(b)) out.push(path); + return; + } + const found = out.length; + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of keys) { + if (key === '') { + if (canonicalize(a[key]) !== canonicalize(b[key])) { + out.splice(found); + out.push(path); + return; + } + continue; + } + const inA = Object.prototype.hasOwnProperty.call(a, key) && a[key] !== undefined; + const inB = Object.prototype.hasOwnProperty.call(b, key) && b[key] !== undefined; + if (inA !== inB) out.push([...path, key]); + else if (inA) walk(a[key], b[key], [...path, key]); + } + // A declared ordered map whose members are unchanged but reordered: the + // order is content, and the map is the deepest key that holds it. + if (out.length === found && path.length === 1 && ordered.has(path[0]!) + && Object.keys(a).join('\u0000') !== Object.keys(b).join('\u0000')) { + out.push(path); + } + }; + walk(before, after, []); + return out; +} + +function isAtOrUnder(path: readonly string[], prefix: readonly string[]): boolean { + if (path.length < prefix.length) return false; + return prefix.every((segment, i) => path[i] === segment); +} + +/** + * The keep rule: `entries` (the draft row's, `[]` for none) after a save that + * changed `paths`, stamped `stamp`. Every entry at or under a changed path is + * dropped and one entry at that path takes its place; the rest are kept in + * their order, the new ones follow in `paths`' order. + */ +export function mergeDraftChanges( + entries: readonly DraftChangeEntry[], + paths: readonly (readonly string[])[], + stamp: DraftChangeStamp, +): DraftChangeEntry[] { + const kept = entries.filter((entry) => !paths.some((p) => isAtOrUnder(entry.path, p))); + return [...kept, ...paths.map((path) => ({ path: [...path], by: stamp.by, actor: stamp.actor, at: stamp.at }))]; +} + +/** + * The entries a draft row stores, or `undefined` when it stores none — a row + * saved before the writer, a column a store does not carry, or a value that is + * not a list of well-formed entries. ⛔ Never `[]`: an empty list would read + * as "nothing changed" for exactly the rows nothing was recorded for. + */ +export function storedDraftChanges(raw: unknown): DraftChangeEntry[] | undefined { + let value = raw; + if (typeof value === 'string') { + if (value === '') return undefined; + try { value = JSON.parse(value); } catch { return undefined; } + } + if (!Array.isArray(value) || value.length === 0) return undefined; + const entries: DraftChangeEntry[] = []; + for (const e of value as unknown[]) { + if (!isPlainObject(e)) return undefined; + const { path, by, actor, at } = e; + if (!Array.isArray(path) || !path.every((s) => typeof s === 'string' && s !== '')) return undefined; + if (typeof by !== 'string' || !SURFACES.has(by)) return undefined; + if (actor !== null && typeof actor !== 'string') return undefined; + if (typeof at !== 'string') return undefined; + entries.push({ path: [...path] as string[], by: by as MetadataAuthoringSurface, actor, at }); + } + return entries; +} diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 5630b9de7b4..13d451ba343 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -93,6 +93,10 @@ import { isOriginGatedType, managedItemSealedSentence, packagedBaseRegimePrescri // its value from a selector or a body — owned by the migration that keys the // overlay index on it, so the key and the write head cannot disagree. import { OVERLAY_MEMBER_COLUMN, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; +import type { MetadataAuthoringSurface } from '@objectstack/spec/api'; +// [#24395] The draft save's per-key provenance — the diff, the keep rule and +// the stored form, one module the strict draft read reads it back through. +import { draftChangedPaths, mergeDraftChanges, storedDraftChanges } from './draft-changes.js'; /** * Canonicalise a driver-materialised timestamp into the ISO-8601 string the @@ -931,6 +935,53 @@ export class SysMetadataRepository implements MetadataRepository { return { row: row ?? null, targetPackageId, ...(ambiguousPackages ? { ambiguousPackages } : {}) }; } + /** + * [#24395] The draft row's `draft_changes` after a draft {@link put} of + * `body` over `existing` (the draft row it updates, `null` for a create): + * the serialized entries to write, or `undefined` when they do not change + * (the save changed no key path), so the column is left as it was. + * + * The body the save replaced is the draft row's; for the draft's first save + * it is the ACTIVE row its publish would replace ({@link promoteDraft} + * writes the draft's binding): that package's row, else the package-less one + * a read of that package is served (ADR-0048 prefer-local). With neither the + * save created the item as a draft, recorded as `[]`, the whole body. Read + * inside the caller's transaction, so the body compared is the one replaced. + */ + private async recordDraftChanges( + ref: Pick, + body: Record, + existing: any | null, + args: { + ctx: Record; + member: string | null | undefined; + targetPackageId: string | null; + stamp: { by: MetadataAuthoringSurface; actor: string | null; at: string }; + }, + ): Promise { + let replaced: any | null = existing; + if (!replaced) { + const context = { ...args.ctx, isSystem: true }; + replaced = await this.findEnvironmentRow({ + where: this.whereFor(ref, 'active', args.targetPackageId, args.member), + context, + }); + if (!replaced && args.targetPackageId !== null) { + replaced = await this.findEnvironmentRow({ + where: this.whereFor(ref, 'active', null, args.member), + context, + }); + } + } + const before = replaced + ? (typeof replaced.metadata === 'string' ? JSON.parse(replaced.metadata) : replaced.metadata ?? {}) + : undefined; + const paths = draftChangedPaths(before, body, ref.type); + if (paths.length === 0) return undefined; + const kept = existing ? storedDraftChanges(existing.draft_changes) ?? [] : []; + return JSON.stringify(mergeDraftChanges(kept, paths, args.stamp)); + } + /** * Resolve a historical version by content hash (ADR-0009). * @@ -978,7 +1029,19 @@ export class SysMetadataRepository implements MetadataRepository { async put( ref: MetaRef, spec: unknown, - opts: PutOptions & { state?: OverlayState; opType?: ExtendedOperation }, + opts: PutOptions & { + state?: OverlayState; + opType?: ExtendedOperation; + /** + * [#24395] The authoring surface a `draft` put records as `by` on every + * key path it changes (`sys_metadata.draft_changes`, served as the + * strict draft read's `draftChanges`). A LOCAL extension, as `state` is. + * Omitted → the put records nothing and leaves the row's entries as + * they are, which is what a write that is not authoring (the + * `migrate-stored` rewrite) states. Ignored on an `active` put. + */ + draftChangesBy?: MetadataAuthoringSurface; + }, ): Promise { this.assertOpen(); // [#7682] The base the caller NAMED is part of the authorization question, @@ -1084,6 +1147,17 @@ export class SysMetadataRepository implements MetadataRepository { } else { parentRowData.package_id = targetPackageId; } + // [#24395] The draft's per-key provenance, recorded in this transaction + // against the body this write replaces: the draft row's, else (the + // draft's first save) the active row its publish would replace, else + // nothing (`[]`, the whole body). Only a column that changes is written. + if (state === 'draft' && opts.draftChangesBy !== undefined) { + const draftChanges = await this.recordDraftChanges(ref, body, existing, { + ctx, member, targetPackageId: parentRowData.package_id as string | null, + stamp: { by: opts.draftChangesBy, actor: opts.actor ?? null, at: now }, + }); + if (draftChanges !== undefined) parentRowData.draft_changes = draftChanges; + } if (existing) { const existingId = (existing as { id?: string }).id; if (existingId === undefined) { From baf0e8ab0fc924d594399e09eeb427e151a954e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:41:07 +0000 Subject: [PATCH 03/14] feat(metadata-protocol): the draft save states its surface, the strict draft read serves draftChanges Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 36 +++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index af22d251fb1..b9333c0d18d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -158,6 +158,10 @@ import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribable // [#23183] The app item read's package-id key — the one spelling of the arm // `GetMetaItemRequestSchema.name` declares ({@link resolveAppPackageIdKey}). import { isAppPackageIdKey } from '@objectstack/spec/api'; +// [#24395] The save's authoring surface, and the draft row's per-key +// provenance read back for the strict draft read. +import { MetadataAuthoringSurfaceSchema, type MetadataAuthoringSurface } from '@objectstack/spec/api'; +import { storedDraftChanges } from './draft-changes.js'; import { parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf, AggregationFunction, DateGranularity, resolveSearchFieldResolution, @@ -11477,6 +11481,9 @@ export class ObjectStackProtocolImplementation implements // [#21899] Whether a stored row was found — the fact the origin-gated // repair's `deletable` reads ({@link servedLockState}). let storedRowServed = false; + // [#24395] The served draft row's per-key provenance — read off the + // row this read serves, so it can never describe another body. + let draftChanges: ReturnType; try { let record = (await this.findServedOverlayRow({ type: request.type, @@ -11512,6 +11519,7 @@ export class ObjectStackProtocolImplementation implements if (recPkg && item && typeof item === 'object' && (item as any)._packageId === undefined) { (item as any)._packageId = recPkg; } + if (readState === 'draft') draftChanges = storedDraftChanges((record as { draft_changes?: unknown }).draft_changes); } } catch (error) { // [#5532] THE site this issue was raised on. Falling through to the @@ -11540,6 +11548,10 @@ export class ObjectStackProtocolImplementation implements // [#22877] The member's own row's, as a save of that member's // body addresses it. version: await this.readVersionToken({ ...request, member }, 'draft'), + // [#24395] `GetMetaItemResponseSchema.draftChanges`: present + // only when the draft row's saves recorded at least one entry + // — ⛔ never `[]` for a row that recorded none. + ...(draftChanges ? { draftChanges } : {}), }; } @@ -22688,7 +22700,7 @@ export class ObjectStackProtocolImplementation implements // STORED content hash itself (`migrateStoredMetadata`): it reaches the // repository as given. ⛔ No transport sets it — every door builds its // request field by field from named inputs, never by spreading a body. - async saveMetaItem(request: { type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace }) { + async saveMetaItem(request: { type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace, surface?: MetadataAuthoringSurface }) { // [ADR-0131 D6] FIRST, before every other gate and every read: an // organization-scoped write is refused for every type, draft or // publish. See {@link organizationScopedWriteRefusal}. @@ -22737,6 +22749,21 @@ export class ObjectStackProtocolImplementation implements err.status = 400; throw err; } + // [#24395] The authoring surface the draft save stamps on every key + // path it changes: a value outside the closed set is refused here, + // before any read or write, with the request-envelope refusal above — + // never stamped as `api`, which would record a caller's mistake as + // a fact about who wrote the draft. The HTTP doors never send + // `copilot` (`metaSaveRequestOptions`); an in-process tool may. + if (request.surface !== undefined && !MetadataAuthoringSurfaceSchema.safeParse(request.surface).success) { + const err: any = new Error( + `saveMetaItem 'surface' must be one of copilot, studio or api for '${request.type}/${request.name}'; ` + + `got ${JSON.stringify(request.surface)}. Omit it for a save that is neither the copilot nor Studio (recorded as api).`, + ); + err.code = 'INVALID_REQUEST'; + err.status = 400; + throw err; + } // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); // [commit 311433f6b] The item-name grammar verdict — static, request-only, so it @@ -24002,6 +24029,13 @@ export class ObjectStackProtocolImplementation implements intent, state: mode === 'draft' ? 'draft' : 'active', ...(request.packageId !== undefined ? { packageId: request.packageId } : {}), + // [#24395] An authoring draft save records which surface + // changed which key path (`api` when it names none). A + // server-stated rewrite — `migrate-stored`, the only caller + // that states a `source` — is not authoring and records none. + ...(mode === 'draft' && request.source === undefined + ? { draftChangesBy: request.surface ?? 'api' } + : {}), }); // Persistence succeeded — NOW it's safe to mutate the // in-memory registry. If put() had thrown, the registry From c6ef106da77d0722aca1f93a8db0b353c3ef86b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:41:07 +0000 Subject: [PATCH 04/14] feat(rest,runtime): ?surface=studio|api on both HTTP save doors, copilot refused Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- packages/rest/src/meta-item-read-gate.ts | 16 ++++---- packages/rest/src/meta-item-write-routes.ts | 8 +++- packages/rest/src/meta-save-request.ts | 45 ++++++++++++++++++++- packages/runtime/src/domains/meta.ts | 8 +++- 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index c88089b5705..2e4b8a226b4 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -2270,18 +2270,18 @@ export const META_ITEM_READ_PARAMS: readonly string[] = [ * [#23072] The closed set of `PUT {metaPath}/:type/:name` (the save). * **Measured** from the handler and the helper it calls: `force` (the * destructive-change acknowledgement), `package` (the saved row's binding, - * `metaItemPackageBinding`) and `mode` — read a frame down, by - * `metaSaveRequestOptions` (`./meta-save-request.ts`), beside the `If-Match` / - * `If-None-Match` headers it reads. The capability, walled-catalog and actor - * gates read the session, not the query. The same three names the door's - * multiplicity guard declares. + * `metaItemPackageBinding`), and `mode` and [#24395] `surface` (the save's + * authoring surface) — both read a frame down, by `metaSaveRequestOptions` + * (`./meta-save-request.ts`), beside the `If-Match` / `If-None-Match` headers + * it reads. The capability, walled-catalog and actor gates read the session, + * not the query. The same four names the door's multiplicity guard declares. * * ⛔ No member selector: the save is BODY-addressed (ruling 6116165299) — the * member it writes is named by the body's own discriminator field * (`email_template`'s `locale`), and `SaveMetaItemRequest` declares no * selector. A `?member=zh-CN` beside an `en-US` body is refused, not read. */ -export const META_ITEM_SAVE_PARAMS: readonly string[] = ['force', 'package', 'mode']; +export const META_ITEM_SAVE_PARAMS: readonly string[] = ['force', 'package', 'mode', 'surface']; /** * [#23072] The closed set of `GET {metaPath}/:type/:name/layers`. @@ -2307,8 +2307,8 @@ export const META_ITEM_PUBLISHED_PARAMS: readonly string[] = []; /** * [#23072] The closed set of the runtime dispatcher's `PUT /meta/:type/:name`: * {@link META_ITEM_SAVE_PARAMS} less `force`. **Measured** on that branch of - * `handleMetadataRequest`: `package` (`metaItemPackageBinding`) and `mode` - * (`metaSaveRequestOptions`) — the names `RestServer`'s save reads through the + * `handleMetadataRequest`: `package` (`metaItemPackageBinding`), `mode` and + * `surface` (`metaSaveRequestOptions`) — the names `RestServer`'s save reads through the * same two shared readers. `force` is the one name that door was ruled never to * take (#11095, settled: its `409 DESTRUCTIVE_CHANGE` remedy already says "the * dispatcher's `PUT /meta` accepts no `force`"), so a `?force=true` there is diff --git a/packages/rest/src/meta-item-write-routes.ts b/packages/rest/src/meta-item-write-routes.ts index 395d5e6ec2c..4c937600e1e 100644 --- a/packages/rest/src/meta-item-write-routes.ts +++ b/packages/rest/src/meta-item-write-routes.ts @@ -263,8 +263,12 @@ export function registerMetaItemWriteRoutes(this: MetaItemWriteRouteHost, scope: // differently. Its `request` members are spread into the // save below, each present only when the caller asked. // + // [#24395] …and `?surface=` (`studio` | `api`; `copilot` is + // refused there), the authoring surface a draft save stamps + // on each key path it changes. + // // The multiplicity guard runs FIRST: it refuses a repeated - // `force`, `package` or `mode` (see the `force` read below + // `force`, `package`, `mode` or `surface` (see the `force` read below // for why that one is sharp) and UNWRAPS one occurrence // encoded as an array, so `?mode=` reaches the mapping as // the string it reads. @@ -275,7 +279,7 @@ export function registerMetaItemWriteRoutes(this: MetaItemWriteRouteHost, scope: // located 400 here, before anything is written, never // dropped while the body's own member is saved. if (refuseUnknownQueryParams(req, res, META_ITEM_SAVE_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return; + if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode', 'surface'])) return; const saveOptions = metaSaveRequestOptions({ headers: req.headers, query: req.query }); if (!saveOptions.ok) { res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: saveOptions.message } }); diff --git a/packages/rest/src/meta-save-request.ts b/packages/rest/src/meta-save-request.ts index 3829bd69d85..bd9129f2a2a 100644 --- a/packages/rest/src/meta-save-request.ts +++ b/packages/rest/src/meta-save-request.ts @@ -50,7 +50,26 @@ * leaves the active row alone. Any other value is an active save, as it has * always been on `RestServer`. * - * `query.mode` is read as the string a door has already judged for + * ## The authoring surface (#24395) + * + * `?surface=` is the save's `surface` (`SaveMetaItemRequestSchema`): which + * surface the save is made through, stamped by a draft save on every key path + * it changes and served on the strict draft read as `draftChanges[].by`. + * Absent, the protocol stamps `api`. A query parameter, as `?mode=` and + * `?force=` are: those are what this save is ASKED to do, while the headers + * above are HTTP's own preconditions; and a query name needs no CORS + * allow-list entry, which a custom request header from a browser on another + * origin would. + * + * Its value set is CLOSED and narrower than the schema's: `studio` or `api`, + * exactly. `copilot` is refused, because it is the mark of an in-process AI + * tool's write and a remote caller cannot be one — an HTTP request saying + * `copilot` would mark its own write as the AI's in Studio. Every other value + * is refused too (case included), never read as `api`: a misspelt `Studio` + * dropped to `api` would record the person's edit as some other caller's. + * Both refusals are `400`, as the precondition's are; neither door mints a code. + * + * `query.mode` and `query.surface` are read as the strings a door has already judged for * multiplicity. `RestServer` calls this AFTER `refuseRepeatedQueryParams`, * which refuses a repeated `mode` and unwraps one occurrence encoded as an * array; the `@objectstack/hono` catch-all flattens its query from the URL, one @@ -68,7 +87,10 @@ export interface MetaSaveRequestHttp { } /** The `saveMetaItem` members {@link metaSaveRequestOptions} answers, each present only when the request asked for it. */ -export type MetaSaveRequestMembers = Pick; +export type MetaSaveRequestMembers = Pick; + +/** [#24395] The `?surface=` values an HTTP save may state — the schema's set less `copilot`. */ +const HTTP_SAVE_SURFACES: ReadonlySet = new Set(['studio', 'api']); /** {@link metaSaveRequestOptions}'s answer: the members to spread into the save, or the sentence of a `400`. */ export type MetaSaveRequestOptions = @@ -117,11 +139,30 @@ export function metaSaveRequestOptions(http: MetaSaveRequestHttp): MetaSaveReque } const mode = http.query?.mode; const draft = typeof mode === 'string' && mode.toLowerCase() === 'draft'; + const surface = http.query?.surface; + if (surface !== undefined) { + if (surface === 'copilot') { + return { + ok: false, + message: '?surface=copilot is not accepted over HTTP: it marks a write as the AI copilot\'s, ' + + 'which only an AI tool calling the save in-process may state. Send ?surface=studio for a ' + + 'person editing in Studio, ?surface=api for any other caller, or omit it (recorded as api).', + }; + } + if (typeof surface !== 'string' || !HTTP_SAVE_SURFACES.has(surface)) { + return { + ok: false, + message: `?surface= takes studio or api, exactly; got ${JSON.stringify(surface)}. ` + + 'Omit it for a save that is not made in Studio (recorded as api).', + }; + } + } return { ok: true, request: { ...(parentVersion !== undefined ? { parentVersion } : {}), ...(draft ? { mode: 'draft' as const } : {}), + ...(surface !== undefined ? { surface: surface as 'studio' | 'api' } : {}), }, }; } diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 7406fd2c61d..25cbfb0c3ac 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -59,7 +59,8 @@ import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-pr // chain, the deprecated `?layers=` flag's parse and the headers that flag is // served under. // [#22141] …and the save door's precondition and lifecycle -// (`metaSaveRequestOptions`): `If-Match` / `If-None-Match: *` and `?mode=draft`. +// (`metaSaveRequestOptions`): `If-Match` / `If-None-Match: *` and `?mode=draft` +// — [#24395] and `?surface=` (`studio` | `api`, `copilot` refused). // [#22188] …and the one reading of `?package=` (`metaItemPackageBinding`), // under which `all` names no package, on every branch here that reads it. // [#23072] …and the by-name doors' query decision (`metaItemQueryRefusal`, @@ -1581,7 +1582,10 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin ...(packageId ? { packageId } : {}), // [#22141] `parentVersion` and `mode`, each present // only when the caller asked — read off the headers and - // the query above, never off `item`. + // the query above, never off `item`. [#24395] So is + // `surface`: `?surface=studio|api`, the authoring + // surface a draft save stamps per key path, and never + // `copilot`, which the mapping refuses on this door. ...saveOptions.request, ...(actor ? { actor } : {}), }); From 1fbb4de8e3391808ebc1c86df23d87c6b4142487 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:05:02 +0000 Subject: [PATCH 05/14] test: pin draftChanges' record, keep and serve rules and the ?surface= doors Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- .../src/draft-changes.test.ts | 130 ++++++++++++++++++ .../protocol.packageless-save-address.test.ts | 120 ++++++++++++++++ .../rest/src/meta-item-query-decision.test.ts | 11 +- .../rest-server-meta-door-closed-sets.test.ts | 52 ++++++- .../domains/meta-item-query-decision.test.ts | 4 +- .../meta-save-preconditions-parity.test.ts | 78 +++++++++++ 6 files changed, 387 insertions(+), 8 deletions(-) create mode 100644 packages/metadata-protocol/src/draft-changes.test.ts diff --git a/packages/metadata-protocol/src/draft-changes.test.ts b/packages/metadata-protocol/src/draft-changes.test.ts new file mode 100644 index 00000000000..55c385cf99b --- /dev/null +++ b/packages/metadata-protocol/src/draft-changes.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24395] The draft save's per-key provenance, as three pure functions — see + * `draft-changes.ts`. The save and read doors that call them are pinned + * through the real protocol in `protocol.packageless-save-address.test.ts`'s + * `[#24395]` block; these rows pin the rules themselves: the path grammar + * (object keys only, an array is one leaf, `[]` is the whole body), the keep + * rule (replace at or under), and the stored form (never `[]`). + */ +import { describe, expect, it } from 'vitest'; +import { GetMetaItemResponseSchema } from '@objectstack/spec/api'; +import { draftChangedPaths, mergeDraftChanges, storedDraftChanges, type DraftChangeEntry } from './draft-changes.js'; + +const AT = '2026-10-18T12:00:00.000Z'; +const AT2 = '2026-10-18T12:05:00.000Z'; + +describe('draftChangedPaths — which key paths a draft save changed', () => { + it('[] when nothing was replaced: the save created the item as a draft', () => { + expect(draftChangedPaths(undefined, { name: 'v', label: 'L' }, 'view')).toEqual([[]]); + }); + + it('stops at the deepest key whose value differs, added or removed', () => { + const before = { name: 'o', fields: { a: { type: 'text', label: 'A' } }, description: 'd' }; + const after = { name: 'o', fields: { a: { type: 'text', label: 'A2' }, b: { type: 'number' } } }; + expect(draftChangedPaths(before, after, 'object')).toEqual([ + ['fields', 'a', 'label'], + ['fields', 'b'], + ['description'], + ]); + }); + + it('an array is one leaf: its path ends at its key and carries no index', () => { + const before = { columns: [{ field: 'name' }], kanban: { columns: ['a'] } }; + const after = { columns: [{ field: 'name' }, { field: 'email' }], kanban: { columns: ['a', 'b'] } }; + expect(draftChangedPaths(before, after, 'view')).toEqual([['columns'], ['kanban', 'columns']]); + }); + + it('a segment is the key as stored, dots included, never one dotted string', () => { + const before = { fields: { 'crm_lead.email': { readable: true } } }; + const after = { fields: { 'crm_lead.email': { readable: false } } }; + expect(draftChangedPaths(before, after, 'permission')).toEqual([['fields', 'crm_lead.email', 'readable']]); + }); + + it('an unchanged body, or a key order change in a map whose order is not content, changes nothing', () => { + expect(draftChangedPaths({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }, 'view')).toEqual([]); + expect(draftChangedPaths({ a: 1, b: 2 }, { b: 2, a: 1 }, 'view')).toEqual([]); + }); + + it("a pure reorder of a declared ordered map (an object's `fields`) is a change at the map", () => { + const before = { fields: { a: { type: 'text' }, b: { type: 'text' } } }; + const after = { fields: { b: { type: 'text' }, a: { type: 'text' } } }; + expect(draftChangedPaths(before, after, 'object')).toEqual([['fields']]); + // The same reorder in a type with no ordered map is no change, as its hash says. + expect(draftChangedPaths(before, after, 'view')).toEqual([]); + }); + + it('a change under an empty key is reported at its parent: an empty segment is no path', () => { + expect(draftChangedPaths({ m: { '': 1, x: 1 } }, { m: { '': 2, x: 2 } }, 'view')).toEqual([['m']]); + }); + + it('every path it answers is one the spec admits', () => { + const paths = draftChangedPaths({ a: { b: 1 } }, { a: { b: 2 }, c: [1] }, 'view'); + const parsed = GetMetaItemResponseSchema.safeParse({ + type: 'view', name: 'v', item: {}, + draftChanges: paths.map((path) => ({ path, by: 'studio', actor: null, at: AT })), + }); + expect(parsed.success).toBe(true); + }); +}); + +describe('mergeDraftChanges — no two entries share a path; the longest prefix answers', () => { + const copilot = { by: 'copilot' as const, actor: 'u_1', at: AT }; + const studio = { by: 'studio' as const, actor: 'u_1', at: AT2 }; + + it('a save replaces every entry at or under each path it changes', () => { + const entries = mergeDraftChanges([], [['fields', 'a', 'label'], ['fields', 'b'], ['label']], copilot); + const next = mergeDraftChanges(entries, [['fields']], studio); + expect(next).toEqual([ + { path: ['label'], ...copilot }, + { path: ['fields'], ...studio }, + ]); + }); + + it('an entry above a changed path is kept: a key under it is answered by the deeper entry', () => { + const entries = mergeDraftChanges([], [['kanban']], copilot); + const next = mergeDraftChanges(entries, [['kanban', 'columns']], studio); + expect(next).toEqual([ + { path: ['kanban'], ...copilot }, + { path: ['kanban', 'columns'], ...studio }, + ]); + }); + + it('a sibling path is not under a changed path: a shared first segment is not a prefix', () => { + const entries = mergeDraftChanges([], [['fields', 'ab']], copilot); + expect(mergeDraftChanges(entries, [['fields', 'a']], studio)).toHaveLength(2); + }); + + it('the whole body ([]) replaces every entry', () => { + const entries = mergeDraftChanges([], [['a'], ['b', 'c']], copilot); + expect(mergeDraftChanges(entries, [[]], studio)).toEqual([{ path: [], ...studio }]); + }); + + it('re-saving the same path keeps one entry, restamped', () => { + const entries = mergeDraftChanges([], [['label']], copilot); + expect(mergeDraftChanges(entries, [['label']], studio)).toEqual([{ path: ['label'], ...studio }]); + }); +}); + +describe('storedDraftChanges — the stored form, never []', () => { + const entry: DraftChangeEntry = { path: ['label'], by: 'copilot', actor: null, at: AT }; + + it('reads the serialized column and an already-parsed value alike', () => { + expect(storedDraftChanges(JSON.stringify([entry]))).toEqual([entry]); + expect(storedDraftChanges([entry])).toEqual([entry]); + }); + + it.each([ + ['NULL (a row saved before the writer)', null], + ['an absent column', undefined], + ['an empty string', ''], + ['an empty list', '[]'], + ['not JSON', '{'], + ['an unknown surface', JSON.stringify([{ ...entry, by: 'robot' }])], + ['an empty segment', JSON.stringify([{ ...entry, path: [''] }])], + ['a dotted-string path', JSON.stringify([{ ...entry, path: 'a.b' }])], + ])('%s reads as not recorded (undefined), never []', (_label, raw) => { + expect(storedDraftChanges(raw)).toBeUndefined(); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts index de5fc27b010..da28eec00ca 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -64,6 +64,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, isCodeArtifactBody } from '@objectstack/metadata-core'; import { applyProtection } from '@objectstack/spec/shared'; +import { GetMetaItemResponseSchema } from '@objectstack/spec/api'; import { resolveThrownHttpError } from '@objectstack/types'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; @@ -1366,3 +1367,122 @@ describe('[#23715] the strict draft read naming no package serves its content fr expect(read.version).toBeNull(); }); }); + +/** + * [#24395] The draft save records `draftChanges` and the strict draft read + * serves it — through the real save, read, publish and discard doors over this + * file's row-keeping engine (the write path is the same `resolveWriteHead` + * transaction the package-address rows above pin). The rules themselves (path + * grammar, keep rule, stored form) are `draft-changes.test.ts`'s. + */ +describe('[#24395] the draft save records per-key provenance, the strict draft read serves it', () => { + const view = (name: string, extra: Record = {}) => ({ ...viewBody(name, 'Base'), ...extra }); + const TWO_COLUMNS = [{ field: 'name', label: 'Name' }, { field: 'title', label: 'Title' }]; + const readDraft = (protocol: any, name: string) => protocol.getMetaItem({ type: 'view', name, state: 'draft' }); + const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + + it('each surface is stamped as `by`; an absent surface stamps `api`; the actor is the save\'s', async () => { + const { protocol } = makeProtocol(); + for (const surface of ['copilot', 'studio', 'api', undefined] as const) { + const name = `v_${surface ?? 'none'}`; + await protocol.saveMetaItem({ + type: 'view', name, item: view(name), mode: 'draft', actor: 'u_1', ...(surface ? { surface } : {}), + }); + const read = await readDraft(protocol, name); + expect(read.draftChanges).toEqual([{ path: [], by: surface ?? 'api', actor: 'u_1', at: expect.stringMatching(ISO_Z) }]); + expect(GetMetaItemResponseSchema.safeParse(read).success).toBe(true); + } + // No actor recorded → `null`, never a sentinel. + await protocol.saveMetaItem({ type: 'view', name: 'v_anon', item: view('v_anon'), mode: 'draft' }); + expect((await readDraft(protocol, 'v_anon')).draftChanges[0].actor).toBeNull(); + }); + + it('the first save is compared with the active row; the second replaces at or under what it changes', async () => { + const { protocol } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_b', item: view('v_b', { description: 'pub' }) }); + await protocol.saveMetaItem({ + type: 'view', name: 'v_b', mode: 'draft', surface: 'copilot', actor: 'u_1', + item: view('v_b', { description: 'pub', label: 'AI', columns: TWO_COLUMNS }), + }); + const first = (await readDraft(protocol, 'v_b')).draftChanges; + expect(first.map((e: any) => [e.path, e.by])).toEqual([[['label'], 'copilot'], [['columns'], 'copilot']]); + await protocol.saveMetaItem({ + type: 'view', name: 'v_b', mode: 'draft', surface: 'studio', actor: 'u_1', + item: view('v_b', { description: 'pub', label: 'Hand', columns: TWO_COLUMNS }), + }); + const second = (await readDraft(protocol, 'v_b')).draftChanges; + // `columns` keeps the copilot's entry; `label` is restamped once, never twice. + expect(second.map((e: any) => [e.path, e.by])).toEqual([[['columns'], 'copilot'], [['label'], 'studio']]); + expect(second[0]).toEqual(first[1]); + }); + + it('a save that changes no key path leaves the entries as they were', async () => { + const { protocol, rows } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_same', item: view('v_same'), mode: 'draft', surface: 'copilot' }); + const stored = rows('view', 'v_same', 'draft')[0].draft_changes; + await protocol.saveMetaItem({ type: 'view', name: 'v_same', item: view('v_same'), mode: 'draft', surface: 'studio' }); + expect(rows('view', 'v_same', 'draft')[0].draft_changes).toBe(stored); + }); + + it('ABSENT on the active read, the ?preview=draft read, and a draft row saved before the writer', async () => { + const { protocol, seedRow } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_abs', item: view('v_abs') }); + await protocol.saveMetaItem({ type: 'view', name: 'v_abs', item: view('v_abs', { label: 'D' }), mode: 'draft', surface: 'studio' }); + expect((await readDraft(protocol, 'v_abs')).draftChanges).toHaveLength(1); + expect('draftChanges' in (await protocol.getMetaItem({ type: 'view', name: 'v_abs' }))).toBe(false); + expect('draftChanges' in (await protocol.getMetaItem({ type: 'view', name: 'v_abs', previewDrafts: true }))).toBe(false); + seedRow({ type: 'view', name: 'v_legacy', state: 'draft', metadata: JSON.stringify(view('v_legacy')), checksum: null }); + const legacy = await readDraft(protocol, 'v_legacy'); + expect(legacy.item.name).toBe('v_legacy'); + expect('draftChanges' in legacy).toBe(false); + }); + + it('publish and discard leave no entry behind: the next draft starts from the published body', async () => { + const { protocol, rows } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_p', item: view('v_p') }); + await protocol.saveMetaItem({ type: 'view', name: 'v_p', item: view('v_p', { label: 'AI' }), mode: 'draft', surface: 'copilot' }); + await protocol.publishMetaItem({ type: 'view', name: 'v_p' }); + expect(rows('view', 'v_p', 'draft')).toEqual([]); + expect(rows('view', 'v_p', 'active').map((r) => r.draft_changes ?? null)).toEqual([null]); + await protocol.saveMetaItem({ type: 'view', name: 'v_p', item: view('v_p', { label: 'AI', description: 'd' }), mode: 'draft', surface: 'studio' }); + expect((await readDraft(protocol, 'v_p')).draftChanges.map((e: any) => [e.path, e.by])).toEqual([[['description'], 'studio']]); + + expect((await protocol.deleteMetaItem({ type: 'view', name: 'v_p', state: 'draft' })).reset).toBe(true); + expect(rows('view', 'v_p', 'draft')).toEqual([]); + await protocol.saveMetaItem({ type: 'view', name: 'v_p', item: view('v_p', { label: 'AI', columns: TWO_COLUMNS }), mode: 'draft' }); + expect((await readDraft(protocol, 'v_p')).draftChanges.map((e: any) => [e.path, e.by])).toEqual([[['columns'], 'api']]); + }); + + it('a draft whose body equals the published one records nothing, and the read answers it ABSENT, never []', async () => { + const { protocol } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_eq', item: view('v_eq') }); + await protocol.saveMetaItem({ type: 'view', name: 'v_eq', item: view('v_eq'), mode: 'draft', surface: 'copilot' }); + const read = await readDraft(protocol, 'v_eq'); + expect(read.item.name).toBe('v_eq'); + expect('draftChanges' in read).toBe(false); + }); + + it('a `migrate-stored` rewrite of a draft row records no path: it is not authoring', async () => { + const { protocol, rows } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_m', item: view('v_m'), mode: 'draft', surface: 'copilot' }); + const stored = rows('view', 'v_m', 'draft')[0].draft_changes; + await protocol.saveMetaItem({ type: 'view', name: 'v_m', item: view('v_m', { label: 'Rewritten' }), mode: 'draft', source: 'migrate-stored' }); + expect(JSON.parse(rows('view', 'v_m', 'draft')[0].metadata).label).toBe('Rewritten'); + expect(rows('view', 'v_m', 'draft')[0].draft_changes).toBe(stored); + }); + + it('an active save records nothing', async () => { + const { protocol, rows } = makeProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'v_a', item: view('v_a'), surface: 'studio' }); + expect(rows('view', 'v_a', 'active')[0].draft_changes ?? null).toBeNull(); + }); + + it('a surface outside the closed set is refused 400 INVALID_REQUEST before anything is written', async () => { + const { protocol, metadataWrites } = makeProtocol(); + const envelope = resolveThrownHttpError(await refusalOf(() => protocol.saveMetaItem({ + type: 'view', name: 'v_bad', item: view('v_bad'), mode: 'draft', surface: 'robot', + }))); + expect(envelope).toMatchObject({ status: 400, code: 'INVALID_REQUEST' }); + expect(metadataWrites()).toEqual([]); + }); +}); diff --git a/packages/rest/src/meta-item-query-decision.test.ts b/packages/rest/src/meta-item-query-decision.test.ts index 28d7a7debf4..507f28e131d 100644 --- a/packages/rest/src/meta-item-query-decision.test.ts +++ b/packages/rest/src/meta-item-query-decision.test.ts @@ -35,22 +35,25 @@ const LAYERED_MEMBER = 'The "member" selector is not served by the layered view const refusal = (rule: string, message: string) => ({ status: 400, code: 'VALIDATION_ERROR', message, rule }); describe('[#23072] metaItemQueryRefusal — each door\'s closed set', () => { - it('the measured sets: the item read\'s six, `/layers`\' one, `/published`\' none, and the dispatcher save\'s two (no `force`)', () => { + it('the measured sets: the item read\'s six, `/layers`\' one, `/published`\' none, and the dispatcher save\'s three (no `force`)', () => { expect([...META_ITEM_READ_PARAMS].sort()).toEqual(['layers', 'locale', 'member', 'package', 'preview', 'state']); expect([...META_ITEM_LAYERS_PARAMS]).toEqual(['package']); expect([...META_ITEM_PUBLISHED_PARAMS]).toEqual([]); // RestServer's own save door reads `force`; the dispatcher's does not. - expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package']); + // [#24395] `surface` joined both save doors' sets. + expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package', 'surface']); expect(metaItemQueryRefusal('save', { force: 'true' })).toEqual(refusal( - 'unknown-parameter', unknownQueryParamMessage(['force'], ['mode', 'package']), + 'unknown-parameter', unknownQueryParamMessage(['force'], ['mode', 'package', 'surface']), )); + expect(metaItemQueryRefusal('save', { surface: 'studio', mode: 'draft' })).toBeUndefined(); + expect(metaItemQueryRefusal('save', { surface: ['studio', 'api'] })?.rule).toBe('repeated-parameter'); }); const DOORS = [ { door: 'item', set: META_ITEM_READ_PARAMS }, { door: 'layers', set: META_ITEM_LAYERS_PARAMS }, { door: 'published', set: META_ITEM_PUBLISHED_PARAMS }, - { door: 'save', set: ['package', 'mode'] }, + { door: 'save', set: ['package', 'mode', 'surface'] }, ] as const; for (const { door, set } of DOORS) { diff --git a/packages/rest/src/rest-server-meta-door-closed-sets.test.ts b/packages/rest/src/rest-server-meta-door-closed-sets.test.ts index b71e099432d..d22fd570408 100644 --- a/packages/rest/src/rest-server-meta-door-closed-sets.test.ts +++ b/packages/rest/src/rest-server-meta-door-closed-sets.test.ts @@ -116,7 +116,7 @@ const DOORS = [ describe('[#23072] each by-name /meta door outside the ruling\'s list declares a closed query set', () => { it('the measured sets — none carries the member selector', () => { - expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package']); + expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package', 'surface']); expect([...META_ITEM_REFERENCES_PARAMS]).toEqual([]); expect([...META_ITEM_LAYERS_PARAMS]).toEqual(['package']); expect([...META_ITEM_AUDIT_PARAMS]).toEqual(['limit']); @@ -150,7 +150,7 @@ describe('[#23072] each by-name /meta door outside the ruling\'s list declares a const answer = await drive('PUT', ITEM, { member: 'zh-CN', mode: 'draft' }, EN); expect(answer.status).toBe(400); expect(answer.body?.error?.message).toMatch(/^The "member" query parameter is not supported by this endpoint\./); - expect(answer.body?.error?.message).toContain('Supported parameters: force, mode, package.'); + expect(answer.body?.error?.message).toContain('Supported parameters: force, mode, package, surface.'); expect(protocol.saveMetaItem).not.toHaveBeenCalled(); }); @@ -217,3 +217,51 @@ describe('[#23072] PRESERVATION — every name each door reads still reaches its expect(answer.body).toEqual(EN); }); }); + +/** + * [#24395] `?surface=` on `PUT /meta/:type/:name`: the save's authoring + * surface, read through `metaSaveRequestOptions` (the runtime dispatcher's + * half is `meta-save-preconditions-parity.test.ts`'s). Both halves per value: + * the arguments the protocol received, or a located 400 and no call at all. + */ +describe('[#24395] ?surface= on the REST save door: studio and api reach the save, copilot never does', () => { + for (const surface of ['studio', 'api'] as const) { + it(`PRESERVATION — ?surface=${surface} reaches saveMetaItem as \`surface\`, beside \`mode\``, async () => { + const { protocol, drive } = boot(); + const answer = await drive('PUT', ITEM, { mode: 'draft', surface }, EN); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1); + expect(protocol.saveMetaItem.mock.calls[0][0]).toMatchObject({ mode: 'draft', surface }); + }); + } + + it('PRESERVATION — no ?surface= sends no `surface` member: the protocol stamps api', async () => { + const { protocol, drive } = boot(); + expect((await drive('PUT', ITEM, { mode: 'draft' }, EN)).status).toBe(200); + expect('surface' in protocol.saveMetaItem.mock.calls[0][0]).toBe(false); + }); + + for (const [label, surface] of [ + ['copilot — an in-process tool\'s mark, never a remote caller\'s', 'copilot'], + ['a case variant, never folded to studio', 'Studio'], + ['an unknown value, never read as api', 'robot'], + ['an empty value', ''], + ] as const) { + it(`REFUSAL — ?surface= ${label}: 400 VALIDATION_ERROR, nothing saved`, async () => { + const { protocol, drive } = boot(); + const answer = await drive('PUT', ITEM, { mode: 'draft', surface }, EN); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toContain('surface'); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + }); + } + + it('REFUSAL — a repeated ?surface= is refused by the arity rule, nothing saved', async () => { + const { protocol, drive } = boot(); + const answer = await drive('PUT', ITEM, { surface: ['studio', 'api'] }, EN); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/meta-item-query-decision.test.ts b/packages/runtime/src/domains/meta-item-query-decision.test.ts index 239e086884e..dafe4e1b309 100644 --- a/packages/runtime/src/domains/meta-item-query-decision.test.ts +++ b/packages/runtime/src/domains/meta-item-query-decision.test.ts @@ -332,7 +332,7 @@ describe('[#23072] GET /meta/:type/:name/published on the dispatcher: its closed // ── PUT ─────────────────────────────────────────────────────────────────────── -describe('[#23072] PUT /meta/:type/:name on the dispatcher: its closed set is `package` and `mode`', () => { +describe('[#23072] PUT /meta/:type/:name on the dispatcher: its closed set is `package`, `mode` and [#24395] `surface`', () => { const ZH_BODY = MEMBERS['zh-CN']; const EN_BODY = MEMBERS['en-US']; @@ -342,7 +342,7 @@ describe('[#23072] PUT /meta/:type/:name on the dispatcher: its closed set is `p expect(answer.status).toBe(400); expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); expect(answer.body?.error?.message).toContain('"member" query parameter is not supported'); - expect(answer.body?.error?.message).toContain('Supported parameters: mode, package.'); + expect(answer.body?.error?.message).toContain('Supported parameters: mode, package, surface.'); expect(protocol.saveMetaItem).not.toHaveBeenCalled(); }); diff --git a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts index 96bc9ac10da..3cb7f77608e 100644 --- a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts +++ b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts @@ -119,6 +119,8 @@ interface SaveOptions { headers?: Record; query?: Record; stored(name: string, state: 'active' | 'draft'): Promise; + /** [#24395] The item read through THIS door, with `query` (`{ state: 'draft' }` for the strict draft read). */ + read(name: string, query?: Record): Promise<{ status: number; envelope: any }>; } /** The refusal's `code` in either door's envelope: nested under `error`, or beside a string `error`. */ @@ -148,8 +150,17 @@ async function bootRestServer(): Promise { rest.registerRoutes(); const route = rest.getRoutes().find((r: any) => r.method === 'PUT' && r.path === `${META}/:type/:name`); if (!route) throw new Error(`PUT ${META}/:type/:name is not registered`); + const getRoute = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `${META}/:type/:name`); + if (!getRoute) throw new Error(`GET ${META}/:type/:name is not registered`); return { stored, + read: async (name, query = {}) => { + const res = makeRes(); + await getRoute.handler({ + method: 'GET', path: `${META}/view/${name}`, params: { type: 'view', name }, headers: {}, query, + }, res); + return { status: res.statusCode, envelope: res.body }; + }, save: async (name, label, { headers = {}, query = {} } = {}) => { const res = makeRes(); await route.handler({ @@ -206,6 +217,12 @@ async function bootCatchAll(): Promise { const dispatcher = dispatcherOver({ protocol }); return { stored, + read: async (name, query = {}) => { + const url = new URL(`http://localhost${META}/view/${name}`); + for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v); + const res = await catchAll(dispatcher, new Request(url, { method: 'GET' })); + return { status: res.response?.status ?? 0, envelope: res.response?.body?.data }; + }, save: async (name, label, opts = {}) => { const res = await catchAll(dispatcher, putRequest(name, label, opts)); const body = res.response?.body; @@ -342,3 +359,64 @@ describe('[#22141] the catch-all\'s fallback writer cannot carry a pin or a life expect(saveItem.mock.calls[0].slice(0, 2)).toEqual(['view', 'case_grid']); }); }); + +// ── [#24395] ?surface= and the strict draft read's draftChanges, on each door ─ + +for (const [doorName, boot] of DOORS) { + describe(`[#24395] ${doorName}: ?surface= is stamped per key path, and the strict draft read serves it`, () => { + const changes = async (door: Door, name: string) => { + const answer = await door.read(name, { state: 'draft' }); + expect(answer.status, JSON.stringify(answer.envelope)).toBe(200); + return answer.envelope.draftChanges; + }; + + it('studio, api and none each stamp `by` on the draft\'s first save; the actor is the caller\'s', async () => { + const door = await boot(); + for (const [name, query] of [ + ['s_studio', { mode: 'draft', surface: 'studio' }], + ['s_api', { mode: 'draft', surface: 'api' }], + ['s_none', { mode: 'draft' }], + ] as const) { + ok(await door.save(name, 'staged', { query })); + const entries = await changes(door, name); + expect(entries).toEqual([{ + path: [], by: 'surface' in query ? query.surface : 'api', actor: AUTHOR.userId, + at: expect.stringMatching(/Z$/), + }]); + } + }, 60_000); + + it('over a published row the first draft save records only the keys it changed, and a second save replaces at the path', async () => { + const door = await boot(); + ok(await door.save('s_base', 'live')); + ok(await door.save('s_base', 'by api', { query: { mode: 'draft', surface: 'api' } })); + const first = await changes(door, 's_base'); + expect(first.map((e: any) => [e.path, e.by])).toEqual([[['label'], 'api']]); + ok(await door.save('s_base', 'by studio', { query: { mode: 'draft', surface: 'studio' } })); + expect((await changes(door, 's_base')).map((e: any) => [e.path, e.by])).toEqual([[['label'], 'studio']]); + }, 60_000); + + it('ABSENT on the active read and on ?preview=draft', async () => { + const door = await boot(); + ok(await door.save('s_abs', 'live')); + ok(await door.save('s_abs', 'staged', { query: { mode: 'draft', surface: 'studio' } })); + expect(await changes(door, 's_abs')).toHaveLength(1); + for (const query of [{}, { preview: 'draft' }]) { + const answer = await door.read('s_abs', query); + expect(answer.status, JSON.stringify(query)).toBe(200); + expect('draftChanges' in (answer.envelope ?? {}), JSON.stringify(query)).toBe(false); + } + }, 60_000); + + it('?surface=copilot is refused 400 VALIDATION_ERROR and writes nothing: only an in-process tool states it', async () => { + const door = await boot(); + const refused = await door.save('s_cop', 'never', { query: { mode: 'draft', surface: 'copilot' } }); + expect({ status: refused.status, code: refused.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + expect(refused.message).toContain('copilot'); + const misspelt = await door.save('s_cop', 'never', { query: { mode: 'draft', surface: 'Studio' } }); + expect({ status: misspelt.status, code: misspelt.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + expect(await door.stored('s_cop', 'draft')).toEqual([]); + expect(await door.stored('s_cop', 'active')).toEqual([]); + }, 60_000); + }); +} From e3c9b55b5341323afc45cf1fb6eaec32c5a8811e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:05:02 +0000 Subject: [PATCH 06/14] chore(changeset): draftChanges writer and the save's surface Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- .changeset/24298-draft-key-provenance.md | 2 +- .changeset/24395-draft-changes-surface.md | 11 +++++++++++ .changeset/24395-draft-changes-writer.md | 14 ++++++++++++++ .changeset/24395-http-save-surface.md | 12 ++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .changeset/24395-draft-changes-surface.md create mode 100644 .changeset/24395-draft-changes-writer.md create mode 100644 .changeset/24395-http-save-surface.md diff --git a/.changeset/24298-draft-key-provenance.md b/.changeset/24298-draft-key-provenance.md index fa6692cebd3..035f34e29b5 100644 --- a/.changeset/24298-draft-key-provenance.md +++ b/.changeset/24298-draft-key-provenance.md @@ -8,5 +8,5 @@ Clause-②: yes (widening) - **The key.** `GetMetaItemResponseSchema` gains an optional `draftChanges` beside `item`: one entry per changed key path of the pending draft body, each `{ path, by, actor, at }`. `path` is an array of the keys from the body root to the deepest key the write changed (`["fields", "renewal_count"]`, `["fields", "renewal_count", "label"]`); an array value is one leaf, so a change to a view's columns is `["columns"]` and never carries an index (which elements differ is read by comparing the array with the published body's; the entry says who last changed the array); `[]` is the whole body of an item created as a draft. `by` is a closed set: `copilot` (an AI assistant's tool writing on a person's behalf), `studio` (a person editing in Studio) or `api` (any other caller of the save door). `actor` is the write's recorded identity or `null`, and `at` its ISO-8601 instant. - **Where it is served.** Only on the strict draft read (`GET /meta/:type/:name?state=draft`), the read Studio's editor loads an item from, so the marks always describe the body that read returned. It is absent on the active read, the cached branch and the `?preview=draft` read. `ListDraftsResponseSchema` (`GET /meta/_drafts`) is unchanged. -- **Absent means "not recorded".** No server writes the key yet; until one does, and for a draft row saved before it did, the key is absent rather than `[]`, so a reader never takes "not recorded" for "nothing changed". +- **Absent means "not recorded".** For a draft row whose saves recorded nothing (one saved before a server recorded the key), the key is absent rather than `[]`, so a reader never takes "not recorded" for "nothing changed". - **Nothing breaks.** Every response that parsed before still parses, and no runtime behaviour changes in this release of the spec. diff --git a/.changeset/24395-draft-changes-surface.md b/.changeset/24395-draft-changes-surface.md new file mode 100644 index 00000000000..641b1af8bae --- /dev/null +++ b/.changeset/24395-draft-changes-surface.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): a metadata save declares its authoring surface — `SaveMetaItemRequestSchema.surface`, in one exported closed set shared with `draftChanges[].by` + +Clause-②: yes (widening) + +- **One closed set, one name.** `MetadataAuthoringSurfaceSchema` (`copilot | studio | api`) is exported from `@objectstack/spec/api`, with its type `MetadataAuthoringSurface`. `GetMetaItemResponseSchema.draftChanges[].by` now reads it instead of an inline enum: the accepted values are unchanged. +- **The save states its surface.** `SaveMetaItemRequestSchema` gains an optional `surface` from that set. A draft save stamps it as `by` on each key path it changes; an absent `surface` stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` only: `copilot` is stated by an in-process AI tool, never over HTTP. +- **The writer exists.** The `draftChanges` describe no longer says no server writes it: it states the writer's rules (the first draft save is compared with the stored published row, an item with no stored published row is recorded as `[]`, and a `migrate-stored` rewrite records nothing). diff --git a/.changeset/24395-draft-changes-writer.md b/.changeset/24395-draft-changes-writer.md new file mode 100644 index 00000000000..4b19c57dfbf --- /dev/null +++ b/.changeset/24395-draft-changes-writer.md @@ -0,0 +1,14 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/metadata-core": minor +--- + +feat(metadata-protocol): the draft save records `draftChanges` — which surface, actor and instant last changed each key path of a pending draft — and the strict draft read serves it + +Clause-②: yes (widening) + +- **Recorded by the draft save.** `saveMetaItem({ mode: 'draft', surface })` compares the body it writes with the body it replaces, inside the write's own transaction: the draft row, else (the draft's first save) the active row its publish would replace, else nothing, which records `[]`, the whole body. A path is the object keys from the body root to the deepest changed key; an array is one leaf, with no index. Each changed path is stamped `{ by: surface ?? 'api', actor, at }`, where `actor` is the value the save's history event carries (or `null`) and `at` is the write's instant as `toISOString()`. +- **Kept until publish or discard.** No two entries share a path: a save replaces every entry at or under each path it changes, so the longest prefix answers for a key. A save that changes no key path leaves the entries as they were. Publish and discard delete the draft row, and the entries with it, so the next draft starts clean. A `migrate-stored` rewrite and an active save record nothing. +- **Served on the strict draft read only.** `getMetaItem({ state: 'draft' })` answering a stored draft row carries `draftChanges` beside `item`. It is absent on the active read, the cached branch, the `?preview=draft` read, and a draft row whose saves recorded none (one saved before this release) — never `[]`. +- **Stored on the row.** `sys_metadata` gains a nullable `draft_changes` column (`@objectstack/metadata-core`'s `SysMetadataObject`), never inside the stored body. Schema sync adds it to an existing table on boot with no manual step (measured on SQLite and the memory driver). +- **An unknown `surface` is refused.** In-process, a value outside `copilot | studio | api` is refused `400 INVALID_REQUEST` before anything is written. diff --git a/.changeset/24395-http-save-surface.md b/.changeset/24395-http-save-surface.md new file mode 100644 index 00000000000..22747fcea07 --- /dev/null +++ b/.changeset/24395-http-save-surface.md @@ -0,0 +1,12 @@ +--- +"@objectstack/rest": minor +"@objectstack/runtime": minor +--- + +feat(rest,runtime): `PUT /meta/:type/:name?surface=studio|api` declares the save's authoring surface on both HTTP save doors; `?surface=copilot` is refused + +Clause-②: yes (widening) + +- **The spelling.** `?surface=` joins the save door's closed query set on `RestServer` and on the runtime dispatcher (`META_ITEM_SAVE_PARAMS`), read once through `metaSaveRequestOptions` beside `?mode=`. A draft save stamps it on each key path it changes (`draftChanges[].by` on the strict draft read); without it the save is recorded as `api`. `?surface=studio` is the spelling for a person's save in Studio. +- **The refusals.** `?surface=copilot` is `400 VALIDATION_ERROR`: it marks a write as the AI copilot's, which only an in-process tool calling `saveMetaItem` may state. Any other value, a case variant included, is the same `400`, never read as `api`; a repeated `?surface=` is refused by the arity rule. Each refusal writes nothing. +- **Why a query parameter.** It is what the save is asked to do, as `?mode=` and `?force=` are, beside the HTTP preconditions the headers carry; and it needs no CORS allow-list entry, which a custom request header would. From 2dfbaacbf6df8dfb09e4035800b6e7cee5257e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:12:41 +0000 Subject: [PATCH 07/14] chore: regenerate spec artifacts and the sys_metadata i18n bundles Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 24 +++++++++++++++---- content/docs/references/index.mdx | 10 ++++---- .../apps/translations/en.objects.generated.ts | 4 ++++ .../translations/es-ES.objects.generated.ts | 4 ++++ .../translations/ja-JP.objects.generated.ts | 4 ++++ .../translations/zh-CN.objects.generated.ts | 4 ++++ packages/spec/api-surface/api.json | 2 ++ packages/spec/authorable-surface/api.json | 1 + packages/spec/declaration-map/api.json | 2 ++ packages/spec/export-origins/api.json | 2 ++ packages/spec/json-schema.manifest/api.json | 1 + 11 files changed, 48 insertions(+), 10 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 8b0e3e5bded..9feb760a153 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,7 +1,7 @@ --- title: Protocol schema — API Protocol reference navTitle: Protocol -description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 136 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 137 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilit ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -1457,7 +1457,7 @@ This schema accepts one of the following structures: | **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | -| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'studio' \| 'api'>; actor: string \| null; at: string }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. No server writes the key yet: until the engine's draft save records it, every draft read is one whose saves recorded none, and the key is absent. The contract a writer must meet: it compares each draft save with the body that save replaced (the previous draft, or the published body for the draft's first save); it keeps the entries from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and it keeps no two entries on one path, replacing every entry at or under a path a save changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | +| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'studio' \| 'api'>; actor: string \| null; at: string }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `GetMetaItemResponse.sortability` @@ -1491,7 +1491,7 @@ One changed key path of the pending draft, and the last write that changed it. | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **path** | `string[]` | ✅ | The changed key path inside `item`: one object key per segment, from the body root down to the deepest key whose value the write changed, added or removed. `["fields", "renewal_count"]` is a field the write added; `["fields", "renewal_count", "label"]` is one property of it. A segment is the key exactly as stored, dots included (a permission set's `fields` map is keyed `object.field`), so a path is never one dotted string. An array is one value: its path ends at its key (`["columns"]`) and never carries an index, which an insert or a reorder would shift. Which elements of an array the draft added or dropped is read by comparing it with the published body's array; the entry answers only who last changed the array as a whole. `[]` is the whole body: the write that created the draft of an item with no published body. | -| **by** | `Enum<'copilot' \| 'studio' \| 'api'>` | ✅ | The authoring surface of the write that last changed this path. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write and a Studio write of the same user carry the same `actor`. | +| **by** | `Enum<'copilot' \| 'studio' \| 'api'>` | ✅ | The authoring surface of the write that last changed this path: the `surface` that save declared (`SaveMetaItemRequestSchema.surface`), `api` when it declared none. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write and a Studio write of the same user carry the same `actor`. | | **actor** | `string \| null` | ✅ | The identity that write was recorded under: the save's `actor`, the value its history event carries. `null` when the write recorded none, never a sentinel string. | | **at** | `string` | ✅ | When that write happened: an ISO-8601 instant in UTC, written with `Z` (the form `toISOString()` produces). An offset or a local time is refused. | @@ -2393,6 +2393,19 @@ Installed package with runtime lifecycle state | **readCount** | `number` | ✅ | Number of notifications marked as read | +--- + +## MetadataAuthoringSurface + +The authoring surface of a metadata write. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write and a Studio write of the same user carry the same `actor`. + +### Allowed Values + +* `copilot` +* `studio` +* `api` + + --- ## MetadataConflictError @@ -2769,6 +2782,7 @@ Installed package with runtime lifecycle state | **mode** | `Enum<'draft' \| 'publish'>` | optional | Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a pending draft overlay (`?mode=draft` on the REST door; the publish door promotes it later); `publish` or ABSENT writes straight to the live `active` row — the legacy default, kept so callers that predate the draft/publish split keep working. Any value other than `draft` is read as `publish`. | | **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent or `null` names no package. An `active` save then writes the row of the one package the item's active rows are bound to, as a save naming that package does, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none writes the env-local overlay (no package stamp); a `draft` save writes the draft of the one package the item's active rows are bound to (a pending change stays in the package it edits), else its package-unbound draft, adopted into that package — the draft the per-item publish naming no package promotes. For an item never published into a package it is the package-unbound draft, else the one package's draft, and with no draft at all the save creates the package-unbound draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages, as that publish is (see `PublishMetaItemRequestSchema.packageId`). The row written is also the one the parent-version resolution reads: an unpinned save takes its version as the parent (last-write-wins), a pinned one is judged against it, and the item read at the same scope serves it as `version`. | | **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch' \| 'external-import'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher, the duplicate-package door and the external-table import have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. | +| **surface** | `Enum<'copilot' \| 'studio' \| 'api'>` | optional | The authoring surface this save is made through (see `MetadataAuthoringSurfaceSchema`). A draft save (`mode: 'draft'`) stamps it as `by` on every key path it changes, the entries the strict draft read serves as `draftChanges`; an active save records no per-key provenance. ABSENT stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` and refuses every other value `400 VALIDATION_ERROR`, `copilot` included: `copilot` is stated only by an in-process AI tool calling the save, never by a remote caller, so an HTTP request cannot mark its own write as the copilot's. A value outside the set in-process is refused `400 INVALID_REQUEST`, before anything is written. | --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index e418ad24497..ef1be7736fd 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1539 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1540 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 437 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 71 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 176 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1539** | 14 protocol modules | +| **Total** | **197** | **1540** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 437 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 438 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -91,7 +91,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 7a0942cb959..64a869802cb 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3076,6 +3076,10 @@ export const enObjects: NonNullable = { label: "Metadata", help: "JSON-serialized metadata payload" }, + draft_changes: { + label: "Draft Changes", + help: "JSON-serialized per-key provenance of a pending draft: which surface (copilot, studio, api), actor and time last changed each key path. Null on active rows." + }, extends: { label: "Extends" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index bc65351b76c..d661a276beb 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3076,6 +3076,10 @@ export const esESObjects: NonNullable = { label: "Metadatos", help: "Payload de metadatos serializado en JSON." }, + draft_changes: { + label: "Cambios del borrador", + help: "Procedencia por clave de un borrador pendiente (JSON serializado): qué superficie (copilot, studio, api), qué actor y cuándo cambió por última vez cada ruta de clave. Nulo en las filas activas." + }, extends: { label: "Extiende" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index f41fb422c2b..6fdd6de75f1 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3076,6 +3076,10 @@ export const jaJPObjects: NonNullable = { label: "メタデータ", help: "JSON シリアライズされたメタデータペイロード" }, + draft_changes: { + label: "下書きの変更", + help: "保留中の下書きのキー単位の出所(JSON シリアライズ):各キーパスを最後に変更したサーフェス(copilot、studio、api)、操作者、日時。公開済みの行では null。" + }, extends: { label: "継承元" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 2ee763c06f0..6177790f895 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3076,6 +3076,10 @@ export const zhCNObjects: NonNullable = { label: "元数据", help: "元数据负载的 JSON 序列化内容" }, + draft_changes: { + label: "草稿变更", + help: "待发布草稿的逐键来源(JSON 序列化):每个键路径最后由哪个来源(copilot、studio、api)、哪位操作者、在何时修改。已发布行为空。" + }, extends: { label: "继承自" }, diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 82d567ef207..4980634e587 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -621,6 +621,8 @@ "MarkNotificationsReadRequestSchema (const)", "MarkNotificationsReadResponse (type)", "MarkNotificationsReadResponseSchema (const)", + "MetadataAuthoringSurface (type)", + "MetadataAuthoringSurfaceSchema (const)", "MetadataBulkRegisterRequest (type)", "MetadataBulkRegisterRequestParsed (type)", "MetadataBulkRegisterRequestSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 60fee7e2e9d..cfda2d3acc6 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1592,6 +1592,7 @@ "api/SaveMetaItemRequest:name", "api/SaveMetaItemRequest:packageId", "api/SaveMetaItemRequest:parentVersion", + "api/SaveMetaItemRequest:surface", "api/SaveMetaItemRequest:type", "api/SaveMetaItemRequest:writeFace", "api/SaveMetaItemResponse:advisories", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index c0bff12d876..16edaaacd34 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -458,6 +458,8 @@ "MarkNotificationsReadRequestSchema": "api/MarkNotificationsReadRequest", "MarkNotificationsReadResponse": "api/MarkNotificationsReadResponse", "MarkNotificationsReadResponseSchema": "api/MarkNotificationsReadResponse", + "MetadataAuthoringSurface": "api/MetadataAuthoringSurface", + "MetadataAuthoringSurfaceSchema": "api/MetadataAuthoringSurface", "MetadataBulkRegisterRequest": "api/MetadataBulkRegisterRequest", "MetadataBulkRegisterRequestSchema": "api/MetadataBulkRegisterRequest", "MetadataBulkResponse": "api/MetadataBulkResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 1d3a90d157f..96c8fd03379 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -595,6 +595,8 @@ "MarkNotificationsReadRequestSchema": "src/api/protocol.zod.ts#MarkNotificationsReadRequestSchema (const)", "MarkNotificationsReadResponse": "src/api/protocol.zod.ts#MarkNotificationsReadResponse (type)", "MarkNotificationsReadResponseSchema": "src/api/protocol.zod.ts#MarkNotificationsReadResponseSchema (const)", + "MetadataAuthoringSurface": "src/api/protocol.zod.ts#MetadataAuthoringSurface (type)", + "MetadataAuthoringSurfaceSchema": "src/api/protocol.zod.ts#MetadataAuthoringSurfaceSchema (const)", "MetadataBulkRegisterRequest": "src/api/metadata.zod.ts#MetadataBulkRegisterRequest (type)", "MetadataBulkRegisterRequestParsed": "src/api/metadata.zod.ts#MetadataBulkRegisterRequestParsed (type)", "MetadataBulkRegisterRequestSchema": "src/api/metadata.zod.ts#MetadataBulkRegisterRequestSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 92e1ce3bf34..923f98997ad 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -253,6 +253,7 @@ "api/MarkAllNotificationsReadResponse", "api/MarkNotificationsReadRequest", "api/MarkNotificationsReadResponse", + "api/MetadataAuthoringSurface", "api/MetadataBulkRegisterRequest", "api/MetadataBulkResponse", "api/MetadataBulkUnregisterRequest", From 34c9c2aa52eff7589d75582bdc9ad2b9367ce6be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:26:00 +0000 Subject: [PATCH 08/14] test(runtime): type the absence-case read query Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- .../runtime/src/domains/meta-save-preconditions-parity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts index 3cb7f77608e..560fffd66c7 100644 --- a/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts +++ b/packages/runtime/src/domains/meta-save-preconditions-parity.test.ts @@ -401,7 +401,7 @@ for (const [doorName, boot] of DOORS) { ok(await door.save('s_abs', 'live')); ok(await door.save('s_abs', 'staged', { query: { mode: 'draft', surface: 'studio' } })); expect(await changes(door, 's_abs')).toHaveLength(1); - for (const query of [{}, { preview: 'draft' }]) { + for (const query of [{}, { preview: 'draft' }] as Array>) { const answer = await door.read('s_abs', query); expect(answer.status, JSON.stringify(query)).toBe(200); expect('draftChanges' in (answer.envelope ?? {}), JSON.stringify(query)).toBe(false); From 00a3ef3aeb3f6ff384e79c80ca51208b6398f7c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:56:28 +0000 Subject: [PATCH 09/14] fix(spec): MetadataAuthoringSurface is the z.input alias, pinned isomorphic (ADR-0122) Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- packages/spec/src/api/protocol.zod.ts | 2 +- packages/spec/src/type-alias-convention.pin.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index fe0db3aafa3..679e08f8206 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -492,7 +492,7 @@ export const MetadataAuthoringSurfaceSchema = z.enum(['copilot', 'studio', 'api' + 'caller of the save door. It names the surface, not the person: a copilot write and a Studio write of ' + 'the same user carry the same `actor`.', ); -export type MetadataAuthoringSurface = z.infer; +export type MetadataAuthoringSurface = z.input; /** * Get Metadata Item Response diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 903eb3aa19b..3bc06885754 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -607,6 +607,7 @@ export type Iso_api_protocol__MarkAllNotificationsReadRequestSchema = Assert, z.infer< typeof M28.MarkAllNotificationsReadResponseSchema > >>; export type Iso_api_protocol__MarkNotificationsReadRequestSchema = Assert, z.infer< typeof M28.MarkNotificationsReadRequestSchema > >>; export type Iso_api_protocol__MarkNotificationsReadResponseSchema = Assert, z.infer< typeof M28.MarkNotificationsReadResponseSchema > >>; +export type Iso_api_protocol__MetadataAuthoringSurfaceSchema = Assert, z.infer< typeof M28.MetadataAuthoringSurfaceSchema > >>; export type Iso_api_protocol__PublishMetaItemRequestSchema = Assert, z.infer< typeof M28.PublishMetaItemRequestSchema > >>; export type Iso_api_protocol__PublishMetaItemResponseSchema = Assert, z.infer< typeof M28.PublishMetaItemResponseSchema > >>; export type Iso_api_protocol__PublishPackageDraftsResponseSchema = Assert, z.infer< typeof M28.PublishPackageDraftsResponseSchema > >>; From 86c033b98b1a0109850064732b805f9110c7bd81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 00:32:53 +0000 Subject: [PATCH 10/14] test(spec): the isomorphic-pin count moves 788 -> 789 with MetadataAuthoringSurfaceSchema Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- packages/spec/src/type-alias-convention.pin.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 3bc06885754..673e2d7c1b6 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -277,7 +277,7 @@ import type * as M189 from './system/fate-supplement.zod.js'; import type * as M190 from './kernel/execution-context.zod.js'; // --------------------------------------------------------------------------- -// 788 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 789 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1703,7 +1703,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 788 isomorphic pins', () => { + it('still declares all 789 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2546,7 +2546,13 @@ describe('ADR-0122 type-alias convention', () => { // 789 -> 788 is #24162: `RecordJsonProps` left with the retired // `record:json` element, so its pin (Iso_ui_component__RecordJsonProps) // left with it. -1. - expect(pins).toHaveLength(788); + // + // 788 -> 789 is #24395: `MetadataAuthoringSurfaceSchema` + // (`api/protocol.zod.ts`), the closed authoring-surface set a save states + // and `draftChanges[].by` reports — a plain string enum, so it is pinned + // here (Iso_api_protocol__MetadataAuthoringSurfaceSchema) rather than + // given a `…Parsed` synonym. +1 added. + expect(pins).toHaveLength(789); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From a3925372f2410b160526bec191bd0adf09b70334 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 00:35:43 +0000 Subject: [PATCH 11/14] chore(changeset): platform-objects ships the new column's labels Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- .changeset/24395-draft-changes-writer.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/24395-draft-changes-writer.md b/.changeset/24395-draft-changes-writer.md index 4b19c57dfbf..2eba916839e 100644 --- a/.changeset/24395-draft-changes-writer.md +++ b/.changeset/24395-draft-changes-writer.md @@ -1,6 +1,7 @@ --- "@objectstack/metadata-protocol": minor "@objectstack/metadata-core": minor +"@objectstack/platform-objects": minor --- feat(metadata-protocol): the draft save records `draftChanges` — which surface, actor and instant last changed each key path of a pending draft — and the strict draft read serves it @@ -11,4 +12,5 @@ Clause-②: yes (widening) - **Kept until publish or discard.** No two entries share a path: a save replaces every entry at or under each path it changes, so the longest prefix answers for a key. A save that changes no key path leaves the entries as they were. Publish and discard delete the draft row, and the entries with it, so the next draft starts clean. A `migrate-stored` rewrite and an active save record nothing. - **Served on the strict draft read only.** `getMetaItem({ state: 'draft' })` answering a stored draft row carries `draftChanges` beside `item`. It is absent on the active read, the cached branch, the `?preview=draft` read, and a draft row whose saves recorded none (one saved before this release) — never `[]`. - **Stored on the row.** `sys_metadata` gains a nullable `draft_changes` column (`@objectstack/metadata-core`'s `SysMetadataObject`), never inside the stored body. Schema sync adds it to an existing table on boot with no manual step (measured on SQLite and the memory driver). +- **Translated labels.** `@objectstack/platform-objects`' translation bundles carry the new column's label and help text in every shipped locale. - **An unknown `surface` is refused.** In-process, a value outside `copilot | studio | api` is refused `400 INVALID_REQUEST` before anything is written. From fc7d18f352aba68549bc933d1dcb6dda2caebee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:08:02 +0000 Subject: [PATCH 12/14] chore(docs): regenerate the reference index on the merged tree Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index ef1be7736fd..178df51d814 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1540 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1541 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -29,12 +29,12 @@ counts are sums of the rows they head. Regenerate with | [Kernel Protocol](/docs/references/kernel) | 30 | 161 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | -| [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | +| [Security Protocol](/docs/references/security) | 5 | 31 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1540** | 14 protocol modules | +| **Total** | **197** | **1541** | 14 protocol modules | --- @@ -266,7 +266,7 @@ Declarative test suites — scenarios, steps, actions and assertions. ## Security Protocol -**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 30 schemas** +**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 31 schemas** Permission sets, row-level security, sharing rules, tenancy posture. @@ -275,7 +275,7 @@ Permission sets, row-level security, sharing rules, tenancy posture. | [`explain.zod.ts`](/docs/references/security/explain) | `AccessMatrix`, `AccessMatrixEntry`, `AuthzPosture`, `ExplainDecision`, `ExplainLayer`, `ExplainMatchedRule`, `ExplainOperation`, `ExplainRecordAttribution`, `ExplainRequest` | | [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture`, `TenantLayer0Verdict` | | [`permission.zod.ts`](/docs/references/security/permission) | `AdminScope`, `EffectiveObjectPermission`, `FieldPermission`, `ObjectAccessScope`, `ObjectPermission`, `PermissionSet` | -| [`rls.zod.ts`](/docs/references/security/rls) | `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` | +| [`rls.zod.ts`](/docs/references/security/rls) | `DefaultRowScope`, `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` | | [`sharing.zod.ts`](/docs/references/security/sharing) | `CriteriaSharingRule`, `OWDModel`, `ShareRecipientType`, `SharingLevel`, `SharingRule`, `SharingRuleType` | --- From c105df36c058ea4209523a96a44ac151d28d9104 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:32:13 +0000 Subject: [PATCH 13/14] test(objectql): the real-engine sys_metadata stubs declare draft_changes Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- packages/objectql/src/default-record-page-save-door.test.ts | 2 ++ packages/objectql/src/protocol-publish-canonical-fold.test.ts | 2 ++ packages/objectql/src/protocol-recorded-by-null.test.ts | 2 ++ packages/objectql/src/protocol-revert-org-scope.test.ts | 2 ++ packages/objectql/src/publish-meta-response-conformance.test.ts | 2 ++ packages/objectql/src/publish-package-drafts-backfill.test.ts | 2 ++ .../src/publish-package-drafts-response-conformance.test.ts | 2 ++ packages/objectql/src/save-meta-response-conformance.test.ts | 2 ++ 8 files changed, 16 insertions(+) diff --git a/packages/objectql/src/default-record-page-save-door.test.ts b/packages/objectql/src/default-record-page-save-door.test.ts index ab4aa920958..a67f3618aaa 100644 --- a/packages/objectql/src/default-record-page-save-door.test.ts +++ b/packages/objectql/src/default-record-page-save-door.test.ts @@ -53,6 +53,8 @@ const sysMetadataObject: ServiceObject = { // it, and an `email_template` write keys its row by it. bundle_member: { name: 'bundle_member', label: 'Bundle Member', type: 'text' as const, maxLength: 100 }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/protocol-publish-canonical-fold.test.ts b/packages/objectql/src/protocol-publish-canonical-fold.test.ts index f2ca91896bf..2b89868b762 100644 --- a/packages/objectql/src/protocol-publish-canonical-fold.test.ts +++ b/packages/objectql/src/protocol-publish-canonical-fold.test.ts @@ -144,6 +144,8 @@ const sysMetadataObject: ServiceObject = { // payload against this map, so omitting it is a fixture defect. package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/protocol-recorded-by-null.test.ts b/packages/objectql/src/protocol-recorded-by-null.test.ts index c6d6de56de9..1825aabef4e 100644 --- a/packages/objectql/src/protocol-recorded-by-null.test.ts +++ b/packages/objectql/src/protocol-recorded-by-null.test.ts @@ -66,6 +66,8 @@ const sysMetadataObject = { // sibling stub below is left alone.) package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/protocol-revert-org-scope.test.ts b/packages/objectql/src/protocol-revert-org-scope.test.ts index a65d717da7c..589439a226d 100644 --- a/packages/objectql/src/protocol-revert-org-scope.test.ts +++ b/packages/objectql/src/protocol-revert-org-scope.test.ts @@ -77,6 +77,8 @@ const sysMetadataObject: ServiceObject = { state: txt('state'), version: num('version'), created_at: dt('created_at'), updated_at: dt('updated_at'), created_by: txt('created_by'), updated_by: txt('updated_by'), + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: long('draft_changes'), }, }; diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index cb9ce93314f..c3f4d1d933f 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -57,6 +57,8 @@ const sysMetadataObject: ServiceObject = { // sibling stub below is left alone.) package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/publish-package-drafts-backfill.test.ts b/packages/objectql/src/publish-package-drafts-backfill.test.ts index 0d0a70e4940..5437d9d7138 100644 --- a/packages/objectql/src/publish-package-drafts-backfill.test.ts +++ b/packages/objectql/src/publish-package-drafts-backfill.test.ts @@ -33,6 +33,8 @@ const sysMetadataObject: ServiceObject = { organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/publish-package-drafts-response-conformance.test.ts b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts index dc03e1f5db4..649b12e4e12 100644 --- a/packages/objectql/src/publish-package-drafts-response-conformance.test.ts +++ b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts @@ -47,6 +47,8 @@ const sysMetadataObject: ServiceObject = { organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, diff --git a/packages/objectql/src/save-meta-response-conformance.test.ts b/packages/objectql/src/save-meta-response-conformance.test.ts index d4627249ed5..5c78c653507 100644 --- a/packages/objectql/src/save-meta-response-conformance.test.ts +++ b/packages/objectql/src/save-meta-response-conformance.test.ts @@ -70,6 +70,8 @@ const sysMetadataObject: ServiceObject = { // sibling stub below is left alone.) package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + // [#24395] The draft row's per-key provenance, written by every draft save. + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, state: { name: 'state', label: 'State', type: 'text' as const }, version: { name: 'version', label: 'Version', type: 'number' as const }, From 49a8e27c6ab74145ea0bfbe6b9088dba3eb584ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 05:35:52 +0000 Subject: [PATCH 14/14] chore: regenerate the authorable surface on the merged tree; the released 24298 note is left as main has it Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 Co-authored-by: Claude --- .changeset/24395-draft-changes-surface.md | 2 +- .changeset/pre/24298-draft-key-provenance.md | 2 +- packages/spec/authorable-surface/api.json | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/24395-draft-changes-surface.md b/.changeset/24395-draft-changes-surface.md index 641b1af8bae..8fae6d4d0b0 100644 --- a/.changeset/24395-draft-changes-surface.md +++ b/.changeset/24395-draft-changes-surface.md @@ -8,4 +8,4 @@ Clause-②: yes (widening) - **One closed set, one name.** `MetadataAuthoringSurfaceSchema` (`copilot | studio | api`) is exported from `@objectstack/spec/api`, with its type `MetadataAuthoringSurface`. `GetMetaItemResponseSchema.draftChanges[].by` now reads it instead of an inline enum: the accepted values are unchanged. - **The save states its surface.** `SaveMetaItemRequestSchema` gains an optional `surface` from that set. A draft save stamps it as `by` on each key path it changes; an absent `surface` stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` only: `copilot` is stated by an in-process AI tool, never over HTTP. -- **The writer exists.** The `draftChanges` describe no longer says no server writes it: it states the writer's rules (the first draft save is compared with the stored published row, an item with no stored published row is recorded as `[]`, and a `migrate-stored` rewrite records nothing). +- **The writer exists.** The `draftChanges` describe no longer says no server writes it: it states the writer's rules (the first draft save is compared with the stored published row, an item with no stored published row is recorded as `[]`, and a `migrate-stored` rewrite records nothing). `18.0.0-next.4`'s note said no server writes `draftChanges` yet; from this release the draft save writes it. diff --git a/.changeset/pre/24298-draft-key-provenance.md b/.changeset/pre/24298-draft-key-provenance.md index 035f34e29b5..fa6692cebd3 100644 --- a/.changeset/pre/24298-draft-key-provenance.md +++ b/.changeset/pre/24298-draft-key-provenance.md @@ -8,5 +8,5 @@ Clause-②: yes (widening) - **The key.** `GetMetaItemResponseSchema` gains an optional `draftChanges` beside `item`: one entry per changed key path of the pending draft body, each `{ path, by, actor, at }`. `path` is an array of the keys from the body root to the deepest key the write changed (`["fields", "renewal_count"]`, `["fields", "renewal_count", "label"]`); an array value is one leaf, so a change to a view's columns is `["columns"]` and never carries an index (which elements differ is read by comparing the array with the published body's; the entry says who last changed the array); `[]` is the whole body of an item created as a draft. `by` is a closed set: `copilot` (an AI assistant's tool writing on a person's behalf), `studio` (a person editing in Studio) or `api` (any other caller of the save door). `actor` is the write's recorded identity or `null`, and `at` its ISO-8601 instant. - **Where it is served.** Only on the strict draft read (`GET /meta/:type/:name?state=draft`), the read Studio's editor loads an item from, so the marks always describe the body that read returned. It is absent on the active read, the cached branch and the `?preview=draft` read. `ListDraftsResponseSchema` (`GET /meta/_drafts`) is unchanged. -- **Absent means "not recorded".** For a draft row whose saves recorded nothing (one saved before a server recorded the key), the key is absent rather than `[]`, so a reader never takes "not recorded" for "nothing changed". +- **Absent means "not recorded".** No server writes the key yet; until one does, and for a draft row saved before it did, the key is absent rather than `[]`, so a reader never takes "not recorded" for "nothing changed". - **Nothing breaks.** Every response that parsed before still parses, and no runtime behaviour changes in this release of the spec. diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index cfda2d3acc6..c17a2b4fc4e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1701,6 +1701,7 @@ "api/TriggerFlowResponse:success", "api/UndoImportJobResponse:deleted", "api/UndoImportJobResponse:failed", + "api/UndoImportJobResponse:failures", "api/UndoImportJobResponse:jobId", "api/UndoImportJobResponse:object", "api/UndoImportJobResponse:restored",