Skip to content

fix(metadata-protocol): one head row per /meta write address — a second unpinned package-less draft save is accepted (#22128) - #22185

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22128-draft-head-resolution
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22128-draft-head-resolution

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22128

Clause-②: yes (widening)

The fix restores ADR-0008's stated behaviour: an unpinned save is last-writer-wins. The packageId describe now states what the code already does.

@objectstack/metadata-protocol is minor, because SysMetadataRepository.headAt is one added public method on an exported class. It is the only change to the package's public surface. The precedent is .changeset/21986-metadata-protocol-declines-stored-row-public.md: one added method, yes (widening), minor. @objectstack/rest and @objectstack/spec stay patch.

The contract review (6053073603) found the earlier declaration of no wrong on this one point. Triage's no came before the fix had a shape. A widening is not a break, so no ADR-0087 disposition marker applies: check-adr-0087-registration reads 1 non-breaking changeset(s).

Direction (triage 6049925243, binding)

One function resolves "the draft head for this address". The save's expected-parent read, the repository's lock comparison and the read's served version all call it.

It resolves a package-less draft over a package-bound active row the way the repository already does. That inheritance is #11087's fix, pinned by sys-metadata-repository.draft-package-inherit.test.ts.

⛔ Not by dropping the inheritance: that re-opens #11087 (drafts lose their package).

The packageId describe, which says absent means env-local, is aligned in the same PR to state the inheritance.

The seat's rider 6051335917 adds two items: ?package=all on the item read, and the metadata_conflict spelling in the publish route's description. Patch round 1 adds a third member of the class, which the contract review escalated: the credential carry-forward. See the section below.

What was wrong (measured before the fix)

The new real-route test packages/rest/src/meta-draft-head-package-inheritance.test.ts was committed first (eb42ce01e). It ran against a dist/ built at the base 7ef50a4fb, which already carries PR #22126:

pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/meta-draft-head-package-inheritance.test.ts
  Tests  6 failed | 1 passed (7)
  step 3: {"error":"view/case_grid has been modified since you loaded it. Expected parent null but current is hmac-sha256:1de899d7…","code":"METADATA_CONFLICT",…}: expected 409 to be 200
  ?state=draft after step 2: expected null to be 'hmac-sha256:1de899d7…'
  ?package=all read: expected null to be 'hmac-sha256:6df7b221…'
  ?state=draft&package=all: {"error":"No pending draft exists for view/case_grid.","code":"NO_DRAFT"}: expected 404 to be 200

The one case that passed is the env-local control, which is meant to pass both before and after.

The three readers resolved three different rows:

reader before after
the save's expected parent (saveMetaItem, through storedHeadAt) repo.get at the key it was handed. With no package, that is the package-unbound row (package_id IS NULL). repo.headAt, which runs resolveWriteHead
the read's version (readVersionToken, through storedHeadAt) the same repo.get. The REST read door forwarded ?package=all verbatim, so the key was package_id = 'all'. the same repo.headAt. all is folded first, as the save door folds it.
the repository's lock (SysMetadataRepository.put) the active row's package, inherited for a draft that names no package (#11087), else the named key, with orphan-draft adoption as a fallback resolveWriteHead, the same code moved into one function

The change

  • The one resolution: SysMetadataRepository.resolveWriteHead (private, packages/metadata-protocol/src/sys-metadata-repository.ts). For a write of state that names packageId, it returns the row the write upserts (null for a create) and the binding a create stamps. It contains SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087's draft inheritance, which reads the active row across the caller's org and the env-wide scope, and the orphan-draft adoption, both unchanged. It lives in the repository because put's lock and stamp already live there, and the protocol already calls the repository. Calling it from the protocol adds no new layer dependency. Keeping it in the protocol would have made the repository call up into its caller.
    • put calls it inside its transaction, for the lock and the stamp. The inheritance lookup now runs inside that transaction too. It used to run just before it.
    • SysMetadataRepository.headAt(ref, { state, packageId }) is the new public method. It answers { head, packageId }. head is the row the write upserts, served as get serves a row, so its hash is the version lockAccepts accepts. packageId is the binding that write targets.
  • The callers rewired.
    • ObjectStackProtocolImplementation.storedHeadAt now asks repo.headAt, not repo.get. Its callers are unchanged:
      • saveMetaItem's expected-parent read, for both the draft and the publish mode. Unpinned, the parent is the head. Pinned, the token is judged against the head (storedParentForToken).
      • readVersionToken, the item read's version, for both ?state=draft and the uncached plain read.
    • storedBodyForCarryForward (patch round 1). Both of its reads now ask repo.headAt: the row the save overwrites, and with no draft yet the active row in the package the draft is stamped into.
    • REST: metaItemPackageBinding(raw) (packages/rest/src/rest-server.ts) is the one reading of ?package= on the three /meta/:type/:name doors. Before, PUT and POST …/publish each had an inline copy, and GET had none. all and the empty value name no package. GET's cache bypass (packageScoped) still reads the raw parameter, so ?package=all keeps the uncached arm and keeps serving version.
  • Spec, descriptions only.

One behaviour moves on GET /meta/:type/:name?package=all: the served content. Before, all was a package address, so the read preferred a row bound to a package literally named all, then the package-less row. Now the read serves what a read with no ?package= serves. That includes the draft the package-less draft save stored in the item's package, which before answered 404 NO_DRAFT. objectui's editor sends this read (read at objectui 9990f9e): ResourceEditPage.tsx scopes its draft and layered reads with the router's raw ?package=, which does not fold all (its own docblock says so), while its saves fold it.

The credential carry-forward (patch round 1)

The defect. A save of a type whose read withholds a credential carries the stored credential back into a body that returns as it was served. Today those types are datasource (built in) and flow (with the automation plugin). The carry-forward is #8154's write-path inverse. storedBodyForCarryForward compared the served body against repo.get at the key the caller named. For a package-less draft save of a package-owned item, neither the inherited draft nor the package-bound active row sits at that key. So the comparison fell to the code layer, nothing was carried, and the stored credential was persisted away.

Measured on the real route (the rest harness above, better-sqlite3), at head 07c229054, before this round's fix:

  • The setup is a package-owned legacy datasource row whose config.url holds a userinfo credential. It was written straight to the store, because the save door now refuses such a URL, so a row like this only exists from before the gates.
  • GET /meta/datasource/warehouse serves it redacted.
  • That body is saved back with PUT …?mode=draft and no ?package=, twice: 200, 200.
  • Each time, a system read of the stored draft row (bound to the package) shows config.url with no credential.
  • The env-local control kept the credential in both drafts.

meta-draft-head-package-inheritance.test.ts went 1 failed | 8 passed (9) on the pre-fix dist/. The failure is expected [ false ] to deeply equal [ true ], on whether the first stored draft holds the credential. With the fix and metadata-protocol rebuilt (grep -c 'target.packageId' dist/index.js → 1), the file is green. Neither the PR body nor any comment carries a credential value: the pins assert a boolean.

The fix is the same resolution (4e63f1122). storedBodyForCarryForward reads repo.headAt at the save's own state. With no draft yet, it reads repo.headAt(active, packageId) with the binding that call answered. The code-layer fallback and its #20552 reason are unchanged. An active save's carry-forward, and the authoring gate's restoredCredentialPathsFor, read the exact key as before, because headAt adds nothing for active.

Pins

packages/rest/src/meta-draft-head-package-inheritance.test.ts: the real RestServer routes, better-sqlite3 :memory:, the real sys_metadata* objects. Each pin reads the store back.

  • Triage's sequence: the measured sequence: active save in a package, a package-less draft, the same draft again with no If-Match → 200, 200, 200
  • Triage's read pin: ?state=draft after the package-less draft serves that draft's version (the save receipt's), not null
  • Triage's controls:
    • control: a stale If-Match is still refused 409, with the inherited draft's token as currentVersion. It also covers If-None-Match: * refused while the inherited draft exists.
    • control: an env-local item with no package is unchanged — unbound rows, unpinned saves accepted, the read serves the receipt's token
  • Rider pins (?package=all):
    • active: the read's version is the token the save compares against; If-None-Match: * is refused only because a row is there
    • active, no row at the env-local address: the read serves null, and If-None-Match: * creates it
    • draft of a package-owned item: ?state=draft&package=all serves the inherited draft and its token, and the save accepts it
  • Carry-forward pins (patch round 1), describe [#22128] the credential carry-forward reads the row the draft save overwrites:
    • package-owned: the redacted read saved back as a package-less draft, twice, keeps the stored credential
    • control: env-local, the same round trip keeps the stored credential, as before

packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts, new describe SysMetadataRepository.headAt — the row put locks against (#22128), 6 cases. They also pin the packageId binding headAt answers.

  • the inherited draft is the head, get at the named key is not, put refuses parent null and accepts the head's version;
  • the orphan draft is the head;
  • an explicit package never resolves another package's row;
  • an active address inherits nothing;
  • an org-scoped draft address resolves across the ADR-0005 reach;
  • a brand-new item drafted first has no head and the package-less binding.

The #11087 inheritance pins in that file are unchanged and green.

Tests and gates

Every reading below is at head ea3c748f8 (patch round 1). origin/main was not merged again, because the branch still merges cleanly. No file of this diff overlaps the 11 commits main has gained since 959c209d5. @objectstack/metadata-protocol was rebuilt before the real-route runs, because @objectstack/rest's tests resolve it through exports to dist/.

  • The real-route pins. meta-draft-head-package-inheritance.test.ts, meta-item-version-token-occ.test.ts and meta-publish-package-scope.test.ts → Test Files 3 passed (3) · Tests 42 passed (42), run at 4e63f1122, whose code ea3c748f8 carries unchanged. That covers this card's 9 pins plus meta(/meta door): the draft read serves no version token, so no client can pin its first draft save with If-Match; the 409 METADATA_CONFLICT body names the current version only in prose #22114's OCC pins and the PUT/publish ?package= parity pins.
    • Before round 0's fix, the first 7 pins were 6 failed | 1 passed, as quoted above.
    • Before round 1's fix, the carry-forward pin was 1 failed | 8 passed (9), as stated in its section.
  • @objectstack/metadata-protocol.
    • vitest run --maxWorkers=2 → Test Files 221 passed | 3 skipped (224) · Tests 28259 passed | 19 skipped. That includes protocol.metadata-redaction.test.ts and protocol.credential-channel.test.ts.
    • typecheck → exit 0.
  • @objectstack/rest.
    • vitest run --project local --maxWorkers=2 → Test Files 262 passed (262) · Tests 4938 passed | 326 skipped.
    • typecheck (tsc --noEmit && check:test-typecheck) → exit 0.
  • Downstream consumer, @objectstack/objectql. The 54 test files that name saveMetaItem / getMetaItem / SysMetadataRepository → 54 passed · 683 tests passed. protocol-meta.test.ts's findOne pins are among them.
  • @objectstack/spec (descriptions only, no source change since round 0).
    • Round 0's readings at 07c229054: the 4 test files that name SaveMetaItemRequestSchema or the REST route table → 4 passed, and typecheck → exit 0.
    • check:generated was re-run at this head and is green.
  • The changeset gates.
    • check-changeset-no-major was run with a pull_request event carrying this body. It answered LEVEL AXIS: this PR declares clause-② yes (widening), and it grades a package whose packages/**/src/** it moves at minor or above, exit 0.
    • check-adr-0087-registration → 1 non-breaking changeset(s), exit 0.
  • Gates.
    • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at ea3c748f8 derived 113 commands, the same set as round 0. Each was run with its exit code captured before any pipe. --ran gives: 113 derived famil(ies) accounted for — 111 run, 2 NOT-MEASURED.
    • NOT MEASURED (exit 3, prerequisite not met, both need a full-repo build, CI runs them):
      • pnpm --filter @objectstack/spec run check:skill-examples (no client-react dist);
      • pnpm check:dual-build-cjs-loads.
    • The derivation flagged the tree as 11 commits behind origin/main. Two gate-source files differ there (scripts/engine-double-contract.pinned.json, which adds no entry for this diff's files, and a removed audit probe).
    • The 48-family artifact-roster block was run too, including check:error-code-provenance, check:error-code-casing, check:engine-double-contract, check:spec-changes and check:meta-url-spelling. 45 exit 0. The three PR-context gates are check-partof-closing-keyword, check-closing-target-claim and check-single-claim-paths. They were run with PR_NUMBER=22185 and this body, and each exits 0.

Acceptance notes

Each item below is noted, not filed.

  • The same ?package=all reading on three more /meta doors (measured). On the real stack, with a view whose active row is stored in package com.probe.pkg:

    • GET /meta/view/case_grid/layers → 200, overlay live. GET /meta/view/case_grid/layers?package=all → 404.
    • GET /meta/view → ["case_grid"]. GET /meta/view?package=all → [].

    The layered read (rest-server.ts, layeredPackageId = req.query?.package || undefined), the list read and the book lookup each forward the literal all. objectui's editor (ResourceEditPage.tsx at objectui 9990f9e) scopes its layered and draft reads with the router's raw ?package=. These doors are outside this card's declared surface, so they are not changed here. The seat filed them as finding(rest): ?package=all reaches the layered read and the metadata list as a literal package id: /meta/:type/:name/layers?package=all answers 404 and GET /meta/:type?package=all answers [] for an item stored in a package #22188. metaItemPackageBinding is the fold they would share.

  • Lowercase metadata_conflict elsewhere. The spelling persists in packages/client/src/index.ts (four docblocks), packages/cli/src/commands/meta/delete.ts:115 (a description string), content/docs/concepts/metadata-lifecycle.mdx:147 and docs/qa/platform-checklist/areas/studio-authoring.json:374,376. The rider named only the route description. sys_metadata_audit's code: 'metadata_conflict' is the audit vocabulary's data value and is correct as it stands.

  • put's draft-inheritance lookup now runs inside its transaction, beside the existing-row read. It used to run just before the transaction. The predicates are unchanged.


Generated by Claude Code

claude added 6 commits October 8, 2026 04:06
…ss draft and ?package=all (#22128)

Red at the base: the second unpinned package-less draft save of a
package-owned item answers 409 METADATA_CONFLICT, and ?state=draft serves
version null while the draft exists.

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…or the save's parent, the repository's lock and the read's version (#22128)

SysMetadataRepository.headAt answers the row a put at the same address
upserts, through the same resolution put's lock runs (resolveWriteHead),
the #11087 draft package inheritance included. storedHeadAt asks it, so
the save door's expected parent and the item read's served version are the
row the lock judges. The /meta item read folds ?package=all through the
save door's reading (metaItemPackageBinding, shared by GET, PUT and
publish). SaveMetaItemRequestSchema.packageId states the inheritance; the
publish route's description spells the wire code METADATA_CONFLICT.

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…ibe; add the #22128 changeset

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

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/rest, @objectstack/spec, touching 22 documentable anchor(s).

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

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

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

Coarse fallback — 142 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 1e5d322c1e14803056c789c71d8ba87a538aa23c → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1e5d322c1e14803056c789c71d8ba87a538aa23c

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 07c229054766c1bb2aab197c764cc59227d168c2
Local-runs: none

Reviewed on the PR head as read from the API (it equals the head the brief named; the PR is a draft of 6 commits, 9 files, +546/−103, base 6ed0c0f3e). Inputs: card #22128 (body, triage 6049925243, rider 6051335917, claim 6051828637, dev report 6052880125, ACCEPT 6052924965), PR #22185 (body, file list, the one docs-drift comment; no review comments), the net compare diff against main, the check-runs on the head. Nothing built, run or re-run.

① Derived judgments

  1. PUT /api/v1/meta/:type/:name?mode=draft, no ?package=, item's active row package-bound, no If-Match. The second and later saves move from 409 METADATA_CONFLICT to 200, last-writer-wins on the one inherited draft row (protocol.ts storedHeadAt → repo.headAt → resolveWriteHead, the same resolution put's lock runs inside its transaction). Right. ADR-0008's declared accept set (unpinned = last-writer-wins) is restored; triage's direction is followed; SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087's inheritance and the orphan-draft adoption are the same predicates (same two-scope $or reach, same whereFor keys) moved into resolveWriteHead, not dropped. Pinned at the HTTP door (meta-draft-head-package-inheritance.test.ts, the 200/200/200 sequence, with the store read back) and at the repository (headAt describe, 5 cases, the SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087 pins untouched).
  2. Same door, pinned. An If-Match carrying the inherited draft's token was refused 409 for every token (judged against currentStored = null; storedParentForToken throws); it is now accepted when current and refused 409 with currentVersion = the inherited head when stale. If-None-Match: * while the inherited draft exists: 409 before and after. Right, and pinned (the stale-If-Match control).
  3. GET /api/v1/meta/:type/:name?state=draft, no ?package=, package-owned item. version moves from null to the inherited draft's keyed token (readVersionToken → the same storedHeadAt). Right: meta(/meta door): the draft read serves no version token, so no client can pin its first draft save with If-Match; the 409 METADATA_CONFLICT body names the current version only in prose #22114's GetMetaItemResponseSchema.version describe ("the stored row a save to this item compares against, at this read's scope") becomes true on this path; the token the read hands out is the token the save accepts. Pinned.
  4. The active address, and every address that names a package. headAt(ref, { state: 'active', packageId }) resolves whereFor(ref, 'active', packageId ?? null), the exact key, which is what get resolved when the packageId key was present ('packageId' in opts, sys-metadata-repository.ts:550). No inheritance for active, no adoption, and a named package never resolves another package's row. So the active save's parent, the plain read's version, and every ?package=PKG save and read are unchanged. Right, and pinned ("an active address inherits nothing", "an explicit package is its own address").
  5. GET /api/v1/meta/:type/:name?package=all (the item read now folds ?package= through metaItemPackageBinding, the function the PUT and POST …/publish doors share): (a) version is resolved at the address a ?package=all save writes (the unbound row) instead of at package_id = 'all'; (b) the content is the plain read's resolution instead of "a row bound to a package literally named all, then the package-less row"; (c) ?state=draft&package=all moves from 404 NO_DRAFT to 200 with the inherited draft. Right. all is the item doors' documented no-package sentinel (the PUT door has folded it since before this card; the describe now says so), the rider directed this exactly, and it is a correction of an existing value's reading at an existing door — no new parameter, value, field or route, and no accept-set widening (the door answered ?package=all before, with version: null). packageScoped still reads the raw parameter (rest-server.ts:6630, :6714), so ?package=all keeps the uncached arm and keeps serving version. The fold predicate is byte-identical to the two inline copies it replaces; the GET door refuses repeated package (:6480), so the non-string arm is unreachable there. The three rider pins read the store back.
  6. SysMetadataRepository.put. Lock, stamp, no-op short-circuit and history append unchanged; the inheritance findOne moved from just before the transaction to inside it, with ctx. Right (a consistency gain, no accept-set change).
  7. Public surface: SysMetadataRepository.headAt(ref, { state?, packageId? }) is a new public method on a class exported at packages/metadata-protocol/src/index.ts:153 and addressable through the package's exports["."] (dist/index.d.ts). As a mechanism it is right: storedHeadAt lives in another exported class, the write-address resolution must sit beside the lock it has to agree with, and the call direction stays protocol → repository. As a published surface it is an expansion of @objectstack/metadata-protocol's public surface, judged in ②.
  8. packages/spec, descriptions only. SaveMetaItemRequestSchema.packageId (text, no shape change): the REST fold (all and empty name no package), the draft inheritance, and that the same row is the parent-version resolution's and the read's version — each sentence holds against resolveWriteHead, metaItemPackageBinding and storedHeadAt/readVersionToken; it leaves out orphan-draft adoption, a repair of pre-SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087 rows and not a contract. Right. plugin-rest-api.zod.ts publish route description metadata_conflict → METADATA_CONFLICT: right (the wire code). GetMetaItemResponseSchema.version unchanged and still true: right. content/docs/references/api/protocol.mdx regenerated for the one describe: right; no hand-written page under content/docs restates the replaced sentence ("parent-version resolution", "env-local overlay (no package stamp)", "names a real package" hit only the generated reference).
  9. Check-runs on the head. First read 2026-10-08T05:22:28Z: 36 runs — 18 success, 5 skipped, 13 in progress, 0 failed. Second read 2026-10-08T05:29:14Z: 38 runs — 27 success, 5 skipped, 6 in progress (Test Core 1/6–5/6, Lint & Repo Gates), 0 failed; Build Core, Build Docs, Type Check · source gates, Type Check · debt ledger, Check Changeset, Governed Surface Queue Guard, Spec property liveness, both single-writer guards and the closing-card guard are success. Combined status: one context, success. Nothing red at either read; the seat reads the remaining six itself before landing.

② Semver level

The changeset .changeset/22128-draft-head-resolution.md declares @objectstack/metadata-protocol: patch, @objectstack/rest: patch, @objectstack/spec: patch, Clause-②: no (no arm); the PR body carries Clause-②: no.

  • @objectstack/rest patch — right. A behaviour fix at existing doors; metaItemPackageBinding is module-private; no route, parameter, value or field is added.
  • @objectstack/spec patch — right. Describe text only; no key, field, enum value or route added or removed; the api-surface baselines are per spec module and a describe does not move them.
  • @objectstack/metadata-protocol patch with Clause-②: no — wrong. The diff adds a public method, headAt, to SysMetadataRepository, exported from the package root and reachable through exports["."]. The Clause ② question (scripts/pm/clause2-line.mjs) is 「本卡放宽接受集或扩大公开面吗」; a new public member on an exported class of a released package expands its public surface, so the declaration is Clause-②: yes (widening) and the level is at least minor (AGENTS.md, Post-Task Checklist 3). The fleet's own precedent on this package and this shape: .changeset/21986-metadata-protocol-declines-stored-row-public.md — one method made public on ObjectStackProtocolImplementation, declared yes (widening) at minor, with "the only change to the public surface is this one added method". The sibling on this seam, .changeset/22114-meta-read-version-token.md, declares yes at minor for added surface. This changeset names the expansion itself ("headAt is the one new method, on the exported SysMetadataRepository") and declares no beside it. Triage's Clause-②: no was a prediction made before the fix had a shape; the diff is the fact. The gates that read the line (Check Changeset success; check-changeset-no-major, check-adr-0087-registration) read the declaration, not the surface, so they cannot see this.

The code is right; the declaration is what moves. Remedy, no code change: @objectstack/metadata-protocol: minor in the changeset, Clause-②: yes (widening) at a line start in the changeset body and in the PR body, rest and spec stay patch; then a fresh record on the new head.

③ Boundary flags

open_questions: none in the dev report (6052880125), so none to answer. Each flag in deviations, each out_of_scope_findings entry, and each item the seat's ACCEPT (6052924965) handed this review:

  • D1, origin/main 959c209d5 merged before deriving gates. The head IS that merge commit, and the check-runs are on it. Answered: no bearing.
  • D2, 12 rest files failed at load in the full run (a concurrent spec dist rebuild), re-run alone green. A local artefact; the head's Test Core shards are the verdict — 6/6 success, 1/6–5/6 in progress at the second read, none failed. Answered as read.
  • D3, a queued test command killed before it ran and re-queued. Process; no bearing.
  • D4, GET ?package=all now serves what a no-?package= read serves, flagged for Clause ②. Answered in ①.5: right, and not a widening on its own — an existing value's reading corrected to its documented meaning at an existing door. It is not what moves ②; headAt is.
  • D5, worktree cleanup after the report post. Process; no bearing.
  • OOS1, the layered read, the list door and the book lookup forward the literal all (measured 404 and []). Outside the claimed surface (rest-server.ts was claimed for the item read's fold only); the seat filed finding(rest): ?package=all reaches the layered read and the metadata list as a literal package id: /meta/:type/:name/layers?package=all answers 404 and GET /meta/:type?package=all answers [] for an item stored in a package #22188. Answered: correctly outside this PR.
  • OOS2, storedBodyForCarryForward reads repo.get at the named key (protocol.ts:8638–8646) — ESCALATED. It is a fourth member of this card's class: for a package-less draft save of a package-owned item of a redactor type (datasource built in, flow through the automation plugin), get(draft, null) and then get(active, null) both miss the package-bound rows, so the comparison body falls to the code layer and the save can persist a body with the redacted credential dropped — the [security] GET /api/v1/meta/datasources still serves stored cleartext credentials — the metadata read path has no per-type redaction hook #8154 data-loss class, the one the carry-forward exists to prevent. It pre-exists this PR on the FIRST such save (always 200), so it is not introduced here; this PR makes the second and later saves reachable with the same misread. The seat dispositioned it as "no carrier, outside the filing classes" without a measurement. A note with no carrier on a possible credential-deletion path is the quiet direction: it should be measured on the real route (a package-owned flow or datasource holding a stored credential, the redacted read, a package-less draft save, the stored body read back) and, if the credential drops, filed as class (a) with [security] GET /api/v1/meta/datasources still serves stored cleartext credentials — the metadata read path has no per-type redaction hook #8154 as its history. The fix is the same two reads asked of repo.headAt. Not a condition of this PR once ② is fixed — triage named three callers and this sits outside the declared surface — but not a note either.
  • OOS3, lowercase metadata_conflict in client docblocks, a CLI description, one docs page and the QA checklist. Docs and description spelling, no wire behaviour; sys_metadata_audit's data value metadata_conflict is correct as it stands. Answered: the acceptance note is the right disposition.
  • Seat: does Clause-②: no still hold now that the ?package=all content side is visible? For that side, yes (①.5). It fails on headAt (②).
  • Seat: the --tier Clause ② SUSPECT surface on protocol.zod.ts and plugin-rest-api.zod.ts. Descriptions only, no shape change; spec at patch is right.

Implemented-by: claude/issue-22128-draft-head-resolution
Reviewed-by: session_01RPo7FUd6bSnAfkWMAKi848

VERDICT: FAIL

One failing item, ②: the @objectstack/metadata-protocol level and the Clause-② declaration do not match the public surface the diff publishes. ① and ③ hold; the mechanism, the pins and the spec text are right.

Reviewed 2026-10-08T05:32Z, read-only: the card, the PR, the compare diff and the head's check-runs.

claude added 2 commits October 8, 2026 05:38
…e draft save overwrites (#22128)

storedBodyForCarryForward read the stored body at the key the caller
named. For a package-less draft save of a package-owned datasource that is
neither the inherited draft nor the package-bound active row, so the
comparison fell to the code layer and the stored credential was persisted
away (measured on the real route). Both of its reads now go through
SysMetadataRepository.headAt, which also answers the binding the write
targets, so the active fallback reads the row the draft overlays.

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…(widening) for the added headAt; the carry-forward bullet (#22128)

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ea3c748f8c42f6e5a0266b405a33430bc9a70477
Local-runs: none

Reviewed on the PR head as read from the API at 2026-10-08T06:28Z (it equals the head the brief named; the PR is a draft of 8 commits, 9 files, +664/−111, merge base 959c209d5, base main). Inputs: card #22128 (body, triage 6049925243, rider 6051335917, claim 6051828637, round-0 report 6052880125, ACCEPT 6052924965, the FAIL record 6053073603, patch round 6053088308, round-1 report 6053828685), PR #22185 (body as patched at 06:25:33Z, file list, the docs-drift comment and the FAIL record; no reviews, no review comments), the net compare diff against main and the round-1 delta against 07c229054, the check-runs on the head. Nothing built, run or re-run. This head is judged whole; the previous record covers only 07c229054.

① Derived judgments

  1. PUT /api/v1/meta/:type/:name?mode=draft, no ?package=, item's active row package-bound, no If-Match. Second and later saves move from 409 METADATA_CONFLICT to 200, last-writer-wins on the one inherited draft row: saveMetaItem → storedHeadAt → repo.headAt → resolveWriteHead, the same resolution put now runs inside its transaction for its lock and its stamp. Right. ADR-0008's declared accept set is restored; triage's direction is followed; SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087's inheritance (the two-scope $or reach) and the orphan-draft adoption are the same predicates moved into resolveWriteHead, not dropped. Pinned at the HTTP door (meta-draft-head-package-inheritance.test.ts, 200/200/200 with the store read back) and at the repository (headAt describe, the SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087 pins untouched and green).
  2. Same door, pinned. If-Match with the inherited draft's current version is accepted; stale is refused 409 with currentVersion = the inherited head; If-None-Match: * while the inherited draft exists is refused 409. Right, pinned (the stale-If-Match control, both halves).
  3. GET /api/v1/meta/:type/:name?state=draft, no ?package=, package-owned item. version moves from null to the inherited draft's keyed token (readVersionToken → the same storedHeadAt). Right: meta(/meta door): the draft read serves no version token, so no client can pin its first draft save with If-Match; the 409 METADATA_CONFLICT body names the current version only in prose #22114's GetMetaItemResponseSchema.version describe becomes true on this path and is unchanged. Pinned.
  4. Every active address and every address naming a package. resolveWriteHead(ref, 'active', p) and resolveWriteHead(ref, state, 'pkg') run no inheritance and no adoption; they resolve whereFor(ref, state, p ?? null), the exact key get resolved when its packageId key was present, with the same isSystem context. So the active save's parent, the plain read's version, the publish door's head and every ?package=PKG save and read are unchanged. Right, pinned ("an active address inherits nothing", "an explicit package is its own address").
  5. GET /api/v1/meta/:type/:name?package=all folds through metaItemPackageBinding (rest-server.ts:1788), the one function the PUT (:7195) and POST …/publish (:7978) doors now share in place of two byte-identical inline copies: version resolved at the address a ?package=all save writes; content as the plain read serves it; ?state=draft&package=all from 404 NO_DRAFT to 200. packageScoped (:6630, :6714) still reads the raw parameter, so all keeps the uncached arm and keeps serving version; the door refuses a repeated package (:6429). Right: an existing value read to its documented meaning at an existing door; no new parameter, value, field or route. Three rider pins read the store back. The layered, list and book doors still forward the literal — outside this surface, finding(rest): ?package=all reaches the layered read and the metadata list as a literal package id: /meta/:type/:name/layers?package=all answers 404 and GET /meta/:type?package=all answers [] for an item stored in a package #22188.
  6. SysMetadataRepository.put. Lock, no-op short-circuit, stamp (existingPkg ?? targetPackageId) and history append unchanged; the inheritance findOne moved from just before the transaction to inside it with ctx. Right. The inheritance's active-row lookup carries no package_id filter, as before (SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087's predicate; the publishPackageDrafts can promote (and drain) ANOTHER package's draft row — the promote resolves the draft without the ADR-0048 package dimension #8907 coin-toss caveat pre-exists and is not re-judged here).
  7. New this round: the credential carry-forward. storedBodyForCarryForward (protocol.ts:8640) asks repo.headAt at the save's own state, and with no row there and state === 'draft' asks repo.headAt(active, target.packageId), the binding the draft write targets; the #20552 code-layer fallback is unchanged. Walked against resolveWriteHead: (a) package-owned item, first package-less draft save — before, get(draft, null) then get(active, null) both missed the package-bound rows and the comparison fell to the code layer, so a redacted body was persisted with the stored credential dropped (the [security] GET /api/v1/meta/datasources still serves stored cleartext credentials — the metadata read path has no per-type redaction hook #8154 data-loss class); now the fallback reads the package-bound active row. (b) Second save — the inherited draft is the head. (c) Env-local item — target.packageId is null, both reads are the unbound rows, as before. (d) Explicit ?package= — no inheritance, the same keys as before. (e) Active (publish-mode) save and the authoring gate's restoredCredentialPathsFor (:8686, state: 'active' by [runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 D1) — exact key, as before, since headAt adds nothing for active. Right. Measured on the real route at 07c229054 (the credential drops on the first such save; the env-local control holds), pinned at the HTTP door with the stored row read back and an env-local control (1 failed | 8 passed on the pre-fix dist, green after). It pre-existed this PR on the first save; this PR's round-0 fix made the second save reachable with the same misread, and this round closes both. flow (automation plugin) takes the same type-agnostic function behind hasMetadataRedactor.
  8. New this round: headAt's return shape is { head, packageId } (sys-metadata-repository.ts:578), where head is the row the put upserts served as get serves it (null for a create) and packageId is targetPackageId, the binding put stamps on a create and, through existingPkg ?? targetPackageId, the one an adopted orphan ends under. headAt is new in this PR (absent at the merge base), so the shape change between PR heads is not a published-surface change and the widening stays one added method. Right, and the binding is pinned in the inherited, orphan, explicit-package, active and brand-new cases (6 repository cases).
  9. packages/spec, descriptions only. SaveMetaItemRequestSchema.packageId: the REST fold (all, empty), the draft inheritance, and that the same row is the parent-version resolution's and the read's version — each sentence holds against resolveWriteHead, metaItemPackageBinding and storedHeadAt/readVersionToken. Right. plugin-rest-api.zod.ts publish route description METADATA_CONFLICT: right (the wire code). content/docs/references/api/protocol.mdx regenerated for the one describe: right.
  10. Check-runs on the head. First read 2026-10-08T06:28:47Z: 42 runs — 38 success, 4 skipped, 0 in progress, 0 failed. Re-read 06:33:39Z: identical. The 4 skipped are Auto Label and Check PR Size in the second batch (the PR-edit event; both success in the first batch), Console Pin Gate (path-filtered) and Packed-tarball smoke (opt-in). The second batch (06:25:38Z–06:26:50Z) re-ran the PR-context guards and Check Changeset against the patched body: all success. Build Core, Test Core 1/6–6/6, Lint & Repo Gates, all four Type Check jobs, Temporal Conformance (live PG + MySQL), the three Dogfood Regression Gate shards, Spec property liveness, Governed Surface Queue Guard, both single-writer guards and the closing-card guard are success. The docs-drift comment says the CI tree is the merge of this head into base 1e5d322c1, so the gates measured a merge with a newer main than the dev's local derivation (which the dev flagged as 11 behind).

② Semver level

.changeset/22128-draft-head-resolution.md declares @objectstack/metadata-protocol: minor, @objectstack/rest: patch, @objectstack/spec: patch, with Clause-②: yes (widening) at a line start; the PR body carries Clause-②: yes (widening) at a line start (line 3).

  • @objectstack/metadata-protocol minor with yes (widening) — right. The diff adds one public method, headAt, to SysMetadataRepository, exported at packages/metadata-protocol/src/index.ts:153 and reachable through exports["."]; no existing signature changes (storedHeadAt, storedBodyForCarryForward, readVersionToken are private; resolveWriteHead is private). The precedent the changeset cites, .changeset/21986-metadata-protocol-declines-stored-row-public.md, is the same shape at the same level. A widening is non-breaking, so no ADR-0087 disposition marker applies. The MetadataRepository interface in packages/metadata-core/src/repository.ts does not declare headAt, so no second package's surface moves. Check Changeset on the head, in the run after the body edit (started 06:25:44Z, completed 06:26:50Z), is success.
  • @objectstack/rest patch — right. Behaviour fixes at existing doors; metaItemPackageBinding is module-private; no route, parameter, value or field added.
  • @objectstack/spec patch — right. Describe text only; no key, field, enum value or route added or removed.

The previous record's one failing item is resolved by this head.

③ Boundary flags

open_questions: none in the round-1 report (6053828685). The previous record's items, each deviations entry and each out_of_scope_findings entry:

  • Previous FAIL item (②). metadata-protocol at patch with Clause-②: no beside a new public method. Resolved: minor, yes (widening) in the changeset and the PR body, the precedent followed, gates green on the patched body (②).
  • Previous escalation (③ OOS2), storedBodyForCarryForward reading repo.get at the named key. The seat's patch round withdrew the ACCEPT's "no carrier" disposition and asked for a measurement first. Measured on the real route at 07c229054: the credential drops on the first package-less draft save of a package-owned datasource, the env-local control holds. Fixed in 4e63f1122 through the same resolution and pinned at the HTTP door with a control (①.7). Closed inside this PR rather than filed: protocol.ts is in the claim's declared surface, the defect is this card's class, and the seat directed exactly this. Resolved.
  • D1, headAt's return shape changed to { head, packageId } this round. Answered in ①.8: right, unreleased, one added method, the binding pinned.
  • D2, the stored credential could not be created through the save door (422 INVALID_METADATA on config.url), so the probe and the committed pin seed the row directly. Right: the carry-forward exists for rows at rest from before the write gates, which is the only population that holds such a value, and the seed stamps checksum as put stamps a row so the lock judges it as any row. The pin's value is a labelled placeholder and the assertion is a boolean; no credential value appears in the PR body, the changeset, either dev report or the test file. Answered.
  • D3, measured on datasource only; flow not probed. storedBodyForCarryForward is one type-agnostic function behind hasMetadataRedactor, so the fix reaches flow by construction. A credential the write-only channel (security(flows): move a flow's inbound-hook secret out of flow metadata into the write-only secret seam #7799 established — no read, the generic data door included, returns it #20790) holds is withheld from the stored body too, so the carry-forward restores nothing there and the channel keeps it across the save; a pre-channel legacy flow row takes the same two reads as the datasource pin. Answered; not a condition.
  • D4, origin/main not merged this round; the tree flagged 11 behind. The head's check-runs ran on the merge of this head into base 1e5d322c1 (①.10), so CI measured against a newer main. The two differing gate-source files (scripts/engine-double-contract.pinned.json, a removed audit probe) touch no file of this diff. Answered; the seat merges main through the regen script before landing if it chooses.
  • D5, the temporary probe file. The net diff and the file list carry no such file. Answered.
  • D6, worktree cleanup after the post. Process; no bearing.
  • OOS-A, ?package=all on the layered read, the list read and the book lookup. Carrier finding(rest): ?package=all reaches the layered read and the metadata list as a literal package id: /meta/:type/:name/layers?package=all answers 404 and GET /meta/:type?package=all answers [] for an item stored in a package #22188 (seat-filed). Answered: correctly outside this PR.
  • OOS-B, lowercase metadata_conflict in client docblocks, a CLI description, one docs page and the QA checklist. Docs and description spelling, no wire behaviour; sys_metadata_audit's data value is correct as it stands. Acceptance note is the right disposition. Answered.
  • Seat's round-0 questions. Does Clause-②: no hold once the ?package=all content side is visible: it does not hold for headAt, and the declaration is now yes (widening) (②); the ?package=all reading itself is not what moves it (①.5). The --tier SUSPECT surface on the two packages/spec/src/api files: descriptions only, patch right.
  • ESCALATED to the seat (pre-existing, not a condition of this PR): the carry-forward's active fallback at an org-scoped save over an env-wide active row. resolveWriteHead's inheritance reads the active row across the org and env-wide scopes ($or), but whereFor keys every row read, the fallback headAt(active, target.packageId) included, to this.organizationId. For an org-scoped caller whose package-owned item's active row lives env-wide (organization_id IS NULL, the ADR-0005 case the two-scope reach exists for), the inheritance answers the package, the draft address is empty, and the fallback misses the env-wide active row, so the comparison falls to the code layer — the same shape as OOS2 on the organization axis instead of the package axis. The pre-PR get(active, packageId) had the same partition, so this is not introduced here, and it sits outside this card's class (package binding). Read in source only, not measured; whether an org-scoped caller reaches a redactor type's draft save on the real route (datasource is a platform setting; flow may be org-overlaid) is the first thing to measure. Direction: measure on the real route; if the credential drops, file as class (a) with [security] GET /api/v1/meta/datasources still serves stored cleartext credentials — the metadata read path has no per-type redaction hook #8154 and this card as history; the fix is a read of the active row at the ADR-0005 reach the inheritance already uses.

Implemented-by: claude/issue-22128-draft-head-resolution
Reviewed-by: session_01RPo7FUd6bSnAfkWMAKi848

VERDICT: PASS

The previous record's failing item is resolved and its escalation is measured, fixed and pinned on this head. ①, ② and ③ hold. One new item is escalated for measurement, pre-existing and outside this PR's class.

Reviewed 2026-10-08T06:35Z, read-only: the card, the PR, the compare diff and the head's check-runs (read 06:28:47Z and 06:33:39Z).

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants