Repository navigation
feat(spec): the draft read declares draftChanges, a pending draft's per-key provenance - #24383
Conversation
…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>
…aft-key-provenance
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>
…ath describe Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
席位核对 (seat check) · PR #24383 ·
|
| 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
Contract reviewServed-tier: 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
② Semver levelThe changeset ③ Boundary flagsThe dev's report carries
Implemented-by: VERDICT: FAIL One item: ①.9, the schema-level Generated by Claude Code |
席位核对 (seat check) · PR #24383 ·
|
…, 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>
…escribe Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude <noreply@anthropic.com>
席位核对 (seat check) · PR #24383 · patch round 1 ·
|
Contract reviewServed-tier: Round 2 of the review. Round 1 ( 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 ① Derived judgments
② Semver levelUnchanged from round 1 and re-read: ③ Boundary flagsThe patch-round dev report (6254096863) carries
Implemented-by: VERDICT: PASS ① 9 is fixed on this head: the schema-level Generated by Claude Code |
|
Contract review adopted ·
Generated by Claude Code |
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 ofGET /api/v1/meta/:type/:name) gains one optional key besideitem,draftChanges: one entry per changed key path of the pending draft body.pathstring[], each segment non-empty["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', closedcopilotis an AI assistant's tool writing on a person's behalf,studiois a person editing in Studio,apiis any other caller of the save door.actorstring | nullactor, the value its history event carries).nullwhen the write recorded none.atZformtoISOString()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:pathis the longest prefix of that key's path, and entries under a key's path are changes inside it.?state=draftread that answers a stored draft row. Absent on the active read, on the cached branch and on a?preview=draftread. 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:
actoris 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
main76f91f80dand at the console pin0707b4ae3712(.objectui-sha) unless a line says otherwise; the line numbers below aremain's, and the pin's differ only inStudioDesignSurface.tsx.Where: on the draft read, not on the
_draftsrow. The card allowed either, and the reader decides. Studio's properties load the item they edit throughreadDraftForSave(app-shell/src/views/metadata-admin/DraftConflictDialog.tsx:162, both refs), which isclient.getDraft. That isreadItemResponsewithstate=draft(data-objectstack/src/metadata-client.ts:1228and:1141, both refs), soGET /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 besideitemreaches the reader through the call it already makes. The_draftslist (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.Beside
item, never inside it. It follows the same rule asregistration: 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 notprovenance, because the ADR-0010 protection envelope already uses that key on this schema. objectui's envelope test matches by the presence oftype,nameanditem, never by key count (isMetaItemEnvelope,metadata-client.ts:858), so one more envelope key changes no unwrapping at the pin.A path is an array of keys. Keys contain dots:
PermissionSetSchema.fieldsis keyedOBJECT.FIELD(security/permission.zod.ts:956, "entity_name.field_name"), so a dotted string such asfields.account.name.readablehas two readings. An array has one.An array value is one leaf, and a path never carries an index. Studio's column selection is positional (
listViewColumnSelectionbuildsconfig.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 nameskanban.columnsas one key path. A writer computes this rule generically, with no per-type knowledge of which member identifies an array element.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 arekanban.columns(KanbanConfigSchema.columns, astring[],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 becomesstudio, 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 changingpath.byis a closed set of authoring surfaces, not a reuse of an existing field. Three candidates were measured, and none answers the question:_draftsrow'supdatedByis 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;source(HistoryMetaItemResponseSchema) is an open label of the code path that wrote the event (mainnow 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.
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).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
mainas76f91f80d). It reads the_draftscounts, and its seat ruling6251864146item 2 declined per-key data on_draftsrows. A?preview=draftread carries nodraftChanges, 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.jsonrecords only that the key exists). So these cards are the enforcement record. The seat files them when this lands.domain:engine, objectstack,packages/metadata-protocol(plus the request member's spec half)saveMetaItemwithmode: 'draft', down toSysMetadataRepository.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.actoris the save'sactor;atis the write's ownnow, the instantupdated_atgets. Promote and discard already delete the row. The strict draft read (getMetaItemwithstate: 'draft', from a stored draft row) serves the entries besideitem, on the REST door and on the dispatcher. The surface value: the door cannot tell Studio from any other HTTP caller today (writeFacenames the transport; the historysourceis a code-path label). So W1 declares one save-request input carrying the surface, onSaveMetaItemRequestSchemaand its REST spelling, with the same closed set, and moves the inline enum into one exported schema both sides reference. An absent input stampsapi. Recommended: the HTTP doors refusecopilot, so only the in-process copilot call can stamp it. Bulk paths to check: everyputthat writes a draft row, andmigrate-stored's rewrite of draft rows, whose conversions are not an authoring write and record no path.service-ai-studiostageDraft/applyDraftinpackages/service-ai-studio/src/tools/metadata-tools.ts, as objectui#12725's phase-5 report read them at cloudf9e45145) passcopiloton their in-processsaveMetaItemcall, through the same door, once cloud's framework pin carries W1.Blocked-by:W1.domain:ui, objectui,packages/app-shelldraftChangesoff the envelopereadDraftForSavealready 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, hasby: 'copilot'; the line's time comes fromat. 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 declarestudioon 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,
studiowould 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 throughscripts/pm/os-verify-lock.sh, and each line quotes that run's own summary line. CI's readings are this PR's check-runs.pnpm --filter @objectstack/spec typecheckgaveVERDICT command-exit 0(check:test-typecheck: OK;tsc -p tsconfig.test.json --listFilesOnlylists the new test file once).pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2gaveTest Files 695 passed (695)andTests 21238 passed | 1 todo (21239).packages/spec/src/api/meta-item-response-shapes.test.ts, the file at22 passed (22)): five runtime cases and five type-level pins.draftChanges.0.by(invalid_value). A dotted-string path is refused (invalid_type), and so is an empty segment (too_small). A missingatis refused (invalid_type), and so is a non-ISO one (invalid_format).byis the three-value union,pathisstring[],actorisstring | null, the key may be absent, and the_draftsrow carries nodraftChanges.pnpm --filter @objectstack/spec buildat438e3570f,check:generatedreads✓ All 14 generated artifacts are up to date.packages/client/srctypeddraftChanges: [{ …, by: 'ai', … }]asGetMetaItemResponse. Compiled withtsc --ignoreConfig --noEmit --strict, it exited 2 witherror TS2322: Type '"ai"' is not assignable to type '"api" | "copilot" | "studio"'. Withby: 'copilot'it exited 0. The file was then removed, andgit statuswas clean.e98129b65, throughscripts/ablation-replace.mjs. No build was needed, because the test imports the source.by: z.enum(['copilot', 'studio', 'api'])becameby: z.string()(anchor x1 → x0, blobfe32063f1f92→7dd4e62cf796). The result wasTests 1 failed | 21 passed (22), and the failing case wasrefuses a surface outside the closed set, at the entry's `by`. The tool then reportedok restored: blob == HEAD (fe32063f1f92) and `git diff HEAD` is empty. Frome98129b65to438e3570fthe only change is one docblock paragraph.git greprepo-wide for the packages that importGetMetaItemResponse/GetMetaItemResponseSchemaby 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).Scope: 3 of 85 workspace projects) were green.@objectstack/clientvitest:52 passed (52)files,671 passed (671)tests.@objectstack/restmeta-item-*route tests:52 passed (52).@objectstack/runtime'sdomains/meta-item-absent-404.test.ts:6 passed (6).438e3570f(VERDICT command-exit 0).0707b4ae3712and onmain76f91f80dimports the schema in one code file,DraftConflictDialog.tsx:84, and reads only.shape.versionthere (:138). Its envelope test is key-presence (isMetaItemEnvelope), so one more optional envelope key changes nothing there.turbo run build --filter=!@objectstack/docs --concurrency=2):Tasks: 76 successful, 76 total. The gates that read other packages'dist/need it.node scripts/pm/dispatch-gates.mjs --commands(no paths; the change set is 6 paths against merge base6a49efb9c) derived 110 commands at438e3570f. All 110 exited 0, each exit code captured before any pipe.--ranreconciliation:110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN(a DERIVED zero — all 110 recorded an exit code and none of them is 3).check:entry-nameabilityprintsNOT MEASURED: no callable export on @objectstack/spec/api-assembled, @objectstack/spec/qa. That covers two entries this diff does not touch; theapientry is measured.pnpm check:dev-prereqsexit 0 (@objectstack/spec … built from the sources on disk), andpnpm check:skill-frame-freshnessexit 0.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.6253694122, item ① 9), at head9e98be435. Only describe text changed: the schema-leveldraftChangesdescribe now states the writer as a contract and says once that no server writes the key yet, and theatdescribe names the one ISO-8601 form the parse admits.gen:docsmoved onlycontent/docs/references/api/protocol.mdx. Afterpnpm --filter @objectstack/spec build,check:generatedreads✓ All 14 generated artifacts are up to date.pnpm --filter @objectstack/spec typecheckpassed (check:test-typecheck: OK), andmeta-item-response-shapes.test.tsgave22 passed (22)(VERDICT command-exit 0).pnpm check:doc-authoringexited 0 (19054 customer-facing string(s) … no internal issue-id references). The full build gaveTasks: 76 successful, 76 total. Re-derived with no paths,dispatch-gates --commandsgave the same 110 commands, and all 110 exited 0.--ranread110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN. The derivation warned that the tree is behindorigin/mainand that two gate-source files changed there (scripts/check-single-authz-resolver.mjs,scripts/engine-double-contract.pinned.json). Both are data rows formain's own new files, and neither touches this diff's paths. CI runs both on the merge ref.Acceptance notes
_draftsrow (choice 1), and the entry is not.strict()(choice 8).82d1987ce) was kept through the hold on [Decision] The copilot's drafts: who sees a copilot change before publish, and where — preview-first (A), auto-publish additive edits (B), sticky author preview (C) or a dev environment (D); the frame #24298 waits on #24324. This run mergedorigin/main6a49efb9cthroughscripts/pm/os-regen-merge.sh(b5e73b9e1): the os-regen driver had kept the branch's side ofcontent/docs/references/api/protocol.mdx, and the regeneration (125f6cb10) carries bothmain's historysourcedescribe (ba5bfe96e) and this key's rows. This run then added one sentence to thepathdescribe and two docblock paragraphs (the array-element reading of choice 5, and the ADR-0033 item), and regenerated the reference page (e98129b65).check:generatedproved them stale:authorable-surface/api.json(the build writes one line,api/GetMetaItemResponse:draftChanges);content/docs/references/api/protocol.mdx(gen:docs);docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md(gen:strictness-ledger: theapi/site total 444 → 445).sourcedescribe is withdrawn:mainrewrote that describe inba5bfe96e(docs(spec): describe the history eventsourceas an open code-path label, not surface labels no writer stamps #24334), and the merged tree carries it.Generated by Claude Code