Skip to content

feat(spec): the draft read declares draftChanges, a pending draft's per-key provenance - #24383

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-24298-draft-key-provenance
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-24298-draft-key-provenance

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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 (fe32063f1f92) 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


Generated by Claude Code

…e, on the draft read

GetMetaItemResponseSchema gains an optional draftChanges list beside item:
one entry per changed key path of the draft body (path as an array of
stored keys, arrays atomic), naming the authoring surface of the last write
that changed it (closed: copilot | studio | api), its actor and its time.
Absent means not recorded. ListDraftsResponseSchema is unchanged and points
at the draft read.

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

authorable-surface/api.json (build), content/docs/references/api/protocol.mdx
(gen:docs) and the api/ strictness-ledger count shard (gen:strictness-ledger),
each one check:generated proved stale; minor changeset on @objectstack/spec.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…aft-key-provenance

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
The os-regen driver kept the branch's side of protocol.mdx; regenerated from
the merged tree it carries both main's history `source` describe and this
branch's draftChanges rows.

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

The ruled properties artboard marks elements of a kanban card's field list.
A path stops at the array's key, so the reader compares the draft's array
with the published one for which elements, and the entry answers who last
changed the array; attribution is per array.

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

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • 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 — 143 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 b2e78701f779809b588096d82f5d86ba4ab5cfde → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 14ab696a224d91293244b4cd23700e3c66f5dd7a — the merge of head 9e98be435433158b531379b1bf2ef811fd2b7364 into base b2e78701f779809b588096d82f5d86ba4ab5cfde, 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 14ab696a224d91293244b4cd23700e3c66f5dd7a && git checkout 14ab696a224d91293244b4cd23700e3c66f5dd7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b2e78701f779809b588096d82f5d86ba4ab5cfde 9e98be435433158b531379b1bf2ef811fd2b7364 && git checkout -B drift-repro b2e78701f779809b588096d82f5d86ba4ab5cfde && git merge --no-ff 9e98be435433158b531379b1bf2ef811fd2b7364

node scripts/docs-audit/affected-docs.mjs --json b2e78701f779809b588096d82f5d86ba4ab5cfde

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 (seat check) · PR #24383 · domain:spec seat 2 · zhuangjianguo · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-18T18:29Z

ACCEPT at head 438e3570f618dd2c4134aa5954d6cbf05429c627 (resume claim 6252719015, report 6253523211 on #24298). A contract review at CONTRACT_REVIEW_TIER is owed (schema face, Clause-②: yes). It runs on this head, isolated from the seat; needs:contract-review is on the PR until its record is adopted. The PR lands after a PASS there and a fully green head.

item reading
Shape Draft, base main, assignee zhuangjianguo. The body's line 1 is Fixes #24298, line 3 is Clause-②: yes; no other closing keyword. 6 files, +174/-1, not governed. The changeset is minor on @objectstack/spec with Clause-②: yes (widening). main was merged at 6a49efb9c through os-regen-merge.sh, and the reference page was regenerated with both describes.
The key GetMetaItemResponseSchema.draftChanges, optional, beside item. Each entry is { path: string[] (non-empty segments), by: 'copilot' | 'studio' | 'api', actor: string | null, at: datetime }. It is present only on a strict ?state=draft read of a stored draft row; absent means "not recorded", never []. ListDraftsResponseSchema is unchanged except a docblock pointer. Placing it on the draft read matches the reader the dev measured: Studio's properties load through readDraftForSave → getDraft (DraftConflictDialog.tsx:162, at objectui main and at the pin).
Ruling A on #24324 It changes nothing this key serves: item 3 names this key, and item 2 (objectui#12968) reads _drafts counts.
Accepted deviation The entry is not .strict(), against the seat's first suggested route: the strictness ledger classes server responses as wire. The contract review judges it.
For the contract review to weigh The describe states the semantics of a writer that does not exist yet ("a write is compared with the body it replaced", "kept from the first recorded save until the draft is published or discarded"). The changeset says "No server writes the key yet". Whether those describe sentences are as narrow as today's enforcement (AGENTS.md Prime Directive #10), with the writer filed at landing per triage 6249057462, is the review's call.
Local runs (dev, on 438e3570f) spec typecheck and 695 test files passed; check:generated 14/14; the enum ablation turned 1 test red and the restore was proven by blob hash. Consumers: client, client-react, service-automation typecheck; client tests and the rest / runtime meta-item tests are green; a full turbo build passed (76 tasks). Gates: 110 derived, 110 run with exit 0, reconciled with --ran.
At landing (triage's order) The seat files the writer and reader cards the dev proposed. W1 (domain:engine): the draft save door records the paths and declares the surface input. W2 (cloud): the copilot passes copilot. R1 (objectui): Studio's properties draw the chip and the source line.
Landing After the contract review PASS and a fully green head: pr_ready then automerge_enable. The merge closes #24298.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 438e3570f618dd2c4134aa5954d6cbf05429c627
Local-runs: none
Scope: packages/spec/src/api/protocol.zod.ts + packages/spec/src/api/meta-item-response-shapes.test.ts, .changeset/24298-draft-key-provenance.md, content/docs/references/api/protocol.mdx, packages/spec/authorable-surface/api.json, docs/audits/2026-07-unknown-key-strictness-ledger.md and its counts/api.md shard, packages/spec/src/ui/view.zod.ts, packages/spec/src/security/permission.zod.ts, packages/spec/src/kernel/metadata-read-decorations.ts, packages/spec/liveness/README.md and liveness/api.json, docs/adr/0033-ai-assisted-metadata-authoring.md, packages/metadata-protocol/src/protocol.ts, packages/rest/src/meta-item-read-gate.ts, packages/rest/src/meta-item-write-routes.ts, packages/runtime/src/domains/meta.ts; in objectui at origin/main 024b7b665 and at the pin 0707b4ae3712: packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx, packages/data-objectstack/src/metadata-client.ts, packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx and ObjectListViewInspector.tsx; the ruled StudioRailProps artboard at objectui 98aab506a. The whole diff (6 files, +174/-1 against the merge base 6a49efb) is inside this scope; nothing is left to the lane seat's ACCEPT at TIER_DEFAULT.

Inputs read: card #24298 (body and all nine comments: triage 6249057462, claim 6249183877, the director's hold 6249880263, the release 6250059826, the pointer 6250719920, the unlock 6250871756, the resume claim 6252719015, the dev's report 6253523211, the seat's ACCEPT pointer 6253556843); ruling A on #24324 (6250704607); objectui#12725 (body, the phase-5 report 6248434538, ruling B 6251335993, the re-point 6252247120); PR #24383 body, file list and net diff; the check-runs on the head. The seat's ACCEPT comment on the PR (6253544200) was read as a seat record, not as evidence.

Check-runs on the head, three reads: at 2026-10-18T18:31Z 25 success, 8 skipped, 10 in progress; at 2026-10-18T18:39Z and again at 2026-10-18T18:41Z 28 success, 8 skipped, 7 in progress (Test Core 1/6 to 6/6, Type Check · workspace). Green at the last read: Check Changeset, Lint & Repo Gates, Build Docs, Build Core, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, every Lint lane, Governed Surface Queue Guard, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, Dogfood Regression Gate, the four PR-shape guards and filter. Skipped (path-filtered or opt-in): Packed-tarball smoke, Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, the dogfood matrix shards, Auto Label and Check PR Size duplicates. No failure at any read. The seven still running are the gate verdicts for the test and workspace typecheck families; this record does not stand in for them.

① Derived judgments

  1. The accept-set. GetMetaItemResponseSchema gains draftChanges, optional, no default: an array of { path: string[] (each segment min 1), by: z.enum(['copilot','studio','api']), actor: string | null, at: z.string().datetime() }. The outer schema was already strip, so a body carrying the key parsed before this PR too, with the key dropped; what changes is that the key is now kept and typed. Absent stays absent (the test pins 'draftChanges' in parsed false). RIGHT.
  2. The refusals. A surface outside the set refuses at draftChanges.0.by (invalid_value), a dotted-string path at path (invalid_type), an empty segment at path.1 (too_small), a missing at (invalid_type) and a non-ISO at (invalid_format); path: [] is admitted (the whole body of an item created as a draft). Each is pinned with its located issue in the new test, and the type-level pins hold by, path, actor, the key's optionality and the _drafts row's absence of the key. RIGHT.
  3. at is z.string().datetime(), the spelling createdAt already uses on this file. In zod 4 that admits the UTC Z form and refuses an offset, so "ISO-8601 instant" in the describe is wider than the parse; toISOString() stamps satisfy it. RIGHT as a parse; a note for the writer, in ③.
  4. The home. The key sits on the draft read, beside item, and ListDraftsResponseSchema changes only its docblock. Ruling A item 3 names "the spec key on the draft read", and the reader measured holds at both objectui refs: Studio's properties load the edited item through readDraftForSave (DraftConflictDialog.tsx:162 at main and at the pin), which is client.getDraft (metadata-client.ts:1228), which is readItemResponse with state=draft (:1141) returning res.json() whole; the callers are StudioDesignSurface.tsx :3580, :3771, :5698, :7382 (at the pin :3534, :3725, :5646, :7324), ObjectListViewInspector.tsx:320, PermissionMatrixEditor.tsx:644, ResourceEditPage.tsx:1141 and :1945, ObjectHooksPanel.tsx:238. extractDraftBody(draftRead.answer) takes .item off a value the caller keeps, so a reader reads .draftChanges off what it already holds. isMetaItemEnvelope (:858) matches on type, name and item by presence, so one more key changes no unwrapping. The pin and current main are byte-identical on the three reader files. On the server side the REST door spreads the protocol's envelope (translateMetaEnvelope, meta-item-read-gate.ts:3179: ...envelope, ...sortability, item), and the dispatcher's ?state=draft read is runtime/src/domains/meta.ts:793; a writer serves the key with no transport change. The _drafts row stays per item, and the name draftChanges avoids the ADR-0010 envelope key provenance (protocol.zod.ts:456). RIGHT.
  5. The shape against the ruled artboard. StudioRailProps at 98aab506a: a kanban card selected (「小王子 · 郑欣怡」), 卡片字段 listing 图书, 读者 草稿, 应还日期 草稿, 状态, and the 来源 line 「读者」「应还日期」两项由 AI 副驾在 2 分钟前作为草稿加入;发布前可在这里改,或在「变更」里整体撤回。 A view body keys its kanban config kanban (view.zod.ts:2866) and the card fields are KanbanConfigSchema.columns, a z.array(z.string()) ("Fields to show on cards", :1973), so under this shape the entry is ["kanban", "columns"] with by: 'copilot' and at; which two elements, and 「加入」, come from comparing the draft array with the published one. That draws the artboard. The stated limit, attribution per array, is honest: a hand edit of the same array before publish flips the entry to studio and the reader can no longer name the copilot's elements; no artboard or ruling draws that case, and a per-element member later is additive. path as string[] is right because keys carry dots (PermissionSetSchema.fields is keyed entity_name.field_name, permission.zod.ts), and an index-free array leaf is right because Studio's column selection is positional (listViewColumnSelection builds config.columns[N], ObjectListViewInspector.tsx:452). actor nullable matches the history event's actor ("null = system-initiated"). RIGHT.
  6. Not .strict() on the entry. The strictness ledger's class rule reads "wire: stay tolerant (.strip / .passthrough); strictness here turns an upstream addition into our parse crash", and its api/ row classes the directory "mixed · endpoint.zod.ts authorable, the rest wire … REST/GraphQL request/response contracts, tolerant by design". Every sibling entry on this schema is strip; the value set and the path spelling are closed. The counts shard moves 444 to 445, the one new z.object site. RIGHT.
  7. The closed by set, studio included, has no door today. SaveMetaItemRequestSchema.writeFace is package-duplicate | meta-envelope | meta-dispatch | external-import, server-stated per transport (meta-item-write-routes.ts:350 stamps meta-envelope, meta.ts:1580 meta-dispatch); the history source is an open code-path label (protocol.${operation}MetaItem, protocol.ts:19250); objectui's MetadataClient.save is a plain PUT /meta/:type/:name?mode=draft with the auth headers and If-Match, no surface marker (metadata-client.ts:1308 to :1352). So no server can tell a Studio save from any other HTTP caller, and studio is a value no writer can stamp until a save-request input exists. The three values are the card's ("by: the authoring surface, copilot | studio | api") and the triage's ("domain:engine writes it in metadata-protocol's save door (studio / api)"), so declaring them is the card's order, not the dev's invention; the risk is real and is carried in ③. RIGHT as declared, with the escalation there.
  8. No liveness ledger covers this key. packages/spec/liveness/ is registry-rooted (its README: the walk reads BUILTIN_METADATA_TYPE_SCHEMAS), and liveness/api.json is the api metadata TYPE (ApiEndpointSchema), not the api/ directory; GetMetaItemResponseSchema is no registered kind, so Spec property liveness is green by construction and authorable-surface/api.json records only api/GetMetaItemResponse:draftChanges exists. RIGHT, and it is why item 9 matters: no gate will ever flag this key as declared-but-unwritten.
  9. Declared-is-enforced. WRONG, and the one FAIL item. Today's enforcement is exactly: the schema parses the key when a body carries it, refuses the shapes in item 2, and keeps absence absent. No process produces it: git grep draftChanges on the head outside packages/spec is 0 hits, and 0 on main. The changeset says so ("No server writes the key yet"); the TSDoc half-says so ("a server with no writer, or a draft row saved before one"); the schema-level .describe(), the text the reference page (protocol.mdx:1460, this diff) and the generated schema carry to SDKs and AI clients, does not. Five of its sentences state the writer's behaviour as present fact:
    • "A write is compared with the body it replaced (the previous draft, or the published body for the draft's first save)."
    • "Kept from the first recorded save until the draft is published or discarded, which remove the draft row and these entries with it."
    • "No two entries share a path, and a write replaces every entry at or under a path it changes, so the last write to change a key is the entry whose path is the longest prefix of that key's path; entries under a key's path are changes inside it."
    • "It records which writes changed what; it is not a list of every difference from the published body (compare the two bodies for that)."
    • "Present only on a ?state=draft read that answers a stored draft row."
      The closing hedge ("and for a draft row whose saves recorded none: absence means not recorded, never no changes") names a case, not the fact that today every draft row is that case. AGENTS.md Prime Directive chore: version packages #10 keeps the claim as narrow as the enforcement, and this file's own precedents say how: the label describe on the locale list "used to read 'Display name of the locale' while no producer ever wrote one: the declared-not-enforced shape ADR-0049 is about, one field wide" and was rewritten to state the convention it ships (protocol.zod.ts:3603); the save request's source member is left undeclared "until a producer on THIS contract pulls it" (:859). Declaring ahead of the writer is the triage's order and stands; stating it as shipped is not. The narrow form, one describe edit plus gen:docs: write the five sentences as the contract a writer must meet ("A writer compares each draft save with the body it replaced …", "keeps the entries from the first recorded save until …", "keeps no two entries on one path and replaces every entry at or under a path a write changes, so …", "It names which writes changed what …", "Served only on a ?state=draft read that answers a stored draft row"), and add once, in the same describe: "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." No card number in the describe (author-facing prose). The TSDoc, the changeset, the tests, the ablation and the other generated artefacts need no change; only protocol.mdx regenerates.
  10. The other docblock claims, each checked in the cited code: METADATA_READ_DECORATIONS is ['_diagnostics', '_draft'] (kernel/metadata-read-decorations.ts:48); ADR-0033's provenance item reads verbatim "attribute each AI draft to the acting user + mark it AI-sourced" (docs/adr/0033:125); HistoryMetaItemResponseSchema.events[].source is "an open label the server stamps" (protocol.zod.ts:1964, main's ba5bfe9 carried by the merge); objectui imports the schema in one code file (DraftConflictDialog.tsx:84, reading .shape.version at :138; the three other hits are docblock mentions). RIGHT.

② Semver level

The changeset .changeset/24298-draft-key-provenance.md declares "@objectstack/spec": minor with Clause-②: yes (widening); the PR body's line 3 is Clause-②: yes. The diff publishes one optional key on a response schema and its nested entry type, inline (no export added, removed or renamed; check:api-surface sits in the green Type Check · source gates); nothing removed, renamed or defaulted, no runtime behaviour. yes takes at least minor, and the arm is (widening): both match the diff. No ADR-0087 marker and no migration are owed. The downstream packages that import GetMetaItemResponse (client, client-react, service-automation, rest, runtime) have no source change and owe no changeset; the type widens through the dependency. The describe fix in ①.9 changes no shape, so the level and the arm survive it. RIGHT.

③ Boundary flags

The dev's report carries open_questions: []; its flags and departures are on the PR body.

  1. The W1 risk ("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 narrows the set before any writer ships"). Answered and escalated. ①.7 confirms no door exists today. Narrowing a published closed set is BREAKING by this repo's own rule ((narrowing) is BREAKING; a consumer typed against the union compiles against studio), whether or not any server ever served the value, so "narrow it in spec first" is not the cheap fallback the body reads it as. The seat carries onto W1's card at landing: the save-request surface input (same closed set, absent stamps api, the HTTP doors refuse copilot) is W1's first deliverable, and if W1 cannot produce studio the narrowing is filed as the breaking change it is. Not a defect of this PR: the set is the card's and the triage's.
  2. Departure: the key on the draft read rather than the _drafts row. Answered RIGHT in ①.4 (ruling A item 3; the reader measured at both refs).
  3. Departure: the entry not .strict(). Answered RIGHT in ①.6 (the ledger's api/ row).
  4. "No liveness ledger covers response keys … these cards are the enforcement record." True (①.8), and it is the reason ①.9 is a FAIL rather than a note: with no gate able to flag the key, the describe is the only control a consumer reads.
  5. at as z.string().datetime(): the writer's stamp must be the UTC Z form (toISOString() of the write's now); an offset form would be refused by the spec the writer ships against. For W1's card.
  6. Path examples differ across the artefacts (["kanban", "columns"] on the PR body, ["columns"] in the changeset and the describe): both are stored spellings (a kanban view's kanban.columns, a list view's config.columns); the writer computes paths from the stored body, so no change is owed.
  7. The dev's out-of-scope note (the history source describe) is withdrawn on main's ba5bfe9, which the merge carries. Nothing owed.
  8. The PR body's local readings (spec typecheck, 695 files, the ablation at e98129b, consumers, the 110-command gate union) were not re-run under this record's read-only shape; the head's check-runs are the gate verdicts, read three times above, none failed, seven still running at the last read.

Implemented-by: claude/issue-24298-draft-key-provenance
Reviewed-by: session_019oFY6Hi35J7atvgVFnmoaK

VERDICT: FAIL

One item: ①.9, the schema-level .describe() of draftChanges states a writer's behaviour that no server delivers; restate it as the contract a writer must meet, say once that no server writes the key yet, regenerate protocol.mdx, and the record is re-rendered on the new head. Everything else in the diff is right as judged above. Rendered 2026-10-18T18:43Z.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 (seat check) · PR #24383 · domain:spec seat 2 · zhuangjianguo · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-18T18:44Z

REWORK (patch round 1, same resume claim 6252719015, same dev) at head 438e3570f618dd2c4134aa5954d6cbf05429c627. The seat adopts contract review 6253694122 (FAIL, one item, ① 9). needs:contract-review stays on the PR: the fixed head is reviewed again.

  • ① 9 (the one item): the schema-level .describe() on draftChanges states the future writer's behaviour as present fact, in five sentences the review quotes. It is the text protocol.mdx and the generated schema carry to SDKs and AI clients. Fix, describe text only, plus gen:docs: write those five sentences as the contract a writer must meet ("A writer compares each draft save with the body it replaced …", and so on). Add once: "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." ⛔ No tracker number in the describe. The TSDoc, the changeset, the tests and the other artefacts stay as they are; only content/docs/references/api/protocol.mdx regenerates.
  • Carried to landing, not a debt of this PR (③ 1): studio has no door today. W1's first deliverable is the save-request surface input. If W1 cannot produce studio, narrowing the set is a BREAKING change, filed as one. The seat writes that onto W1's card when it files it.
  • Noted for W1 (③): at parses only the UTC Z form of ISO-8601; a toISOString() stamp satisfies it.
  • The seat's own ACCEPT (6253544200) named this sentence set as the review's call; the review confirms it is a FAIL.
  • After the push: the seat checks the new head, applies the dev's PR-body pairs if any, and runs the contract review again.

Generated by Claude Code

…, not as shipped

No server writes the key yet. The schema-level describe stated the future
writer's behaviour as present fact; it now states the contract a writer must
meet and says once that the key is absent until the engine's draft save
records it. The `at` describe names the one ISO-8601 form the parse admits.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 (seat check) · PR #24383 · patch round 1 · domain:spec seat 2 · zhuangjianguo · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-18T19:24Z

Head 9e98be435433158b531379b1bf2ef811fd2b7364: two commits over 438e3570f (e609b8d87 describe text; 9e98be435 the regenerated protocol.mdx), 2 files, +19/-13 (dev report 6254096863 on #24298). The seat read the diff.

  • ① 9: the draftChanges describe now says once "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 five writer sentences now read as "The contract a writer must meet: …". "Served only on a ?state=draft read …" stays a statement of where the key may appear. There is no tracker number in it.
  • Accepted deviation: the at describe now names the one ISO-8601 form the parse admits (UTC, Z; an offset or a local time is refused). This is the gap the review's ① 3 and ③ name. It is describe text, and it makes the claim narrower, never wider.
  • PR body: the seat applied the dev's 3 old_str/new_str pairs. Each matched the live body exactly once, and the result equals the dev's copy. Fixes #24298 is still the only closing declaration.
  • Next: the contract review runs again on this head. needs:contract-review stays until that record is adopted.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9e98be435433158b531379b1bf2ef811fd2b7364
Local-runs: none
Scope: packages/spec/src/api/protocol.zod.ts + packages/spec/src/api/meta-item-response-shapes.test.ts, .changeset/24298-draft-key-provenance.md, content/docs/references/api/protocol.mdx, packages/spec/authorable-surface/api.json, docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md; the files the hunks make a claim about, read on this head: packages/spec/src/kernel/metadata-read-decorations.ts, packages/spec/src/ui/view.zod.ts, packages/spec/src/security/permission.zod.ts, docs/adr/0033-ai-assisted-metadata-authoring.md, packages/metadata-protocol/src/protocol.ts and sys-metadata-repository.ts, packages/rest/src/meta-item-read-gate.ts and meta-item-write-routes.ts, packages/runtime/src/domains/meta.ts, packages/spec/scripts/lib/strictness-ledger.ts, .github/workflows/lint.yml and spec-liveness-check.yml; in objectui at origin/main 024b7b665 and at the pin 0707b4ae3712 (.objectui-sha on this head): packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx, packages/data-objectstack/src/metadata-client.ts, packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx; zod 4.6.5 as published (the ^4.6.5 pin in packages/spec/package.json, resolved to 4.6.5 in pnpm-lock.yaml): v4/core/regexes.js, v4/core/api.js, v4/core/schemas.js, v4/classic/schemas.js. The whole diff (6 files, +180/-1 against the merge base 6a49efb, unchanged this round) is inside this scope; nothing is left to the lane seat's ACCEPT at TIER_DEFAULT.

Round 2 of the review. Round 1 (6253694122, head 438e3570f) failed on one item, ① 9. This head adds two commits over it: e609b8d87 (describe text in protocol.zod.ts) and 9e98be435 (the regenerated protocol.mdx). The diff 438e3570f..9e98be435 is 2 files, +19/-13: the schema-level draftChanges describe, the at describe, and the two mdx rows those render to. No schema shape, test, changeset, authorable-surface line or ledger count moved, and the merge base is the same, so every round-1 judgment about code and shape is confirmed from the diff and re-checked against the cited code on this head below.

Inputs read: card #24298 (body and all twelve comments: triage 6249057462, claim 6249183877, the director's hold 6249880263, the release 6250059826, the pointer 6250719920, the unlock 6250871756, the resume claim 6252719015, the round-1 dev report 6253523211, the ACCEPT pointer 6253556843, the REWORK pointer 6253714465, the patch-round dev report 6254096863); PR #24383 body as refreshed this round, file list, net diff against the merge base and the round-1 record; the check-runs on the head. The seat's REWORK (6253704901) and patch-round check (6254121807) were read as seat records, not as evidence.

Check-runs on 9e98be435, two reads so far. At 2026-10-18T19:25:25Z: 46 runs, 27 success, 7 skipped, 12 in progress. At 2026-10-18T19:32:53Z: 32 success, 7 skipped, 7 in progress (Test Core 1/6 to 6/6, Type Check · workspace). Green at the last read: Lint & Repo Gates, every Lint lane (repo & tooling gates carries check:doc-authoring; docs & code guards; CI, release & engine; ESLint; PM dispatch-gates; the three scoped lanes), Type Check · source gates (carries check:authorable-surface and check:docs), Type Check · consumer gates (carries check:api-surface), Type Check · debt ledger, Build Core, Build Docs, Check Changeset (both runs: the second was raised by the body refresh), Check PR Size, Check Documentation Links, Flag docs affected by code changes, Governed Surface Queue Guard, Spec property liveness (carries check:strictness-ledger), Dogfood Regression Gate, filter, and the four PR-shape guards, each twice. Skipped (path-filtered or opt-in): Packed-tarball smoke, Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, the dogfood matrix shard, and the second Auto Label and Check PR Size. No failure at either read. The seven still running are the gate verdicts for the test and workspace-typecheck families; this record does not stand in for them. A third read is appended at the end of ③.

① Derived judgments

  1. The accept-set, unchanged from round 1 and re-read on this head. GetMetaItemResponseSchema gains draftChanges, optional, no default: an array of { path: string[] (each segment min 1), by: z.enum(['copilot','studio','api']), actor: string | null, at: z.string().datetime() }. The outer object is strip, as it was, so a body carrying the key parsed before this PR with the key dropped; now it is kept and typed. Absent stays absent (the test pins 'draftChanges' in parsed false). RIGHT.
  2. The refusals, unchanged: a surface outside the set at draftChanges.0.by (invalid_value), a dotted-string path at path (invalid_type), an empty segment at path.1 (too_small), a missing at (invalid_type), a non-ISO at (invalid_format); path: [] admitted. Each pinned with its located issue, plus the five type-level pins. The test file did not move this round. RIGHT.
  3. at is z.string().datetime(), and the describe now says what that admits. Read in zod 4.6.5 as published: classic ZodString.datetime(params) is this.check(core._isoDateTime(ZodISODateTime, params)) (v4/classic/schemas.js:322); _isoDateTime defaults offset: false, local: false, precision: null (v4/core/api.js:284); $ZodISODateTime takes regexes.datetime(def) (v4/core/schemas.js:367); and datetime() with those defaults builds ^DATE T hh:mm:ss(.fraction)? Z$: the suffix set is ["Z"] alone, an offset arm is added only under offset, the suffix-less time only under local, and seconds are required wherever Z or an offset is present (v4/core/regexes.js:98 to 106, timeSource at :81). No case-insensitive flag, so a lower-case z is refused too. So "an ISO-8601 instant in UTC, written with Z (the form toISOString() produces). An offset or a local time is refused." is exactly the parse: toISOString() yields YYYY-MM-DDTHH:mm:ss.sssZ, admitted; the test's 2026-10-18T11:02:38.000Z is that form. The parenthetical pins the extended calendar form, so the describe is no wider than the regex. RIGHT. The dev's extra edit narrows the claim to the enforcement; it is the gap round 1 ① 3 and ③ 5 named, and it is right to carry it in the describe rather than only on W1's card.
  4. The home, unchanged, re-confirmed at both objectui refs: readDraftForSave (DraftConflictDialog.tsx:162 at the pin and at main), getDraft (metadata-client.ts:1228) and isMetaItemEnvelope (:858) are where round 1 read them, listViewColumnSelection at ObjectListViewInspector.tsx:452, objectui's one code import of the schema at DraftConflictDialog.tsx:84 reading .shape.version at :138, and git grep draftChanges in objectui at main is 0 hits. On the server side the REST door spreads the protocol envelope (translateMetaEnvelope, meta-item-read-gate.ts:3179, ...envelope, ...sortability) and the dispatcher's draft read passes state: 'draft' (runtime/src/domains/meta.ts:793), so a writer serves the key with no transport change. ListDraftsResponseSchema changes only its docblock. RIGHT.
  5. The shape against the ruled artboard, unchanged: kanban is the view body's key (view.zod.ts:2866), KanbanConfigSchema.columns is z.array(z.string()) ("Fields to show on cards", :1973), the permission fields map is keyed entity_name.field_name (permission.zod.ts:956) so a path is an array, and an index-free array leaf matches Studio's positional column selection. The limit (attribution per array) is stated in the path describe. RIGHT.
  6. Not .strict() on the entry, unchanged: the ledger's api/ row classes the directory as wire; the counts shard moves 444 to 445, and the counter counts z.object / strictObject / z.strictObject / z.looseObject call sites (scripts/lib/strictness-ledger.ts:32 to :47), so the one new z.object( is the one new site. The gate is check:strictness-ledger in spec-liveness-check.yml:79, the green Spec property liveness run. RIGHT.
  7. The closed by set has no door today, unchanged: writeFace is package-duplicate | meta-envelope | meta-dispatch | external-import (protocol.zod.ts:948), stamped per transport (meta-item-write-routes.ts:350, meta.ts:1580); the history source is protocol.${operation}MetaItem (metadata-protocol/src/protocol.ts:19250). The values are the card's and the triage's. RIGHT as declared; the escalation stays in ③.
  8. No liveness ledger covers this key, unchanged; authorable-surface/api.json records api/GetMetaItemResponse:draftChanges as one line at its sorted place among the top-level keys, the way registration and sortability are recorded without their nested keys. The gate is check:authorable-surface in Type Check · source gates, green. RIGHT.
  9. Declared-is-enforced: FIXED. Today's enforcement is still exactly ① 1 and ① 2, and git grep draftChanges on this head outside packages/spec hits only the changeset and the generated page (0 hits on main). Sentence by sentence on the schema-level describe, which is the text the generated schema and the reference page carry: (a) "Per-key provenance of the pending draft: which authoring surface last changed each key path of item, under which actor, and when" defines the key's meaning. (b) "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" states today's fact plainly, once. (c) "The contract a writer must meet: it compares each draft save with the body that save replaced ...; 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 ..." frames all three writer rules as the contract, not as behaviour; the one present-tense clause inside it, that publish and discard remove the draft row, is true today (promoteDraft deletes the draft row after the put, sys-metadata-repository.ts:1326 and its docblock; the discard is the draft delete, :536, through discardDraftInItsScope, protocol.ts:27096). (d) "The entries name which writes changed what; they are not a list of every difference from the published body" is the meaning of an entry when one exists. (e) "Served only on a ?state=draft read that answers a stored draft row" is a restriction on where the key may ever appear, and with (b) beside it reads as such. (f) "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" is true everywhere today. The per-entry describes (path, by, actor, at) define what a value means when an entry exists and name no writer as shipped; actor cites the history event's actor, which exists (protocol.zod.ts:1962, nullable). The TSDoc states purpose ("what lets Studio's properties mark") and the four design reasons, and its absence bullet names "a server with no writer"; the one sentence that reads as a property of the design ("the provenance comes from the same stored row as the body") is the consequence of the placement choice it explains, in a rationale docblock whose [#24298] opener follows this file's convention (15 such docblocks on main). The changeset says "No server writes the key yet" in its own bullet. No tracker number is in any describe (checked by scan, and check:doc-authoring is green). RIGHT.
  10. The other docblock claims, re-read on this head: METADATA_READ_DECORATIONS is ['_diagnostics', '_draft'] (kernel/metadata-read-decorations.ts:48); ADR-0033's provenance item reads verbatim "attribute each AI draft to the acting user + mark it AI-sourced" (docs/adr/0033:125); provenance is the ADR-0010 envelope key (protocol.zod.ts:456). RIGHT.
  11. The regenerated page equals what gen:docs derives from the describes. Read two ways without a build: each of the six describe strings on this head (the key, the entry, path, by, actor, at) was joined from its source literals and compared byte for byte with its cell in protocol.mdx (the draftChanges row at :1460 and the four rows under the nested shape at :1487); all six are equal, the type column renders the entry the way the page renders every other array-of-object key and enum (39 [number] nested shapes and 191 enum cells on main), and the Required column marks the four entry members required, actor included (nullable, not optional). The gate that proves it is check:docs in Type Check · source gates, green on this head. RIGHT.

② Semver level

Unchanged from round 1 and re-read: .changeset/24298-draft-key-provenance.md declares "@objectstack/spec": minor with Clause-②: yes (widening); the PR body's line 3 is Clause-②: yes. The diff publishes one optional key on a response schema with its inline entry type: no export added, removed or renamed (check:api-surface in the green Type Check · consumer gates), nothing defaulted, no runtime behaviour. yes takes at least minor, and the arm is (widening): both match. No ADR-0087 marker and no migration are owed. This round changed describe text only, so the level and the arm survive it. The downstream importers of GetMetaItemResponse (client, client-react, service-automation, rest, runtime) have no source change and owe no changeset. RIGHT.

③ Boundary flags

The patch-round dev report (6254096863) carries open_questions: [] and three deviations; the round-1 flags are re-read.

  1. Deviation (1): the at describe narrowed beyond the five sentences the order named. Answered RIGHT in ① 3: it is describe text, it makes the claim narrower, and it is exact against zod 4.6.5. Nothing to revert.
  2. Deviation (2): no merge of main this round; the dev's derivation warned the tree is behind origin/main with two gate-source files changed there (scripts/check-single-authz-resolver.mjs, scripts/engine-double-contract.pinned.json). No merge is owed: after a PR is open, main is merged only on a reported conflict, a main-caused red or a queue removal, and none applies; the PR is mergeable and CI runs on the merge ref (the docs-drift check names it, 14ab696a2). RIGHT.
  3. Deviation (3): one push at the end instead of a WIP push before each minute-long step. A process note against the push rule; it changed nothing in the diff and the push landed. Noted, nothing owed.
  4. The PR body: the seat applied the dev's three pairs, and the body read this round carries them (the writer-contract block, the at row with the UTC Z form, and the Patch round 1 readings bullet). Fixes #24298 is still the only closing declaration. RIGHT.
  5. Carried from round 1 ③ 1, unchanged: studio has no door today (① 7). The save-request surface input is W1's first deliverable; if W1 cannot produce studio, narrowing the published closed set is BREAKING by this repo's rule and is filed as such. For W1's card at landing; not a defect of this PR.
  6. Carried from round 1 ③ 5, now closed in the text: the writer's at stamp must be the UTC Z form with seconds; the describe says so, and toISOString() of the write's now satisfies it. For W1's card as a pointer to the describe.
  7. The changeset's at clause reads "its ISO-8601 instant" without the Z form. The describe and the generated page carry the exact form, no consumer produces at, and the changeset's own next bullet carries the no-writer fact; a note, not a defect.
  8. Round 1 ③ 6 (path examples differ across artefacts) and ③ 7 (the history source describe, withdrawn on main's ba5bfe96e) are unchanged; nothing owed.
  9. The dev's local readings (the 22 tests, check:generated 14/14, check:doc-authoring, the full build, the 110-command gate union re-derived) were not re-run under this record's read-only shape; the head's check-runs are the gate verdicts, read as recorded above.
  10. Third check-runs read, at 2026-10-18T19:34:49Z: 32 success, 7 skipped, 7 in progress (Test Core 1/6 to 6/6, Type Check · workspace), no failure. Those seven remain the gate verdicts for their families; the landing waits for their green, not for this record.

Implemented-by: claude/issue-24298-draft-key-provenance
Reviewed-by: session_019oFY6Hi35J7atvgVFnmoaK

VERDICT: PASS

① 9 is fixed on this head: the schema-level draftChanges describe says once that no server writes the key yet and states the writer's three rules as the contract a writer must meet; the at describe names exactly the form z.string().datetime() admits in zod 4.6.5; the regenerated page equals the describes byte for byte and check:docs is green. Every other round-1 judgment holds on this head, confirmed from a diff that is describe text and a generated page. No FAIL item. Rendered 2026-10-18T19:35Z.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review adopted · domain:spec seat 2 · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-18T19:36Z

  • Adopted: contract review round 2, 6254235014 (Served-tier: CONTRACT_REVIEW_TIER, Head-sha 9e98be435433158b531379b1bf2ef811fd2b7364, the current head, PASS, no FAIL item). ① 9 (6253694122) is fixed. The describe says once that no server writes the key yet, and it states the writer's three rules as a contract. The one present-tense clause, that publish and discard remove the draft row, is true today. The at describe names the form z.string().datetime() admits in zod 4.6.5. The regenerated page equals the describes. needs:contract-review is removed in this act.
  • Carried to landing: the writer and reader cards W1 (domain:engine), W2 (cloud) and R1 (objectui), per triage 6249057462. W1 carries both review notes: its first deliverable is the save-request surface input, since no door produces studio today and narrowing the set later would be BREAKING; and at takes the UTC Z form.
  • Landing: once every check on this head is green: pr_ready then automerge_enable. The merge closes metadata-protocol / spec: a draft carries no per-field provenance (which surface changed which key, and when), so Studio cannot mark a copilot's draft change with the ruled 草稿 chip and 来源 line #24298.

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/m tests tooling

Projects

None yet

2 participants