Skip to content

Commit 2ec6827

Browse files
feat(metadata-protocol): the draft save records draftChanges per key path; the save declares its surface (?surface=studio|api, copilot in-process only) (#24418)
Fixes #24395 Clause-②: yes (widening) ## What this does `GetMetaItemResponseSchema.draftChanges` landed in `@objectstack/spec` (#24383) with no writer. This PR is the writer. Its first deliverable is a save request that declares its authoring surface. - **Surface input (spec).** `MetadataAuthoringSurfaceSchema` (`copilot | studio | api`) is now one exported schema. `SaveMetaItemRequestSchema.surface` uses it, and so does `draftChanges[].by` (the inline enum is gone, same values). An absent `surface` stamps `api`. - **HTTP spelling: `?surface=studio|api`** on `RestServer`'s `PUT /meta/:type/:name` and on the runtime dispatcher's. It joins the closed query set `META_ITEM_SAVE_PARAMS` and its arity list, and is read once in `metaSaveRequestOptions` beside `?mode=`. Both doors answer `400 VALIDATION_ERROR` for `?surface=copilot` (only an in-process tool may state it), for any other value (a case variant included, never folded to `api`), and for a repeated `?surface=`. No new error code: in-process, a value outside the set is `400 INVALID_REQUEST`, the save door's existing request-envelope refusal. - **Record (repository).** `SysMetadataRepository.put` with `state: 'draft'` records the entries inside `resolveWriteHead`'s transaction. It compares the new body with the body it replaces: the draft row, else the active row the draft's publish would replace (that package's row, else the package-less one), else nothing, which records `[]`. A path is object keys only, and an array is one leaf. The keep rule: every entry at or under a changed path is replaced, so no two entries share a path. A save that changes no path leaves the column as it was. `actor` is `opts.actor ?? null`, the value the history event carries. `at` is the write's own `now` (`toISOString()`). The pure rules live in `packages/metadata-protocol/src/draft-changes.ts`. - **Who records.** `saveMetaItem` passes `draftChangesBy: surface ?? 'api'` only on an authoring draft save. A `migrate-stored` rewrite (the only caller that states a `source`) records nothing, and neither does an active save. - **Serve.** The strict draft read (`getMetaItem` with `state: 'draft'` from a stored row) adds `draftChanges` beside `item`, taken from the row it serves. It is absent on the active read, the cached branch, `?preview=draft`, and a draft row that recorded none, and never `[]`. Neither HTTP door needed a transport change (review 6253694122 ③ 4 held: measured below). - **Storage home: a nullable `sys_metadata.draft_changes` column** (`SysMetadataObject`, JSON text), on the draft row and never inside `metadata`. Publish (`promoteDraft`'s drain) and discard (`discardDraftInItsScope`) delete the row, and the entries go with it. ## Where it landed outside the claim's file surface, and why - `packages/rest/src/meta-item-read-gate.ts` (`META_ITEM_SAVE_PARAMS`) and `packages/rest/src/meta-item-write-routes.ts` (the REST door's arity list). Both doors' closed query sets live there; without them `?surface=` is refused as an unknown parameter (measured before). - `packages/metadata-protocol/src/draft-changes.ts` (new), holding the recorder's pure functions, and its test. - `packages/platform-objects/src/apps/translations/*.generated.ts`: the new column's label, regenerated by `node scripts/check-i18n-bundles.mjs --write`. The zh-CN, ja-JP and es-ES values are hand-translated, which is allowed for values. - `packages/spec/src/type-alias-convention.pin.test.ts`: the ADR-0122 isomorphic pin for the new enum (`check:spec-parsed-alias` asked for it), with the count moving from 788 to 789. - `.changeset/24298-draft-key-provenance.md`, **a deliberate correction of a pending release note, which needs a person's confirmation.** Its bullet said "No server writes the key yet". That sentence becomes false the moment this PR ships, and both notes are pending for the same release. I rewrote that one sentence. `check-empty-changeset` stays red on it by design, and its own text says this class is not to be restored from base. The seat or maintainer confirms or rejects the correction. - Generated by tooling, never by hand: `content/docs/references/api/protocol.mdx`, `content/docs/references/index.mdx`, `packages/spec/{api-surface,authorable-surface,declaration-map,export-origins,json-schema.manifest}/api.json` (`check:generated --fix` plus the spec build). - Pins: the protocol-level block is appended to `protocol.packageless-save-address.test.ts`, which reuses that file's ledgered engine double rather than registering a new one. The both-doors block is appended to `packages/runtime/src/domains/meta-save-preconditions-parity.test.ts`, which runs over a real SQL store. ## Verification (M, booted) **REST door, booted `pnpm dev:crm --fresh --database file:persist.db` (SQLite), before and after:** | case | before (`d79dc4883c`) | after | |---|---|---| | `PUT ?mode=draft` | 200; read has no `draftChanges` | 200; `[{path:[],by:"api",actor:"Voj5…",at:"…Z"}]` | | `?surface=studio` / `api` | 400 unknown parameter (`Supported parameters: force, mode, package.`) | 200; `by:"studio"` / `by:"api"` | | `?surface=copilot` | 400 unknown parameter | 400 `VALIDATION_ERROR`, nothing written | | `?surface=STUDIO`, a repeated `surface` | 400 unknown parameter | 400 `VALIDATION_ERROR` (value / arity) | | first draft save over an active row | n/a | `[["columns"],api]` only | | second save (studio, label) | n/a | `[["columns"],api], [["label"],studio]` | | third save (studio, columns back) | n/a | `[["label"],studio], [["columns"],studio]` (replaced at the path, one entry) | | active read · cached branch (ETag) · `?preview=draft` | absent | absent | | draft row saved BEFORE the writer (same DB file, booted again on the new build) | n/a | absent; its next save records `[["label"],studio]` | | `POST …/publish` → rows | n/a | `[('active', None)]`; next draft records only `["description"]` | | `DELETE ?state=draft` → rows | n/a | `[('active', None)]`; next draft starts clean | **Existing deployment gains the column:** the `before` boot created `sys_metadata` without `draft_changes` (`pragma table_info` listed 24 columns, no `draft_changes`). Booting the after-build on the same file added it, with no manual step, and the pre-writer row reads `NULL`. **Dispatcher (a real `createHonoApp` catch-all over the real protocol), on SqliteWasm and on the memory driver, with an in-process `saveMetaItem`:** before, every `?surface=` was 400 unknown (`Supported parameters: mode, package.`) and no read carried the key. After: none, `studio` and `api` stamp `by`, while `copilot`, `Studio` and `bogus` are 400 `VALIDATION_ERROR`. In-process `surface: 'copilot'` stamps `by:"copilot"` with its actor, and `surface: 'robot'` is refused `INVALID_REQUEST` 400. The copilot first save over an active row gives `[label, columns]` by copilot, and then a studio save of `label` gives `[columns:copilot, label:studio]`. The active read and `?preview=draft` are absent. After publish and after discard, the rows are `active:null` only and the next draft starts clean. The memory driver gives the same results. **Tests (merged with `origin/main` at `78652c09`, which carries #24401):** full suites, each `VERDICT command-exit 0` through `os-verify-lock`: - `@objectstack/metadata-protocol`: 247 files, 20686 tests. - `@objectstack/spec`: 762 files, 22361 tests, re-run after the alias fix. - `@objectstack/metadata-core` and `@objectstack/platform-objects`. - `@objectstack/rest`: 303 files, 6365 tests. - `@objectstack/runtime`: 424 files, 7442 tests. Typecheck exit 0 for all six touched packages. `pnpm --filter @objectstack/spec check:generated` passes after a rebuild on the merged tree. **Ablations** (`scripts/ablation-replace.mjs`: each mutation proven on disk, restored by blob hash equal to HEAD with `git diff HEAD` empty; the subjects resolve through `src`, so there is no dist hop): 1. The recorder (`if (state === 'draft' && opts.draftChangesBy !== undefined)` → `if (false && …)`) turned 4 pins red in the protocol block. 2. The replace rule (`entries.filter(…isAtOrUnder…)` → `[...entries]`) turned 4 red: 3 unit pins and the protocol's two-save pin. 3. The `copilot` refusal (both guards: the explicit branch and the closed set) turned exactly the REST `?surface=copilot` refusal pin red. **Gates, on the final head `a3925372f`:** `node scripts/pm/dispatch-gates.mjs --commands` with no paths derives 116 families. All 116 ran, each exit code captured before any pipe, and `--ran` reconciles them: 116 run, 0 NOT-MEASURED (a derived zero), 115 exit 0. - The one red is by design: `check-empty-changeset` (exit 1), the 24298 note correction above. - Six spec gates first answered exit 3 (PREREQUISITE NOT MET: the spec `dist` predated the pin-test edit). After `pnpm --filter @objectstack/spec build` each one exited 0: `check:api-surface`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`, `check:generated` and `check:skill-examples`. - `check:spec-parsed-alias` went red on an earlier head and was fixed: the alias became `z.input`, with its isomorphic pin. ## Acceptance notes - **First-save baseline for an item with no stored published row.** A draft of an item that only a code package ships (no `sys_metadata` active row) records `[]`, the whole body, per the claim's "else nothing". The `draftChanges` describe now says "the stored published row" so declared equals enforced. For Studio this means a copilot draft of a packaged view marks every key as the copilot's. It is raised in the report's open questions. - An active save accepts and validates `?surface=`, but records nothing, since there is no draft row to record on. - Downstream, not in this PR: cloud's `stageDraft` / `applyDraft` pass `surface: 'copilot'`, and objectui's Studio sends `?surface=studio` and reads the key (objectstack-ai/objectui#12725 item 3). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 --- _Generated by [Claude Code](https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e67ac4b commit 2ec6827

38 files changed

Lines changed: 882 additions & 47 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): a metadata save declares its authoring surface — `SaveMetaItemRequestSchema.surface`, in one exported closed set shared with `draftChanges[].by`
6+
7+
Clause-②: yes (widening)
8+
9+
- **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.
10+
- **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.
11+
- **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.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/metadata-core": minor
4+
"@objectstack/platform-objects": minor
5+
---
6+
7+
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
8+
9+
Clause-②: yes (widening)
10+
11+
- **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()`.
12+
- **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.
13+
- **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 `[]`.
14+
- **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).
15+
- **Translated labels.** `@objectstack/platform-objects`' translation bundles carry the new column's label and help text in every shipped locale.
16+
- **An unknown `surface` is refused.** In-process, a value outside `copilot | studio | api` is refused `400 INVALID_REQUEST` before anything is written.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/rest": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
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
7+
8+
Clause-②: yes (widening)
9+
10+
- **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.
11+
- **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.
12+
- **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.

0 commit comments

Comments
 (0)