Skip to content

Commit 584b422

Browse files
feat(spec): the draft read declares draftChanges, a pending draft's per-key provenance (#24383)
Fixes #24298 Clause-②: yes The spec half of per-key draft provenance, for objectstack-ai/objectui#12725 Target item 3: a field or a view setting the AI copilot changed carries a 草稿 chip and a 来源 line, for example 「由 AI 副驾在 2 分钟前作为草稿加入」. Ruling A on #24324 (`6250704607`, item 3) keeps that reader: "the spec key on the draft read". This PR declares the key only. No server writes it yet and no reader draws it yet. The writer and the reader are the follow-up cards named below; the seat files them when this lands. ## What is declared `GetMetaItemResponseSchema` (the body of `GET /api/v1/meta/:type/:name`) gains one optional key beside `item`, `draftChanges`: one entry per changed key path of the pending draft body. | member | type | meaning | |:--|:--|:--| | `path` | `string[]`, each segment non-empty | The object keys from the body root down to the deepest key the write changed, added or removed: `["fields", "renewal_count"]` for an added field, `["fields", "renewal_count", "label"]` for one property of it. An array value is one leaf (`["kanban", "columns"]`, never an index). `[]` is the whole body, for an item created as a draft. | | `by` | `'copilot' \| 'studio' \| 'api'`, closed | The authoring surface of the write that last changed this path. `copilot` is an AI assistant's tool writing on a person's behalf, `studio` is a person editing in Studio, `api` is any other caller of the save door. | | `actor` | `string \| null` | The write's recorded identity (the save's `actor`, the value its history event carries). `null` when the write recorded none. | | `at` | ISO-8601 instant, UTC `Z` form | When that write happened. The parse refuses an offset or a local time; `toISOString()` produces the admitted form. | The meaning, as the `.describe()` text states it and the regenerated reference page carries it. 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 rest is 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 no two entries on one path, and replaces 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. - It keeps the entries from the first recorded save until the draft is published or discarded. Both remove the draft row, and the entries go with it. - The entries name which writes changed what. They are not a list of every difference from the published body. Which elements of an array differ is read by comparing the array with the published body's; the array's entry answers who last changed the array. - Served only on a `?state=draft` read that answers a stored draft row. Absent on the active read, on the cached branch and on a `?preview=draft` read. Absent means "not recorded", never "no changes". `ListDraftsResponseSchema` (`GET /meta/_drafts`) is unchanged; its docblock now points at the draft read. The key carries ADR-0033's provenance item ("attribute each AI draft to the acting user + mark it AI-sourced") at the grain of a key: `actor` is the acting user, `by: 'copilot'` is the AI-sourced mark. They are two members because one user makes both kinds of edit. ## Why this shape: the measurements behind each choice Readings are at objectui `main` `76f91f80d` and at the console pin `0707b4ae3712` (`.objectui-sha`) unless a line says otherwise; the line numbers below are `main`'s, and the pin's differ only in `StudioDesignSurface.tsx`. 1. **Where: on the draft read, not on the `_drafts` row.** The card allowed either, and the reader decides. Studio's properties load the item they edit through `readDraftForSave` (`app-shell/src/views/metadata-admin/DraftConflictDialog.tsx:162`, both refs), which is `client.getDraft`. That is `readItemResponse` with `state=draft` (`data-objectstack/src/metadata-client.ts:1228` and `:1141`, both refs), so `GET /meta/:type/:name?state=draft`, and it returns the whole envelope (`res.json()`). Its callers are the loads every pillar's properties edit: `StudioDesignSurface.tsx:3580` (app), `:3771` (the current item), `:5698` (object), `:7382` (flow); `ObjectListViewInspector.tsx:320` (view); `PermissionMatrixEditor.tsx:644`; `ResourceEditPage.tsx:1141`, `:1945`; `ObjectHooksPanel.tsx:238`. A key beside `item` reaches the reader through the call it already makes. The `_drafts` list (`usePendingDrafts`) is what the 变更 badge and the changes drawer read, one row per item. Declaring the key on the read the body comes from also means the marks and the body come from one stored row, read once, so a mark can never describe a body the editor has not loaded. 2. **Beside `item`, never inside it.** It follows the same rule as `registration`: it is a fact about the stored row the document cannot carry. Inside the body it would be one more read decoration every GET, edit and PUT round trip must strip (`METADATA_READ_DECORATIONS`). The name is not `provenance`, because the ADR-0010 protection envelope already uses that key on this schema. objectui's envelope test matches by the presence of `type`, `name` and `item`, never by key count (`isMetaItemEnvelope`, `metadata-client.ts:858`), so one more envelope key changes no unwrapping at the pin. 3. **A path is an array of keys.** Keys contain dots: `PermissionSetSchema.fields` is keyed `OBJECT.FIELD` (`security/permission.zod.ts:956`, "entity_name.field_name"), so a dotted string such as `fields.account.name.readable` has two readings. An array has one. 4. **An array value is one leaf, and a path never carries an index.** Studio's column selection is positional (`listViewColumnSelection` builds `config.columns[N]`, `ObjectListViewInspector.tsx:452`). An index moves when a column is inserted or reordered, so an index-keyed mark would land on the wrong column. The card's own sketch names `kanban.columns` as one key path. A writer computes this rule generically, with no per-type knowledge of which member identifies an array element. 5. **The ruled artboard marks array ELEMENTS, and this shape serves it.** Re-checked this run against the maintainer's 「按「右栏二选一」定稿」 artboard, StudioRailProps (objectui `98aab506a`). With a kanban card selected, the properties list the card's fields (图书, 读者, 应还日期, 状态), and 读者 and 应还日期 carry the 草稿 chip, under the 来源 line 「读者」「应还日期」两项由 AI 副驾在 2 分钟前作为草稿加入. Those card fields are `kanban.columns` (`KanbanConfigSchema.columns`, a `string[]`, `ui/view.zod.ts:1973`). Under this shape the entry `["kanban", "columns"]` (`by: 'copilot'`, `at`) gives the who and the when; the reader finds which elements by comparing the draft's array with the published one (读者 and 应还日期 are not in it), and 「加入」 is the same comparison (a key absent from the published body was added). That draws the artboard exactly. The limit, stated in the describe text: attribution is per array. If a person also edits the same array before publish, its entry becomes `studio`, and the reader can no longer say the copilot added 读者. No artboard or ruling draws that case. A per-element attribution later can be added as an optional member of the entry without changing `path`. 6. **`by` is a closed set of authoring surfaces, not a reuse of an existing field.** Three candidates were measured, and none answers the question: - the `_drafts` row's `updatedBy` is the acting user, the same person for a copilot write and a hand edit; - `sys_metadata.source` (`filesystem | database | api | migration`) is the storage origin, and it is not projected; - the history event's `source` (`HistoryMetaItemResponseSchema`) is an open label of the code path that wrote the event (`main` now describes it so, `ba5bfe96e`), recorded per write, not per key. The three values are the card's and the triage's. Each one has a writer named below. 7. **Optional, never defaulted to `[]`.** Absent means "not recorded": a server with no writer, or a draft row saved before the writer landed. An empty-array default would answer "nothing changed" for exactly those rows. An older response with no key still parses, and the key stays absent (pinned). 8. **The entry is a plain object, not `.strict()`.** The seat's earlier suggested route asked for a strict entry; this PR does not do that. The strictness ledger (`docs/audits/2026-07-unknown-key-strictness-ledger.md`, the class table) classes server responses as **wire**: "stay tolerant (`.strip` / `.passthrough`); strictness here turns an upstream *addition* into our parse crash". The value set is closed (`by`), and so is the path's spelling (an array of non-empty strings). The key set stays tolerant, like every other entry on this schema. The contract review judges this one. **Ruling A re-checked.** Nothing in A changes what the reader needs from this key. A's item 3 names this key on the draft read. A's item 2, the chat's Preview for any draft, is objectstack-ai/objectui#12968 (merged on objectui `main` as `76f91f80d`). It reads the `_drafts` counts, and its seat ruling `6251864146` item 2 declined per-key data on `_drafts` rows. A `?preview=draft` read carries no `draftChanges`, and #12968 asks for none. ## Declared is enforced: the writer and the reader No liveness ledger covers response keys (`packages/spec/liveness/` governs registered metadata types; `authorable-surface/api.json` records only that the key exists). So these cards are the enforcement record. The seat files them when this lands. | card | lane and repo | what it does | |:--|:--|:--| | **W1, the writer** | `domain:engine`, objectstack, `packages/metadata-protocol` (plus the request member's spec half) | A draft save (`saveMetaItem` with `mode: 'draft'`, down to `SysMetadataRepository.put(…, { state: 'draft' })`) computes the changed paths in the transaction that already reads the head row (`resolveWriteHead`). It compares the replaced body (the existing draft row, else the active row for a first draft save, else nothing) with the new one by the spec's path rule, and merges the paths into the draft row's stored entries: each changed path replaces the entries at or under it. `actor` is the save's `actor`; `at` is the write's own `now`, the instant `updated_at` gets. Promote and discard already delete the row. The strict draft read (`getMetaItem` with `state: 'draft'`, from a stored draft row) serves the entries beside `item`, on the REST door and on the dispatcher. **The surface value:** the door cannot tell Studio from any other HTTP caller today (`writeFace` names the transport; the history `source` is a code-path label). So W1 declares one save-request input carrying the surface, on `SaveMetaItemRequestSchema` and its REST spelling, with the same closed set, and moves the inline enum into one exported schema both sides reference. An absent input stamps `api`. Recommended: the HTTP doors refuse `copilot`, so only the in-process copilot call can stamp it. Bulk paths to check: every `put` that writes a draft row, and `migrate-stored`'s rewrite of draft rows, whose conversions are not an authoring write and record no path. | | **W2, the copilot stamp** | cloud, objectstack-ai/cloud, `service-ai-studio` | The copilot's draft tools (`stageDraft` / `applyDraft` in `packages/service-ai-studio/src/tools/metadata-tools.ts`, as objectui#12725's phase-5 report read them at cloud `f9e45145`) pass `copilot` on their in-process `saveMetaItem` call, through the same door, once cloud's framework pin carries W1. `Blocked-by:` W1. | | **R1, the reader** | `domain:ui`, objectui, `packages/app-shell` | Studio's properties read `draftChanges` off the envelope `readDraftForSave` already returns. For the selected element (a field is `["fields", NAME]`, a view setting is its key) they draw the 草稿 chip and the 来源 line when the entry answering for it, or an entry under it, has `by: 'copilot'`; the line's time comes from `at`. For an element of an array setting (the artboard's card fields) the reader compares the draft's array with the published body's: the view inspector reads the published layer today only when no draft exists (`ObjectListViewInspector.tsx`, the load at `:320`), so R1 adds that one read on a draft load. The object designer already reads both (`StudioDesignSurface.tsx:5697`). Studio's own draft saves declare `studio` on W1's input, so a hand edit replaces a copilot mark. `Blocked-by:` W1, plus objectui's framework pin. It completes objectstack-ai/objectui#12725 item 3. | **The W1 risk to watch.** If W1 finds no acceptable way for Studio's saves to declare their surface, `studio` would be a declared value with no writer. Then W1 comes back to this schema and narrows the set before any writer ships; it must not invent a mapping. ## Verification Every reading below is local, in this run's container, at branch head `438e3570f` (`git rev-parse --short HEAD`) unless it names another commit. Heavy runs went through `scripts/pm/os-verify-lock.sh`, and each line quotes that run's own summary line. CI's readings are this PR's check-runs. - **The spec package.** `pnpm --filter @objectstack/spec typecheck` gave `VERDICT command-exit 0` (`check:test-typecheck: OK`; `tsc -p tsconfig.test.json --listFilesOnly` lists the new test file once). `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` gave `Test Files 695 passed (695)` and `Tests 21238 passed | 1 todo (21239)`. - **The new tests** (`packages/spec/src/api/meta-item-response-shapes.test.ts`, the file at `22 passed (22)`): five runtime cases and five type-level pins. - Runtime: every entry survives a parse (each surface, a root path, a dotted key, a null actor). A response without the key parses, and the key stays absent. A surface outside the set is refused at `draftChanges.0.by` (`invalid_value`). A dotted-string path is refused (`invalid_type`), and so is an empty segment (`too_small`). A missing `at` is refused (`invalid_type`), and so is a non-ISO one (`invalid_format`). - Type level: `by` is the three-value union, `path` is `string[]`, `actor` is `string | null`, the key may be absent, and the `_drafts` row carries no `draftChanges`. - **Generated artefacts.** After `pnpm --filter @objectstack/spec build` at `438e3570f`, `check:generated` reads `✓ All 14 generated artifacts are up to date`. - **Reverse verification**, against the rebuilt declarations from a consumer package. A scratch file in `packages/client/src` typed `draftChanges: [{ …, by: 'ai', … }]` as `GetMetaItemResponse`. Compiled with `tsc --ignoreConfig --noEmit --strict`, it exited 2 with `error TS2322: Type '"ai"' is not assignable to type '"api" | "copilot" | "studio"'`. With `by: 'copilot'` it exited 0. The file was then removed, and `git status` was clean. - **Ablation**, at `e98129b65`, through `scripts/ablation-replace.mjs`. No build was needed, because the test imports the source. `by: z.enum(['copilot', 'studio', 'api'])` became `by: z.string()` (anchor x1 → x0, blob `fe32063f1f92` → `7dd4e62cf796`). The result was `Tests 1 failed | 21 passed (22)`, and the failing case was `` refuses a surface outside the closed set, at the entry's `by` ``. The tool then reported `` ok restored: blob == HEAD (fe32063) and `git diff HEAD` is empty ``. From `e98129b65` to `438e3570f` the only change is one docblock paragraph. - **Consumers.** I ran `git grep` repo-wide for the packages that import `GetMetaItemResponse` / `GetMetaItemResponseSchema` by name. These are downstream consumers of `@objectstack/spec`: `@objectstack/client` (the type, re-exported), `@objectstack/client-react`, `@objectstack/service-automation` (a type import in an integration test), `@objectstack/rest` (the route ledger's schema name) and `@objectstack/runtime` (comments). - The three typechecks (`Scope: 3 of 85 workspace projects`) were green. - `@objectstack/client` vitest: `52 passed (52)` files, `671 passed (671)` tests. - The four `@objectstack/rest` `meta-item-*` route tests: `52 passed (52)`. - `@objectstack/runtime`'s `domains/meta-item-absent-404.test.ts`: `6 passed (6)`. - All of these ran at `438e3570f` (`VERDICT command-exit 0`). - objectui at the pin `0707b4ae3712` and on `main` `76f91f80d` imports the schema in one code file, `DraftConflictDialog.tsx:84`, and reads only `.shape.version` there (`:138`). Its envelope test is key-presence (`isMetaItemEnvelope`), so one more optional envelope key changes nothing there. - **The full build** (`turbo run build --filter=!@objectstack/docs --concurrency=2`): `Tasks: 76 successful, 76 total`. The gates that read other packages' `dist/` need it. - **The derived gate union**, run after the final commit: - `node scripts/pm/dispatch-gates.mjs --commands` (no paths; the change set is 6 paths against merge base `6a49efb9c`) derived 110 commands at `438e3570f`. All 110 exited 0, each exit code captured before any pipe. - `--ran` reconciliation: `110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN` (`a DERIVED zero — all 110 recorded an exit code and none of them is 3`). - One standing line inside a green family: `check:entry-nameability` prints `NOT MEASURED: no callable export on @objectstack/spec/api-assembled, @objectstack/spec/qa`. That covers two entries this diff does not touch; the `api` entry is measured. - **The local-only families:** `pnpm check:dev-prereqs` exit 0 (`@objectstack/spec … built from the sources on disk`), and `pnpm check:skill-frame-freshness` exit 0. - **Declared to CI:** the whole `check:*` farm, `pnpm lint`, `test:repo`, and every type-check lane outside the four packages typechecked above (spec, client, client-react, service-automation). The reconciliation counts these outside the derived total. - **Patch round 1** (contract review `6253694122`, item ① 9), at head `9e98be435`. Only describe text changed: the schema-level `draftChanges` describe now states the writer as a contract and says once that no server writes the key yet, and the `at` describe names the one ISO-8601 form the parse admits. `gen:docs` moved only `content/docs/references/api/protocol.mdx`. After `pnpm --filter @objectstack/spec build`, `check:generated` reads `✓ All 14 generated artifacts are up to date`. `pnpm --filter @objectstack/spec typecheck` passed (`check:test-typecheck: OK`), and `meta-item-response-shapes.test.ts` gave `22 passed (22)` (`VERDICT command-exit 0`). `pnpm check:doc-authoring` exited 0 (`19054 customer-facing string(s) … no internal issue-id references`). The full build gave `Tasks: 76 successful, 76 total`. Re-derived with no paths, `dispatch-gates --commands` gave the same 110 commands, and all 110 exited 0. `--ran` read `110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN`. The derivation warned that the tree is behind `origin/main` and that two gate-source files changed there (`scripts/check-single-authz-resolver.mjs`, `scripts/engine-double-contract.pinned.json`). Both are data rows for `main`'s own new files, and neither touches this diff's paths. CI runs both on the merge ref. ## Acceptance notes - **Two departures from the seat's earlier suggested route, each with its reason above:** the key sits on the draft read rather than the `_drafts` row (choice 1), and the entry is not `.strict()` (choice 8). - **Resumed run.** The first run's branch (`82d1987ce`) was kept through the hold on #24324. This run merged `origin/main` `6a49efb9c` through `scripts/pm/os-regen-merge.sh` (`b5e73b9e1`): the os-regen driver had kept the branch's side of `content/docs/references/api/protocol.mdx`, and the regeneration (`125f6cb10`) carries both `main`'s history `source` describe (`ba5bfe96e`) and this key's rows. This run then added one sentence to the `path` describe and two docblock paragraphs (the array-element reading of choice 5, and the ADR-0033 item), and regenerated the reference page (`e98129b65`). - **Generated artefacts, regenerated only where `check:generated` proved them stale:** `authorable-surface/api.json` (the build writes one line, `api/GetMetaItemResponse:draftChanges`); `content/docs/references/api/protocol.mdx` (`gen:docs`); `docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md` (`gen:strictness-ledger`: the `api/` site total 444 → 445). - **The first run's note on the history `source` describe is withdrawn:** `main` rewrote that describe in `ba5bfe96e` (#24334), and the merged tree carries it. --- _Generated by [Claude Code](https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1eae21f commit 584b422

6 files changed

Lines changed: 180 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the draft read declares `draftChanges`, a pending draft's per-key provenance: which authoring surface last changed each key path of the draft, under which actor, and when
6+
7+
Clause-②: yes (widening)
8+
9+
- **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.
10+
- **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.
11+
- **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".
12+
- **Nothing breaks.** Every response that parsed before still parses, and no runtime behaviour changes in this release of the spec.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,6 +1457,7 @@ This schema accepts one of the following structures:
14571457
| **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. |
14581458
| **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. |
14591459
| **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. |
1460+
| **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". |
14601461

14611462
### Nested Shape: `GetMetaItemResponse.sortability`
14621463

@@ -1483,6 +1484,17 @@ This schema accepts one of the following structures:
14831484
| :--- | :--- | :--- | :--- |
14841485
| **state** | `'unregistered'` | ✅ | The engine holds nothing under the flow's name and recorded no refusal for it. |
14851486

1487+
### Nested Shape: `GetMetaItemResponse.draftChanges[number]`
1488+
1489+
One changed key path of the pending draft, and the last write that changed it.
1490+
1491+
| Property | Type | Required | Description |
1492+
| :--- | :--- | :--- | :--- |
1493+
| **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. |
1494+
| **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`. |
1495+
| **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. |
1496+
| **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. |
1497+
14861498

14871499
---
14881500

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `api/` | 444 |
22+
| `api/` | 445 |

‎packages/spec/authorable-surface/api.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,7 @@
789789
"api/GetMetaItemRequest:state",
790790
"api/GetMetaItemRequest:type",
791791
"api/GetMetaItemResponse:deletable",
792+
"api/GetMetaItemResponse:draftChanges",
792793
"api/GetMetaItemResponse:editable",
793794
"api/GetMetaItemResponse:item",
794795
"api/GetMetaItemResponse:lock",

‎packages/spec/src/api/meta-item-response-shapes.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
GetMetaItemResponse,
3333
GetMetaItemsResponse,
3434
GetMetaItemLayeredResponse,
35+
ListDraftsResponse,
3536
MetadataConflictError,
3637
} from './protocol.zod';
3738
import type { MetadataLock } from '../kernel/metadata-protection.zod';
@@ -305,3 +306,64 @@ describe('the engine\'s verdict on a stored flow: `registration` beside the item
305306
expect('registrations' in GetMetaItemsResponseSchema.parse({ type: 'flow', items: [FLOW] })).toBe(false);
306307
});
307308
});
309+
310+
/**
311+
* #24298 — a pending draft's per-key provenance, `draftChanges`, beside the
312+
* item on the draft read: a closed `by` vocabulary, a path that is an ARRAY of
313+
* keys, and absence kept distinct from "nothing changed". Typed at the level
314+
* an SDK caller branches on, and deliberately not on the `_drafts` row.
315+
*/
316+
type DraftChange = NonNullable<GetMetaItemResponse['draftChanges']>[number];
317+
export type DraftChangeByIsTheClosedSurfaceSet = Assert< Eq< DraftChange['by'], 'copilot' | 'studio' | 'api' > >;
318+
export type DraftChangePathIsAnArrayOfKeys = Assert< Eq< DraftChange['path'], string[] > >;
319+
export type DraftChangeActorIsIdOrNull = Assert< Eq< DraftChange['actor'], string | null > >;
320+
export type DraftChangesMayBeAbsent = Assert< Eq< undefined extends GetMetaItemResponse['draftChanges'] ? true : false, true > >;
321+
export type DraftsRowCarriesNoDraftChanges = Assert< Eq< 'draftChanges' extends keyof ListDraftsResponse['drafts'][number] ? true : false, false > >;
322+
323+
describe('`draftChanges` — which surface last changed each key of a pending draft, beside the item', () => {
324+
const VIEW = { name: 'loan_board', label: 'Loans', columns: ['title', 'due_on'] };
325+
const AT = '2026-10-18T11:02:38.000Z';
326+
const ENTRIES = [
327+
{ path: [], by: 'copilot', actor: 'usr_1', at: AT },
328+
{ path: ['columns'], by: 'studio', actor: 'usr_1', at: AT },
329+
{ path: ['fields', 'account.name', 'readable'], by: 'api', actor: null, at: AT },
330+
];
331+
const DRAFT_READ = { type: 'view', name: 'loan_board', item: VIEW, version: 'v1', draftChanges: ENTRIES };
332+
333+
it('keeps every entry through a parse — each surface, a root path, a dotted KEY and a null actor', () => {
334+
expect(GetMetaItemResponseSchema.parse(DRAFT_READ).draftChanges).toEqual(ENTRIES);
335+
});
336+
337+
it('an older response without the key still parses, and the key stays ABSENT — never defaulted to []', () => {
338+
const parsed = GetMetaItemResponseSchema.parse({ type: 'view', name: 'loan_board', item: VIEW, version: 'v1' });
339+
expect('draftChanges' in parsed).toBe(false);
340+
});
341+
342+
/** The issue a refused body raises, located — never a bare "it failed". */
343+
const issueOf = (draftChanges: unknown) => {
344+
const verdict = GetMetaItemResponseSchema.safeParse({ ...DRAFT_READ, draftChanges });
345+
expect(verdict.success).toBe(false);
346+
return verdict.success ? undefined : verdict.error.issues[0];
347+
};
348+
349+
it('refuses a surface outside the closed set, at the entry\'s `by`', () => {
350+
for (const by of ['ai', 'user', 'mcp', 'Copilot']) {
351+
expect(issueOf([{ ...ENTRIES[0], by }])).toMatchObject({ code: 'invalid_value', path: ['draftChanges', 0, 'by'] });
352+
}
353+
});
354+
355+
it('refuses a dotted-string path and an empty key segment — a path is an array of stored keys', () => {
356+
expect(issueOf([{ ...ENTRIES[1], path: 'fields.renewal_count' }]))
357+
.toMatchObject({ code: 'invalid_type', path: ['draftChanges', 0, 'path'] });
358+
expect(issueOf([{ ...ENTRIES[1], path: ['fields', ''] }]))
359+
.toMatchObject({ code: 'too_small', path: ['draftChanges', 0, 'path', 1] });
360+
});
361+
362+
it('refuses an entry without its write\'s time, or with one that is not an ISO-8601 instant', () => {
363+
const withoutAt: Record<string, unknown> = { ...ENTRIES[0] };
364+
delete withoutAt.at;
365+
expect(issueOf([withoutAt])).toMatchObject({ code: 'invalid_type', path: ['draftChanges', 0, 'at'] });
366+
expect(issueOf([{ ...ENTRIES[0], at: 'two minutes ago' }]))
367+
.toMatchObject({ code: 'invalid_format', path: ['draftChanges', 0, 'at'] });
368+
});
369+
});

‎packages/spec/src/api/protocol.zod.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,49 @@ const MetadataProtectionEnvelopeFields = {
546546
* live and kept out of the ETag ({@link GetMetaItemCachedResponseSchema}). It
547547
* is absent wherever no automation engine answered, so absence is never read
548548
* as `registered`.
549+
*
550+
* [#24298] `draftChanges` is a pending draft's per-key provenance: for each
551+
* changed key path of the draft body, which authoring surface (`copilot`,
552+
* `studio`, `api`) last changed it, under which actor, and when. It is what
553+
* lets Studio's properties mark a field or a view setting the AI copilot
554+
* changed, with the draft chip and the source line objectui#12725 rules
555+
* (「由 AI 副驾在 2 分钟前作为草稿加入」). It carries ADR-0033's provenance
556+
* item, "attribute each AI draft to the acting user + mark it AI-sourced",
557+
* at the grain of a key: `actor` is the acting user and `by: 'copilot'` the
558+
* AI-sourced mark, two members because one user makes both kinds of edit.
559+
* Four measured choices:
560+
*
561+
* - **On this read, not on the `_drafts` row.** The reader is Studio's
562+
* properties, and they read the item they edit through the strict draft
563+
* read (`?state=draft`, the client's `getDraft`); the `_drafts` list is the
564+
* change badge's and the changes drawer's read, one row per item. Here the
565+
* provenance comes from the same stored row as the body and `version` it
566+
* sits beside, so a mark can never describe a body the editor has not
567+
* loaded.
568+
* - **Beside `item`, never inside it**, like `registration`: a fact about the
569+
* stored row the document cannot carry. Inside the body it would be one more
570+
* read decoration every GET → edit → PUT round trip must strip
571+
* (`METADATA_READ_DECORATIONS`), and `provenance` is already the ADR-0010
572+
* envelope key (where the item came from), hence the distinct name.
573+
* - **A path is an array of keys, and an array value is one leaf.** Keys may
574+
* contain dots (a permission set's `fields` map is keyed `object.field`), so
575+
* a dotted string would be ambiguous; and an index (`columns[3]`) moves when
576+
* a column is inserted or reordered, so the path stops at `columns` — the
577+
* granularity the ruling names, "a field or a view setting". The ruled
578+
* properties artboard also marks ELEMENTS of an array setting: a kanban
579+
* card's two copilot-added fields, under the line
580+
* 「读者」「应还日期」两项由 AI 副驾在 2 分钟前作为草稿加入. The reader finds
581+
* which elements by comparing the draft's array with the published body's,
582+
* and the array's entry says who last changed it and when. Attribution is
583+
* per array, never per element.
584+
* - **Optional, never defaulted to `[]`.** Absent means "not recorded" (a
585+
* server with no writer, or a draft row saved before one); an empty-array
586+
* default would answer "nothing changed" for exactly those rows.
587+
*
588+
* The history log's per-event `source` (see `HistoryMetaItemResponseSchema`)
589+
* is not this vocabulary: it is an open label of the code path that wrote the
590+
* event, not the surface a person or the copilot used, and it is per write,
591+
* not per key.
549592
*/
550593
export const GetMetaItemResponseSchema = lazySchema(() => z.object({
551594
type: z.string().describe('Metadata type name'),
@@ -588,6 +631,49 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({
588631
+ 'the answering process\'s engine. Beside `item`, never inside it: the item\'s `status` stays what '
589632
+ 'its author declared.',
590633
),
634+
draftChanges: z.array(z.object({
635+
path: z.array(z.string().min(1)).describe(
636+
'The changed key path inside `item`: one object key per segment, from the body root down to the '
637+
+ 'deepest key whose value the write changed, added or removed. `["fields", "renewal_count"]` is a '
638+
+ 'field the write added; `["fields", "renewal_count", "label"]` is one property of it. A segment '
639+
+ 'is the key exactly as stored, dots included (a permission set\'s `fields` map is keyed '
640+
+ '`object.field`), so a path is never one dotted string. An array is one value: its path ends at '
641+
+ 'its key (`["columns"]`) and never carries an index, which an insert or a reorder would shift. '
642+
+ 'Which elements of an array the draft added or dropped is read by comparing it with the '
643+
+ 'published body\'s array; the entry answers only who last changed the array as a whole. '
644+
+ '`[]` is the whole body: the write that created the draft of an item with no published body.',
645+
),
646+
by: z.enum(['copilot', 'studio', 'api']).describe(
647+
'The authoring surface of the write that last changed this path. Closed: `copilot` is an AI '
648+
+ 'assistant\'s tool writing on a person\'s behalf (Studio\'s AI copilot); `studio` is a person '
649+
+ 'editing in Studio; `api` is any other caller of the save door. It names the surface, not the '
650+
+ 'person: a copilot write and a Studio write of the same user carry the same `actor`.',
651+
),
652+
actor: z.string().nullable().describe(
653+
'The identity that write was recorded under: the save\'s `actor`, the value its history event '
654+
+ 'carries. `null` when the write recorded none, never a sentinel string.',
655+
),
656+
at: z.string().datetime().describe(
657+
'When that write happened: an ISO-8601 instant in UTC, written with `Z` (the form '
658+
+ '`toISOString()` produces). An offset or a local time is refused.',
659+
),
660+
}).describe('One changed key path of the pending draft, and the last write that changed it.'))
661+
.optional().describe(
662+
'Per-key provenance of the pending draft: which authoring surface last changed each key path of '
663+
+ '`item`, under which actor, and when. No server writes the key yet: until the engine\'s draft '
664+
+ 'save records it, every draft read is one whose saves recorded none, and the key is absent. '
665+
+ 'The contract a writer must meet: it compares each draft save with the body that save replaced '
666+
+ '(the previous draft, or the published body for the draft\'s first save); it keeps the entries '
667+
+ 'from the first recorded save until the draft is published or discarded, which remove the draft '
668+
+ 'row and the entries with it; and it keeps no two entries on one path, replacing every entry at '
669+
+ 'or under a path a save changes, so the last write to change a key is the entry whose `path` is '
670+
+ 'the longest prefix of that key\'s path, and entries under a key\'s path are changes inside it. '
671+
+ 'The entries name which writes changed what; they are not a list of every difference from the '
672+
+ 'published body (compare the two bodies for that). Served only on a `?state=draft` read that '
673+
+ 'answers a stored draft row. ABSENT on the active read, the cached branch and a '
674+
+ '`?preview=draft` read, and for a draft row whose saves recorded none: absence means "not '
675+
+ 'recorded", never "no changes".',
676+
),
591677
}));
592678

593679
/**
@@ -1962,6 +2048,12 @@ export const GetPublishedMetaItemResponseSchema = lazySchema(() => z.unknown().d
19622048
* one template are two drafts with one `type` and `name`. It is the row's
19632049
* stored member, never read off the body, and it is the value the by-name
19642050
* doors' `member` selector takes back.
2051+
*
2052+
* [#24298] Per-key provenance (which surface changed which key of the draft,
2053+
* and when) is deliberately NOT on this row: it is
2054+
* {@link GetMetaItemResponseSchema}'s `draftChanges`, on the strict draft read
2055+
* Studio's properties load the item from. `updatedBy` here stays the row's
2056+
* last actor, the same person for a copilot write and a hand edit.
19652057
*/
19662058
export const ListDraftsResponseSchema = lazySchema(() => z.object({
19672059
drafts: z.array(z.object({

0 commit comments

Comments
 (0)