Skip to content

feat(metadata-protocol): the draft save records draftChanges per key path; the save declares its surface (?surface=studio|api, copilot in-process only) - #24418

Merged
objectstack-fleet[bot] merged 17 commits into
mainfrom
claude/issue-24395-draft-changes-writer
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 17 commits into
mainfrom
claude/issue-24395-draft-changes-writer

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9


Generated by Claude Code

claude added 12 commits October 18, 2026 22:41
… and draftChanges.by

Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9
Co-authored-by: Claude <noreply@anthropic.com>
…t draft read serves draftChanges

Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9
Co-authored-by: Claude <noreply@anthropic.com>
…orphic (ADR-0122)

Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9
Co-authored-by: Claude <noreply@anthropic.com>
…thoringSurfaceSchema

Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 19, 2026
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/metadata-core, @objectstack/metadata-protocol, @objectstack/platform-objects, @objectstack/rest, @objectstack/runtime, @objectstack/spec, touching 26 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 685bdb95570fc5492c6d8fbde0d63104b7af3efd — the merge of head 49a8e27c6ab74145ea0bfbe6b9088dba3eb584ff into base 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 685bdb95570fc5492c6d8fbde0d63104b7af3efd && git checkout 685bdb95570fc5492c6d8fbde0d63104b7af3efd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb 49a8e27c6ab74145ea0bfbe6b9088dba3eb584ff && git checkout -B drift-repro 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb && git merge --no-ff 49a8e27c6ab74145ea0bfbe6b9088dba3eb584ff

node scripts/docs-audit/affected-docs.mjs --json 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Check Changeset on c105df36c0 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-19T02:34Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c105df36c058ea4209523a96a44ac151d28d9104
Local-runs: none
Scope: packages/spec/src/api/protocol.zod.ts + packages/metadata-protocol/src/protocol.ts (the save at :22703, the put call at :24022, the strict draft read at :11486 to :11556, the preview arm at :11425, migrateStoredMetadata at :24664, discardDraftInItsScope at :27222, duplicatePackage :28295 and the unmanaged import :28514), packages/metadata-protocol/src/sys-metadata-repository.ts (put :1029 to :1256, recordDraftChanges :951, resolveWriteHead :858, get :723, delete :1258, promoteDraft :1400 to :1708, restoreVersion :1727, whereFor :2556, findEnvironmentRow :2158), packages/metadata-protocol/src/draft-changes.ts and its test, packages/metadata-protocol/src/item-lock.ts :300, packages/metadata-core/src/objects/sys-metadata.object.ts, packages/rest/src/meta-save-request.ts, packages/rest/src/meta-item-read-gate.ts (META_ITEM_SAVE_PARAMS :2284, metaItemQueryRefusal :2413, repeatedOnceRefusal :2439), packages/rest/src/meta-item-write-routes.ts (:263 to :364), packages/runtime/src/domains/meta.ts (:1491 to :1590, the 405 at :1672), packages/adapters/hono/src/index.ts :531 and packages/plugins/plugin-hono-server/src/adapter.ts :535, packages/drivers/driver-sql/src/schema-drift.ts :6 and :1044, packages/services/service-automation/src/flow-credential-migration.ts :222, the other in-repo saveMetaItem callers (cli organization-ownership :833, runtime automation :1825, packages :1828, permission-set-clone :350, service-datasource plugin :206), packages/spec/src/api/index.ts :61, packages/spec/src/type-alias-convention.pin.test.ts, the five generated packages/spec/*/api.json, content/docs/references/api/protocol.mdx and index.mdx, the four .changeset files, scripts/check-empty-changeset.mjs (the foreign-changeset rule), the three pin files (protocol.packageless-save-address.test.ts, rest-server-meta-door-closed-sets.test.ts, meta-save-preconditions-parity.test.ts), both meta-item-query-decision tests and the eight packages/objectql stubs. The whole diff (39 files, +883/-48 against the merge base e22defb) is inside this scope; nothing is left to the lane seat's ACCEPT at TIER_DEFAULT.

Inputs read: card #24395 (body; the claim 6255766595, the dispatch 6255818963, the dev's report 6257483184 and its fix-round report 6259140498, all as claims); the seat's Check Changeset note on the PR (6258430779) as a claim; PR #24418 body, file list and the net diff git diff e22defb13c pr/24418; the precedent: card #24298, its landing PR #24383 with contract review 6253694122 (③ 1 and ③ 4), ruling A item 3 on #24324 (6250704607). The dev's local readings (the booted M measurement, the suite counts, the three ablations, the 118-family gate union) were not re-run under this record's read-only shape; the head's check-runs are the gate verdicts.

Check-runs on c105df36c0, one read at 2026-10-19T04:06Z: 41 runs, all completed; 35 success, 5 skipped (Dogfood Verify CLI, Temporal Conformance live PG + MySQL, the Dogfood Regression Gate matrix shard, Console Pin Gate, Packed-tarball smoke: path-filtered or opt-in), 1 failure: Check Changeset. Green: Test Core and all six shards (1/6 at 02:46Z, so the packages/spec test:repo red the dev saw on a392537 does not stand on this head), Build Core, Build Docs, TypeScript Type Check and its workspace, source-gate, consumer-gate and debt-ledger legs, Lint & Repo Gates and every Lint lane, Spec property liveness, Governed Surface Queue Guard, Check Documentation Links, Flag docs affected, Check PR Size, Auto Label, filter, and the four PR-shape guards. The Check Changeset run (117861905774) carries exactly one failure annotation beside the exit-code line, on .changeset/24298-draft-key-provenance.md, with the foreign-changeset rule's two-class sentence (COLLISION / DELIBERATE CORRECTION); no empty-frontmatter row and no other path. So the reason the seat's note names is the ONLY reason that gate is red.

① Derived judgments

  1. The closed set and its one name. MetadataAuthoringSurfaceSchema is z.enum(['copilot', 'studio', 'api']) with a describe, exported from @objectstack/spec/api through api/index.ts:61 (export * from './protocol.zod'); MetadataAuthoringSurface is its z.input alias, pinned isomorphic (the ADR-0122 pin, 788 to 789). SaveMetaItemRequestSchema.surface is MetadataAuthoringSurfaceSchema.optional(); GetMetaItemResponseSchema.draftChanges[].by is the same schema with its own describe (.describe() clones, so the shared describe is untouched) and the same three values the inline enum had. The generated artefacts carry it: api-surface, declaration-map, export-origins and json-schema.manifest gain the pair, authorable-surface gains api/SaveMetaItemRequest:surface, protocol.mdx gains the MetadataAuthoringSurface section and the surface row, index.mdx moves 1540 to 1541 and 437 to 438. check:generated and check:api-surface sit in the green source-gate leg. RIGHT.
  2. Absent stamps api. saveMetaItem passes draftChangesBy: request.surface ?? 'api' (protocol.ts :24035) on an authoring draft save; the by describe says "api when it declared none". RIGHT.
  3. Both HTTP doors accept studio | api and refuse the rest 400 VALIDATION_ERROR. One reader, metaSaveRequestOptions (meta-save-request.ts :142 to :159): an explicit copilot branch, then HTTP_SAVE_SURFACES = {studio, api} by exact string, so Studio, robot and '' are refused and never folded to api. RestServer's PUT calls it at meta-item-write-routes.ts :283 after refuseUnknownQueryParams(META_ITEM_SAVE_PARAMS) (:281, the set now force, package, mode, surface) and refuseRepeatedQueryParams([..., 'surface']) (:282), and writes { error: { code: 'VALIDATION_ERROR' } } at :285. The dispatcher's PUT asks metaItemQueryRefusal('save', query) at meta.ts :1491 (recognition over META_ITEM_SAVE_PARAMS less force, then arity through repeatedOnceRefusal), then the same mapping, answering deps.error(message, 400). The repeat IS reachable on the dispatcher: both hono hosts normalise a repeated name to an array (readDispatchQuery, adapters/hono :531; readQuery, plugin-hono-server :535), and metaItemQueryRefusal('save', { surface: ['studio', 'api'] }) is pinned repeated-parameter in the rest query-decision test. The parity test drives both doors over a real SQL store per value, with copilot and Studio refused and nothing stored. The parity is real: the two doors share the closed set, the arity rule and the value reader. RIGHT.
  4. No door builds the save request by spreading a client body. The REST literal is built field by field (meta-item-write-routes.ts :341 to :361; ...saveOptions.request is the closed mapping's own output, never the body); the dispatcher's likewise (meta.ts :1579 to :1590). The protocol's own gate refuses a value outside the set 400 INVALID_REQUEST before canonicalizeMetaRequestType and before any read (protocol.ts :22757 to :22766), the same code and position as the item-null envelope refusal above it, so no new code. Every other in-repo saveMetaItem caller (cli organization-ownership :833, runtime automation :1825, packages :1828, permission-set-clone :350, service-datasource plugin :206, service-automation flow-credential-migration :222, and the protocol's own duplicatePackage :28295, unmanaged import :28514 and migrateStoredMetadata :24664) passes no surface and none spreads a body. copilot is reachable in-process only, which is the card's order; cloud's stageDraft / applyDraft is its named producer. RIGHT.
  5. Accepted on an active save, recording nothing. put records only when state === 'draft' && opts.draftChangesBy !== undefined (repository :1154), and saveMetaItem passes draftChangesBy only when mode === 'draft' (:24035); the surface describe says "an active save records no per-key provenance", pinned. Open question 4, letter A: declared equals enforced. RIGHT.
  6. The recorder's transaction. put runs its reads and writes inside withTxn (:1070); existing is the row resolveWriteHead returns in that transaction (:1074), the same row the lock judges; recordDraftChanges runs at :1155 inside the same callback, and its first-save read of the active row carries { ...ctx, isSystem: true } so the statement joins the transaction (legacyOrganizationRowIds reads context.transaction). at is the write's own now (new Date().toISOString(), :1109), the Z form z.string().datetime() accepts; actor is opts.actor ?? null (:1157), the value recorded_by gets on the history row (:1207) and the save passes as request.actor ?? null (:24026). draft_changes is written in the SAME engine.update / engine.insert as metadata (parentRowData, :1159), then the history insert; on a SQL driver the three commit or roll back together. Two concurrent draft saves therefore cannot interleave into entries that describe another body: each computes its entries against the existing it read and writes body and entries in one row update, so the later commit replaces both together, and a pinned save (If-Match) is refused by lockAccepts on the same resolution. A save that fails after the diff leaves nothing on SQL (one transaction); the memory driver's withTxn has no rollback (documented at :689, pre-existing), and the credential-channel write that precedes the put (protocol.ts :24010) is the pre-existing caveat, not entries. RIGHT.
  7. The baseline: draft row, else active row, else nothing. recordDraftChanges (:951 to :983) compares with existing (the draft row resolveWriteHead returned), else the active row at the binding the write stamps (parentRowData.package_id), else the package-less active row (ADR-0048 prefer-local, the row the strict and preview reads serve an author through findServedOverlayRow), else undefined, which draftChangedPaths answers [[]], recorded as []. The describe says "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". RIGHT; the repository docblock's phrase "the active row its publish would replace" is looser than the code for one edge (③ 18).
  8. One entry per path, replacing at or under; an array is one leaf. mergeDraftChanges (draft-changes.ts :106 to :113) drops every entry whose path is at or under a changed path (isAtOrUnder), keeps the rest in order, appends one entry per changed path; an entry ABOVE a changed path is kept, so the longest prefix answers and entries under a key's path are changes inside it; [] replaces everything. draftChangedPaths (:60 to :96) recurses only while both sides are plain objects (isPlainObject excludes arrays) and otherwise compares canonicalize whole, so an array's path ends at its key with no index; a key held undefined counts as absent, as JSON stores it; two refinements beyond the describe are consistent with the spec: a pure reorder of a declared ordered map (an object's fields) is a change at the map, as the hash already says, and a change under an empty key is reported at its parent because a segment is min(1). Every path it answers parses under GetMetaItemResponseSchema (pinned). RIGHT.
  9. A save that changes no key path leaves the column as it was (:980 returns undefined, :1159 writes only when defined). The one case this reaches is a first draft save whose body equals the published row, which inserts the draft row with draft_changes NULL and reads ABSENT, never [] (pinned). The describe says absent "for a draft row whose saves recorded none" and the TSDoc now names this case ("one whose saves changed no key path"). RIGHT.
  10. The keep rule. promoteDraft puts the ACTIVE row (state: 'active', no draftChangesBy; parentRowData carries no draft_changes key, so an updated active row keeps NULL) and then drains the draft row by its own hash and binding (dropPromotedDraftRow, :1690 to :1698); a drain refused by the [metadata-protocol] SysMetadataRepository.publishDraft() 把 draft 清理的全部失败都当「并发发布者已抽走」,静默留下一条永远 pending 的 draft 行 #4981 benign race leaves a NEWER draft row, still pending, with its own entries, which is right. The discard is discardDraftInItsScope (protocol.ts :27222), deleteMetaItem with state: 'draft', repo.delete, engine.delete of the row (:1330). The only repository put the protocol calls is saveMetaItem's (:24022), so every draft-row write passes the draftChangesBy gate; restoreVersion, rollbackMetaItem and revertCommit write active rows only ("Does NOT touch any draft row", :1363); duplicatePackage and the unmanaged import save mode: 'publish'; the package rebind (:28799) spreads a stored row into a registry entry, never back into a row; uninstall and the package revert delete rows whole. A migrate-stored rewrite of a draft row (migrateStoredMetadata, and the flow credential migration at service-automation :222, both source: 'migrate-stored') states a source and records nothing (the gate is request.source === undefined, :24035), leaving the entries as they were while the body is canonicalised, the card's own order ("not authoring"). No path deletes or rewrites a draft row and leaves stale entries, and none records a path it should not. RIGHT.
  11. The serve. getMetaItem reads draft_changes off the served record only when readState === 'draft' (protocol.ts :11522) and spreads ...(draftChanges ? { draftChanges } : {}) into the strict-draft return beside item and version (:11554); storedDraftChanges answers undefined for NULL, an absent column, '', '[]', non-JSON and any malformed entry (draft-changes.ts :122 to :140), so the key is never []. The previewDrafts arm returns { type, name, item } before that read (:11425 to :11470); the active return never carries it; the cached branch is getMetaItemCached, a different method. On the REST door translateMetaEnvelope spreads the protocol's envelope and the dispatcher returns it as data, so neither door needed a transport change (review 6253694122 ③ 4 held; the parity test reads both doors). listDrafts (:25792) is a declared shape and unchanged. Item reads never spread row columns into item: storedRowDocument (item-lock.ts :300) and storedRowBody (repository :203) read metadata alone. RIGHT.
  12. The column. SysMetadataObject.draft_changes is Field.textarea({ required: false }), with the dev's docblock stating the keep rule. sys_metadata is isSystem: true, managedBy: 'engine-owned', whose "All Metadata" menu is a read-only debug surface that already serves metadata, checksum, updated_by and created_by; the new column is served on that data read as every column is, beside the body it describes, which is no wider a disclosure than metadata plus updated_by already are; no per-column allow-list exists for this object and the meta document's read decorations (METADATA_READ_DECORATIONS) never touch row columns. Existing deployments: the SQL driver's initObjects sync is additive-only, "creates missing tables and adds missing columns" (schema-drift.ts :6 to :7, the ALTER TABLE ADD COLUMN path :1044), the memory driver registers declared fields on syncSchema, Mongo is schemaless; the column is nullable and NULL reads as not recorded, so no backfill and no ADR-0087 entry are owed. The dev measured SQLite (the pre-writer table gained the column on boot) and the memory driver; live PG and MySQL ran through no CI job on this head (Temporal Conformance was path-skipped), so they rest on the driver's additive rule rather than a measurement. The eight objectql stubs are the same fact from the other side: the engine's declared-field door refuses a write to an undeclared column, so a fixture that drives a draft save must declare it, exactly as the real object does. RIGHT.
  13. The first-save baseline gap (open question 3). An item only a code package ships has no stored active row, so its first draft save records [] and Studio will mark every key as the surface's. The key's describe states it exactly ("a draft of an item with no stored published row is recorded as [], the whole body"), the changeset and the PR body restate it, and the TSDoc names it; declared equals enforced on the sentence that states the rule. One phrase one level down is looser: the path describe's [] sentence, "the write that created the draft of an item with no published body" (unchanged from feat(spec): the draft read declares draftChanges, a pending draft's per-key provenance #24383), under-states the case, since a packaged item HAS a published body served from code and still records []. Not false, incomplete; the outer describe completes it. Honest; the phrase is ③ 13.
  14. The describe and docblock text that states the writer's contract. The sentences that FAILed the precedent (6253694122 ①.9) are now true: the draft save compares, keeps, replaces at or under, serves on the strict draft read only, and the "No server writes the key yet" sentence is gone from the describe, the TSDoc and this PR's changesets. Each remaining sentence was checked above against the code it names. The surface describe's refusal sentences (both HTTP doors 400 VALIDATION_ERROR, copilot included; in-process 400 INVALID_REQUEST) match ①.3 and ①.4. RIGHT.

② Semver level

Three changesets ship with the diff, each Clause-②: yes (widening): .changeset/24395-draft-changes-surface.md (@objectstack/spec: minor), 24395-draft-changes-writer.md (@objectstack/metadata-protocol, @objectstack/metadata-core, @objectstack/platform-objects: minor), 24395-http-save-surface.md (@objectstack/rest, @objectstack/runtime: minor); the PR body's line 3 is Clause-②: yes (widening). Against what ships: the spec gains one export pair and one optional request member, and by's accept set is unchanged; the response key draftChanges was declared optional in #24383 and is now served, so a consumer typed against it parses; both HTTP doors refused every ?surface= as an unknown parameter before (Supported parameters: force, mode, package. and mode, package.) and now accept studio | api while still refusing the rest, so nothing previously accepted is refused; in-process, surface was not a member, and an untyped caller's off-set value was ignored before and is refused now, the one narrowing-shaped edge, on a member that did not exist, the same additive-optional-member case the precedent judged (widening); sys_metadata gains a nullable column under an additive sync. yes takes at least minor; the arm (widening) matches each diff; no ADR-0087 marker and no migration are owed. RIGHT.

The foreign changeset edit, .changeset/24298-draft-key-provenance.md. Both notes are pending in .changeset/ on main at 18.0.0-next.3 (packages/spec/CHANGELOG.md carries no draftChanges entry), so absent an intervening Version Packages cut they ship in one release. For that release the original bullet, "No server writes the key yet; until one does, and for a draft row saved before it did, the key is absent rather than []", is FALSE in its first clause: @objectstack/metadata-protocol in the same cut writes the key. The rewritten bullet, "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'", is TRUE for that release (①.9, ①.11), and stays true should the spec note ship alone, since it states the absence rule only. The note's closing bullet, "no runtime behaviour changes in this release of the spec", stays true: the spec package carries none. The spec changeset of this PR says the describe "no longer says no server writes it", which is true. Whether the correction stays here (A) or is restored from base and stated in this PR's own changeset (C′) is the maintainer's decision; one mechanical fact for it: a Version Packages cut landing before this PR deletes the edited file on main, and the modification then conflicts at merge, which C′ avoids by construction.

③ Boundary flags

  1. packages/rest/src/meta-item-read-gate.ts (META_ITEM_SAVE_PARAMS) and packages/rest/src/meta-item-write-routes.ts (the arity list), outside the claim's file surface. Needed: the closed set is where recognition lives, and without it ?surface= is refused as unknown on both doors; the arity list is the same door's gate. The domain:cli declaration the dispatch names (6255785533 on [PM seat] domain:cli — 🟢 zhuangjianguo · session_01SYEJqDL3NXxrJRBVqaMu9T #6024, 6255791182 on [PM seat] domain:cli · seat 2 — 🟢 os-justin · session_01LmjU2x1faLZ39DVTFyVca6 #22648) covers the two HTTP save doors. Accepted.
  2. The new packages/metadata-protocol/src/draft-changes.ts and its test. Pure functions, in the package that owns the writer; nothing of it in packages/spec. Accepted.
  3. The four translation bundles regenerated for the column, with zh-CN, ja-JP and es-ES values hand-translated. Values only; check-i18n-bundles sits in the green lint lanes. Accepted.
  4. The ADR-0122 isomorphic pin, 788 to 789, with its ledger note. Mechanical, demanded by check:spec-parsed-alias. Accepted.
  5. The foreign changeset edit. Judged in ②: original false for the joint release, rewritten true; the A / C′ choice is escalated to the maintainer as the seat's note already has (6258430779). Not a defect of the diff.
  6. The protocol-level pins appended to protocol.packageless-save-address.test.ts, reusing its ledgered engine double rather than registering a new one. The os-dev rule prefers an existing double. Accepted.
  7. The trailers. Every commit on the branch ends with Claude-Session: https://claude.ai/code/session_01Q3mCs3NX4GshNUCh7Fr1b9 and the model-free Co-authored-by: Claude trailer, the pair AGENTS.md prescribes; the harness reminder's model-named trailer was not used, and AGENTS.md's own exemption calls a harness-written one REPORTING. No model identifier in the PR title, body, comments, changesets or code. Compliant; accepted.
  8. The detached checkout. The dev ran git checkout --detach on the shared primary checkout /home/user/objectstack (twice: at dispatch and at the fix round) because the dispatch left this branch checked out there; nothing was edited in it and the branch was restored at cleanup. A HEAD switch on the shared tree is the hazard Prime Directive Migrate documentation site to Fumadocs with monorepo structure and shared content #11 names for other agents' uncommitted work; a process flag for the seat, not a defect of the diff.
  9. The eight packages/objectql test stubs declaring draft_changes. Fixtures of a hand-written minimal sys_metadata object; the engine's declared-field door refused the new column (Unknown field draft_changes), the same class the files' Undeclared fields still reach the driver: beforeInsert hooks run and an auto-number is consumed before the request is refused, and the whole INSERT with its values is logged at ERROR (17.0.0 GA) #8682 note records. The fixture half of the column's pin; no production code moved. Accepted.
  10. The packages/spec test:repo red on a392537 that did not reproduce. On this head Test Core (1/6) is green (02:46Z), the authoritative reading the dev recommended; the stale content/docs/references/index.mdx was regenerated in the same push (fc7d18f). The a392537 cause stays unmeasured and is moot on this head. Closed.
  11. Open question 1, the query spelling (A chosen). Right: the doors already read save intent from the query (?mode=, ?force=, ?package=) through one closed set with recognition and arity; a custom header would need a DEFAULT_CORS_ALLOW_HEADERS entry for any cross-origin browser caller, and a misspelt header is invisible where an unknown query name is a located 400. Answered.
  12. Open question 2, the column home (A chosen). Right: the entries live and die with the row, written in the put's transaction, deleted by publish and discard with no second cleanup path to forget; an absent column reads as not recorded. Answered; the PG / MySQL half rests on the driver's additive sync rule (①.12), not on a measurement.
  13. Open question 3, the first-save baseline on a code-only item (A shipped, B as a follow-up). The card's "else nothing" is what shipped and the describe says so (①.13). The reader-side cost is real: a copilot draft of a packaged view marks every key as the copilot's. Escalated to the seat as the follow-up card the dev named, to be filed if Studio's reader over-marks (objectui#12725 item 3). One phrase owed when that card or any next describe edit touches this schema: the path describe's "an item with no published body" should read "no stored published row". Not a defect of this head.
  14. Open question 4, surface on an active save (A chosen). Right, ①.5. Answered.
  15. Open question 5, the changeset correction. ② and item 5. Escalated.
  16. Out-of-scope finding: cloud's stageDraft / applyDraft passing surface: 'copilot' once cloud's framework pin carries this. Downstream, named on the card; cloud's seat files it at landing. Not filed here; accepted.
  17. Out-of-scope finding: objectui's reader (Studio copilot: one right slot that shows either the properties or the copilot — properties follow the selection and mark the copilot's drafts; the copilot folds to a strip while properties show; change card v2; the 变更 badge in sync; no 源码 tab; pillar labels kept objectui#12725 item 3), Studio sending ?surface=studio and reading draftChanges off readDraftForSave's envelope. The pointer is on that card; accepted.
  18. Out-of-scope finding: the runtime dispatcher answers 405 (Allow: GET, HEAD, PUT) for DELETE and POST /publish on a metadata item. Measured true in meta.ts :1672 to :1697, and deliberate: the code's own comment says mounting the delete on that transport "would expand the public surface and needs its own card". Dormant, no producer on a catch-all-only topology; not a filing class. The seat may note it in the PR's acceptance notes. Two prose imprecisions, no behaviour: the PR body's "the only caller that states a source" is two callers with one label (migrateStoredMetadata and the flow credential migration), both server-stated rewrites that record nothing; and the repository docblock's "the active row its publish would replace" is looser than the code for a package-bound first draft over a package-less active row, where promoteDraft writes under the draft's own binding without falling back while the recorder compares with the package-less row the read served, which is the describe's "stored published row". Neither needs a change on this head.
  19. The meta-save-request.ts header sentence that the catch-all flattens a repeated query name to one string predates this PR and is now contradicted by both hono hosts' array normalisation (①.3); harmless, since the dispatcher judges arity before the mapping reads a value. Not this PR's.

Implemented-by: claude/issue-24395-draft-changes-writer
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS

Every served-set and accept-set change in ① is right as judged; the three changesets and the PR body carry the level the diff takes; the one red check is the foreign-changeset rule on a deliberate correction whose rewritten sentence is true for the release that ships both notes, and that decision (A / C′) is the maintainer's. Rendered 2026-10-19T04:30Z.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-19T04:25Z · ACCEPT (queue held on one decision)

Head: c105df36c0 (the writer, the HTTP spellings, the generated surfaces, a merge of main at 3db85cc7f, the regenerated reference index, the eight fixture stubs). Card #24395 (p1, area:studio, target:v18), claim 6255766595, dispatch 6255818963. Dev reports: 6257483184, fix round 6259140498.

Item Reading
Shape Draft PR → main. The body opens Fixes #24395. 39 files, +883 / −48. Not governed (check-governed-merges.mjs --pr 24418: no path hits the register; 931 lines, under the human-merge threshold).
Scope One closed set, MetadataAuthoringSurfaceSchema, shared by SaveMetaItemRequestSchema.surface and draftChanges[].by. The draft save records per-key entries on a nullable sys_metadata.draft_changes column inside put's transaction; the strict draft read serves them. Both HTTP doors accept ?surface=studio|api and refuse copilot and every other value 400 VALIDATION_ERROR; in-process, an off-set value is refused 400 INVALID_REQUEST. No new error code.
Seat's own read At the head: metaSaveRequestOptions refuses copilot by an explicit branch and anything outside {studio, api} by exact match; saveMetaItem passes draftChangesBy only when mode === 'draft' and no source is stated; put records only for state === 'draft' with that stamp; getMetaItem reads the column only when readState === 'draft' and spreads the key only when non-empty; storedDraftChanges never answers []. These match the record's ① 3 to ① 11.
Review CONTRACT_REVIEW_TIER record of record 6259630019, on this head: PASS.
Declaration Clause-②: yes (widening) on the claim, the PR body and each of the three changesets.
Cross-lane domain:spec 6255777346, 6255780671 and addenda 6259433698, 6259437563; domain:cli 6255785533, 6255791182 and addenda 6259421806, 6259427539; domain:devx 6259442690, 6259447625.
Gates Dev, on c105df36c0: 118 derived, 118 run, 117 exit 0. The one exit 1 is check-empty-changeset, the note below.
CI At this read on c105df36c0: 41 runs, 35 success, 5 rostered skips, 1 failure. The failure is Check Changeset (run 117861905774), and its only file annotation is the foreign-changeset rule on .changeset/24298-draft-key-provenance.md.

Disposition:

  • open_questions: 1 (query spelling, A), 2 (column home, A) and 4 (surface on an active save, A) accepted, as the record's ③ 11, ③ 12 and ③ 14 judge them. 3 (first-save baseline on a code-only item) accepted as shipped and stated in the describe; the follow-up card is filed only if Studio's reader over-marks (objectui#12725 item 3). 5 (the foreign changeset) is the maintainer's, below.
  • deviations: the packages/rest closed-set and arity rows and the eight packages/objectql fixture stubs, accepted (③ 1, ③ 9). The dev detached its own container's primary checkout to cut the worktree; noted for the seat's next prompt, not a defect of the diff (③ 8).
  • out_of_scope_findings: cloud's stageDraft / applyDraft passing surface: 'copilot', and objectui#12725 item 3's reader: both pointers are sent at landing. The dispatcher's 405 for DELETE and POST /publish on an item is deliberate (its own comment names a card); noted, not filed.

Landing: held on one decision. The PR rewrote the pending 24298-draft-key-provenance.md sentence "No server writes the key yet", which this PR makes false in the same release (record ②; blocker note 6258430779). The maintainer chooses:

  • A: the correction stays here, confirmed on this PR as a deliberate correction;
  • C′: the note is restored from base and the correction is stated in this PR's own changeset (the seat's lean). A Version Packages cut that lands first would otherwise conflict with the edited file.

After the ruling: the ordinary queue (pr_ready + automerge_enable through the relay). A C′ head that moves only .changeset/ keeps this record; the seat re-checks the delta before it queues.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · delta · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-19T06:13Z · ACCEPT carries

Head: 49a8e27c6a (fix round 2, report 6260899374): a merge of main at 95246668d7 (ede240123d), then the regenerated authorable surface and the changeset sentence.

Item Reading
Why the round The Version Packages cut 725a5b1311 (#24168, @objectstack/spec 18.0.0-next.4) published 24298-draft-key-provenance.md and moved it to .changeset/pre/. Its sentence "No server writes the key yet" was true for next.4, which ships no writer. That closed the A / C′ question without a ruling and made this PR dirty.
Net diff, seat's own read Against its base, the PR's net file list on 49a8e27c6a equals the one on c105df36c0, less .changeset/24298-draft-key-provenance.md: 38 files, +882 / −47. Per file, the net patch is byte-identical except .changeset/24395-draft-changes-surface.md, which gains one sentence: "18.0.0-next.4's note said no server writes draftChanges yet; from this release the draft save writes it." packages/spec/authorable-surface/api.json nets to the same one line, api/SaveMetaItemRequest:surface. The released note in .changeset/pre/ is main's, untouched.
Record The only path that moved is a changeset, a prose face, so the CONTRACT_REVIEW_TIER record 6259630019 (PASS) carries to this head.
Gates Dev, on 49a8e27c6a: 118 derived, 118 run, 118 exit 0. check-empty-changeset --base origin/main exit 0; check:generated exit 0.
Landing The ordinary queue (pr_ready + automerge_enable through the relay) once CI on 49a8e27c6a is read green.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants