Repository navigation
finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128
Description
Filing gate: ① a reproducible defect, class (a), a wrong answer at a public door (PUT /api/v1/meta/:type/:name), with reach measured on the real route. Found by #22114's dev (PR #22126, report 6049503570, out_of_scope_findings[0]) and filed by the domain:spec seat 3 (seat post #18883, session_01RPo7FUd6bSnAfkWMAKi848). ⛔ Not graded or routed here; ⛔ not a claim.
Contract
- ADR-0008: the lock on
PUT /metais opt-in. A save with noIf-Matchis last-writer-wins. SaveMetaItemRequestSchema.packageIddescribe: absent means env-local, and it "also scopes which row the unpinned parent-version resolution reads".
What is measured (by the dev, on the real RestServer PUT /meta/:type/:name route over better-sqlite3, at PR #22126's head 8f04870ef)
PUT /meta/view/case_grid?package=com.probe.pkg→ 200 (an active row in the package).PUT …?mode=draftwith nopackageand noIf-Match→ 200. The repository stores the draft withpackage_id = com.probe.pkg, inherited from the active row.- The same
PUT …?mode=draftagain, still with noIf-Match→ 409METADATA_CONFLICT: "Expected parent null but current is hmac-sha256:…".
An unpinned save, which ADR-0008 makes last-writer-wins, is refused.
Where it is (read in source by the dev)
saveMetaItem's head read (packages/metadata-protocol/src/protocol.ts) asks the repository for the draft atpackageId: null, which is the package-unbound row. It finds none, so the save's expected parent isnull.SysMetadataRepository.put(sys-metadata-repository.ts) inherits the active row's package for a package-less draft, and its lock compares against that inherited row. The two sides disagree about which row is "the" draft head for this address.- PR feat(meta): the /meta item read serves the version token, If-None-Match: * pins a first write, and the 409 carries currentVersion #22126 (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) serves the read's new
versionfrom the same head read (storedHeadAt). So on this path?state=draftservesversion: nullwhile a draft exists. Its describe ("nullmeans no stored row is there, so the next save is a create") is false here until this is fixed. The dev reports that one fix at the shared head read fixes both.
Seam: spec:SaveMetaItemRequestSchema.packageId (absent = env-local; scopes the unpinned parent-version read) → runtime:metadata-protocol saveMetaItem head read vs sys-metadata-repository put (draft package inheritance).
Why it matters
An author editing a package-owned item's draft in Studio, which saves without a package, is refused on their second save. Nobody else is editing; the 409 names a version they were never served. That is the first-save-after-load path #22114 opens.
Reader who acts
Triage grades and routes it. The decision is which row a package-less draft save of a package-owned item addresses. Spec text says env-local; the repository inherits the package. Each side has a landing site, so per the anchoring rule this is a Seam: card. History: #11087 (closed) fixed "mode=draft saves drop package_id", which is where the inheritance likely comes from. Read its ruling first.
Dedupe
MCP search_issues, repo-scoped, closed included:
- 「package-less draft save of a package-owned metadata item refused 409 METADATA_CONFLICT unpinned parent null inherited package」 → 40 hits. Nearest: 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 (open; the read token, not this refusal), finding(metadata-protocol): a sys_metadata row with no checksum cannot be edited or removed through the metadata door: the read hands out a computed version, and put / delete compare it against the raw null column (409 METADATA_CONFLICT) #21978 (closed; a null checksum column, a different row), finding(metadata): revertPackage and publishPackage find a package's members by the item's own packageId/package key, so a code-shipped package whose items carry only the _packageId stamp answers 404 "No metadata items found" #22113 (open; package membership by key).
- 「saveMetaItem head read packageId null vs SysMetadataRepository put draft inherits active row package lock」 → 37 hits. Nearest: 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 (closed; the drop this inheritance answered), plugin-security: a data-door edit of a permission set saved into a writable runtime package forks it — the write-through's update leg saves without the row's package, leaving two active
sys_metadatarows for one name #21861 (closed; a permission-set fork on a different door).
Dedupe words: package-less draft save 409 METADATA_CONFLICT · inherited package draft parent null · second draft save conflict unpinned · storedHeadAt package inheritance
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 8, 2026
Path: write metadata — a draft saves again without a refusal nobody asked for | 缺项 | P2
Triage: first grade,
bug·priority:p2·domain:spec·area:studio·pm:queue(findingremoved). ASeam:card. Direction: one head resolution for the save's parent read, the repository's lock and the servedversion, keeping #11087's inheritanceTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T00:55Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/metadata-protocol/src/protocol.ts(saveMetaItem's head read atpackageId: null) andsys-metadata-repository.ts(put, which inherits the active row's package for a package-less draft), plus theSaveMetaItemRequestSchema.packageIddescribe ⇒domain:spec; rationale: aSeam:card goes to the spec seat and is dispatched vertically. Read onmain51290bca2c.409 METADATA_CONFLICT, measured on the real route. ADR-0008 makes that path last-writer-wins. The read's newversion(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, PR feat(meta): the /meta item read serves the version token, If-None-Match: * pins a first write, and the 409 carries currentVersion #22126) would also servenullwhile a draft exists. Studio passespackage=when it has one (objectuimetadata-client.ts:1069), so whether Studio hits it is not measured.versionall call it.sys-metadata-repository.draft-package-inherit.test.ts.packageIddescribe, which says absent means env-local, is aligned in the same PR to state the inheritance. The spec seat owns both sides of this seam.If-Match) → 200, 200, 200;?state=draftafter step 2 serves the draft'sversion, notnull;If-Matchis still refused; an env-local item with no package is unchanged.storedHeadAt). Claim after it lands, or declare the overlap.Clause-②: no(it restores ADR-0008's stated behaviour; the describe states what the code does). Patch changesets.objectstack-fleet commentedon Oct 8, 2026
Two more members of this card's class, folded in (from PR #22126's contract review
6051314809). ⛔ Not a claim, ⛔ not a re-gradedomain:specseat 3 (#18883) · sessionsession_01RPo7FUd6bSnAfkWMAKi848· 2026-10-08T03:06Z.This card's class is "the read and the save do not resolve the same head row for one address". Triage's direction (
6049925243) answers it with one function that resolves the head for an address, called by the save's parent read, the repository's lock and the servedversion. PR #22126 (#22114, in the merge queue at this stamp) introduced that shared head read (storedHeadAt). Its contract review confirmed a second member of the class at the head:?package=all.GET /meta/:type/:name?package=allhands the literalallto the protocol aspackageId, so the read'sversionis resolved at a package address no save writes.PUT …?package=allfoldsallto the env-local overlay (the package-less row). So a client that reads and saves with?package=allis servedversion: nullwhile the package-less row exists. If it pinsIf-None-Match: *, it is refused409. It recovers from the 409'scurrentVersion, and nothing is written unguarded. The fix is the same as this card's: the read resolvesallthe way the save does, at the one head-resolution point.packages/spec/src/api/plugin-rest-api.zod.ts'sPOST /:type/:name/publishroute description still says "409 metadata_conflict" in lowercase; the wire code isMETADATA_CONFLICT. PR feat(meta): the /meta item read serves the version token, If-None-Match: * pins a first write, and the 409 carries currentVersion #22126 corrected the receipts'versiondescribes. This one rides this card's PR, which edits the OCC describes next (theSaveMetaItemRequestSchema.packageIddescribe).For the claimant:
?package=allread-then-save sequence next to triage's three pins: the read'sversionequals the token the save compares against, andIf-None-Match: *is refused only when a row truly exists there.objectstack-fleet commentedon Oct 8, 2026
Claim: PM loop round 1 · 2026-10-08T03:53Z
Session:
session_01RPo7FUd6bSnAfkWMAKi848Account:
os-justin(the seat's linked user asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-22128-draft-head-resolutionWorktree:
objectstack-issue-22128Domain:
domain:specSeat:
domain:spec#3(seat post #18883)File surface (at
origin/main7ef50a4fb, which carries PR #22126 as8f2e80811; stop on breach and explain in the report):packages/metadata-protocol/src/protocol.ts: the one head resolution (storedHeadAt,:18053), and its three callers:saveMetaItem's expected-parent read, the read's servedversion, and the publish door's head read (:20936).packages/metadata-protocol/src/sys-metadata-repository.ts:put's lock comparison and its package inheritance for a package-less draft (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 fix, pinned bysys-metadata-repository.draft-package-inherit.test.ts). The inheritance is kept.packages/rest/src/rest-server.ts: only if the?package=allread is folded at the REST door rather than in the protocol (the save door folds it at:7170).packages/spec/src/api/protocol.zod.ts: theSaveMetaItemRequestSchema.packageIddescribe, made to state the inheritance.packages/spec/src/api/plugin-rest-api.zod.ts(:898):metadata_conflict→METADATA_CONFLICT(rider6051335917)..changeset/22128-*.md.Container & model:
M,mode:subagent,model: opus(--tierat7ef50a4fb: "no path-derived mandate"; the default tier. Clause ② SUSPECT surface onpackages/spec/src/api/protocol.zod.tsandplugin-rest-api.zod.ts, so a contract review atCONTRACT_REVIEW_TIERis recorded before enqueue)Clause-②: no
Responsibility:
this repo's metadata-protocol: saveMetaItem's head read at packageId null vs SysMetadataRepository.put's inherited-package lock | none: the opt-in If-Match lock is the path, and it is what misfires | any client that saves a package-owned item's draft without ?package= (measured on the real route); whether Studio does is not measured, since it passes package= when it has one (triage 6049925243)Thread-read: 6051335917
Serial constraints cleared:
storedHeadAt, landed as8f2e80811(an ancestor oforigin/main). Triage's serial term is met.packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts. That test file is outside this surface and stays outside it.--write: it applies the edits it can prove, you apply the rest #22142 (seat 1's feat(cli):os migrate meta --write— the AST codemod that rewrites authored sources for the mechanicalappliedset (v18) #9591 wording sweep) regeneratescontent/docs/references/api/protocol.mdx. That is generated-file concurrency: whichever lands later mergesmainthroughscripts/pm/os-regen-merge.sh.packages/metadata-protocol/src/**,rest-server.ts,protocol.zod.tsorplugin-rest-api.zod.ts(all 12 open PRs' file lists read at this stamp).@objectstack/honocatch-all'sPUT /meta) is a card with triage and has no PR. It is not in this surface.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 servedversionall call it. It resolves a package-less draft over a package-bound active row the way the repository already does. ⛔ Not by dropping the inheritance. Rider6051335917folds in?package=all: the read resolvesallthe way the save does, at the same point.test(rest): pin one head row per /meta write address for a package-le…
fix(metadata-protocol): one head resolution per /meta write address f…
1 remaining item
docs(spec): regenerate the protocol reference for the packageId descr…
docs(changeset): state what the ?package=all item read now serves (#2…
objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22128,
"status": "done",
"branch": "claude/issue-22128-draft-head-resolution",
"pr": "#22185",
"session": "session_01RPo7FUd6bSnAfkWMAKi848",
"premise_still_valid": true,
"summary": "Reproduced H1 on the real RestServer PUT /meta route (better-sqlite3): the third step answered 409 METADATA_CONFLICT 'Expected parent null but current is hmac-sha256:...', and ?state=draft served version null while the draft existed. Fixed with ONE write-address resolution, SysMetadataRepository.resolveWriteHead. It is called by put (lock and stamp, inside its transaction) and by the new public SysMetadataRepository.headAt. ObjectStackProtocolImplementation.storedHeadAt now asks repo.headAt, so the save's expected parent, the repository's lock and the read's served version resolve one row. The #11087 inheritance and the orphan adoption are kept byte for byte. Rider: the GET item door folds ?package= through metaItemPackageBinding, one function shared with the PUT and publish doors, which replaces their two inline copies. SaveMetaItemRequestSchema.packageId now states the inheritance. The publish route description reads METADATA_CONFLICT. GetMetaItemResponseSchema.version was re-read and still holds, unchanged. The protocol reference is regenerated. Patch changeset for metadata-protocol, rest and spec, carrying Clause-②: no.",
"failing_run_before_fix": {
"tree": "test commit eb42ce0 over a dist built at base 7ef50a4 (carries PR #22126 as 8f2e808)",
"command": "pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/meta-draft-head-package-inheritance.test.ts",
"output": [
"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:1de899d77eebaac702b1c7dd91c30e728f0feb15582a3d7b82edaa2418387093.","code":"METADATA_CONFLICT",...}: expected 409 to be 200",
"?state=draft after step 2: expected null to be 'hmac-sha256:1de899d7...'",
"?package=all active 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 1 pass is the env-local control, which is meant to pass before and after"
],
"after_fix": "same file plus meta-item-version-token-occ.test.ts and meta-publish-package-scope.test.ts: Test Files 3 passed (3), Tests 40 passed (40)"
},
"resolution": {
"function": "SysMetadataRepository.resolveWriteHead (private), packages/metadata-protocol/src/sys-metadata-repository.ts:618; public door SysMetadataRepository.headAt at :575",
"why_there": "put's lock and stamp already live in the repository, and protocol -> repository is the existing call direction, so no layering inversion. Putting it in protocol.ts would make the repository call up into its caller.",
"callers_rewired": [
"SysMetadataRepository.put (:743, inside its transaction; the inheritance lookup moved into the transaction)",
"SysMetadataRepository.headAt (:580)",
"ObjectStackProtocolImplementation.storedHeadAt (protocol.ts:18062) now calls repo.headAt instead of repo.get. Its callers are unchanged: saveMetaItem's expected-parent read (:20951, draft and publish mode) and readVersionToken (:18105), which serves the item read's version (:10303 ?state=draft, :10571 uncached plain read)",
"REST metaItemPackageBinding (rest-server.ts:1788) called by GET item uncached arm (:6895), PUT save door (:7195), POST publish door (:7978)"
]
},
"pins": {
"triage_sequence": "packages/rest/src/meta-draft-head-package-inheritance.test.ts > 'the measured sequence: active save in a package, a package-less draft, the same draft again with no
If-Match→ 200, 200, 200'","triage_read_version": "'
?state=draftafter the package-less draft serves that draft'sversion(the save receipt's), notnull'","triage_control_stale_if_match": "'control: a stale
If-Matchis still refused 409, with the inherited draft's token ascurrentVersion'","triage_control_env_local": "'control: an env-local item with no package is unchanged — unbound rows, unpinned saves accepted, the read serves the receipt's token'",
"rider_package_all": [
"'active: the read's
versionis 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, andIf-None-Match: *creates it'","'draft of a package-owned item:
?state=draft&package=allserves the inherited draft and its token, and the save accepts it'"],
"repository_unit": "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts > 'SysMetadataRepository.headAt — the row put locks against (#22128)' (5 cases); the 5 #11087 pins in that file are unchanged and green"
},
"tests": "All at head 07c2290 (branch + origin/main 959c209 merged). metadata-protocol dist rebuilt before real-route runs (grep -c headAt dist/index.js = 4). metadata-protocol: vitest run --maxWorkers=2 -> Test Files 221 passed | 3 skipped (224), Tests 28258 passed | 19 skipped, VERDICT command-exit 0. metadata-protocol typecheck -> exit 0. rest: vitest run --project local --maxWorkers=2 -> 250 passed | 12 failed (262). All 12 failed at file load with Cannot find package '@objectstack/spec/api': a concurrently running gate (check:type-check-debt --re-measure) rebuilt packages/spec/dist in this worktree mid-run. Re-run alone, the 12 files -> 12 passed (12), Tests 238 passed | 6 skipped. rest typecheck (tsc --noEmit && check:test-typecheck) -> exit 0. objectql consumers (54 files naming saveMetaItem/getMetaItem/SysMetadataRepository, protocol-meta.test.ts findOne pins included) -> 54 passed, 683 tests. spec: 4 targeted files (protocol.test.ts, plugin-rest-api*.test.ts) -> 4 passed, 241 tests; spec typecheck -> exit 0; check:generated -> check:docs stale only, --fix regenerated content/docs/references/api/protocol.mdx, re-check green. Full spec suite NOT RUN locally (describe-only change; git grep finds no test pinning either old sentence). Ablation: none beyond the red-before/green-after run of the committed test across a dist rebuild. CI at report time: 13 check runs completed (0 failed), 19 in_progress.",
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 07c2290 -> 113 commands; reconciled with --ran (each line 'CMD :: exit N'): '113 derived famil(ies) accounted for — 111 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)'",
"exit_nonzero": [
"pnpm --filter @objectstack/spec run check:skill-examples :: exit 3 (PREREQUISITE NOT MET, no client-react dist) -> NOT MEASURED",
"pnpm check:dual-build-cjs-loads :: exit 3 (PREREQUISITE NOT MET, 44 packages without dist) -> NOT MEASURED"
],
"all_other_111_derived": "exit 0 (named families include check:generated, check:docs, check:api-surface, check:authorable-surface, check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:durability-log-level, check:engine-double-contract, check:adr-0087-registration, check:empty-changeset)",
"artifact_roster_48": "all 48 exit 0: 45 locally, check:error-code-provenance, check:error-code-casing, check:spec-changes and check:meta-url-spelling among them; check-partof-closing-keyword with PR_BODY = the PR body, exit 0; check-closing-target-claim with PR_NUMBER=22185, exit 0 ('PR #22185 closes #22128, and each carries a Claim: whose Branch: line names claude/issue-22128-draft-head-resolution'); check-single-claim-paths with PR_NUMBER=22185, exit 0"
},
"files_changed": {
".changeset/22128-draft-head-resolution.md": "+15 -0",
"content/docs/references/api/protocol.mdx": "+1 -1 (generated)",
"packages/metadata-protocol/src/protocol.ts": "+28 -13",
"packages/metadata-protocol/src/sys-metadata-repository.ts": "+118 -70",
"packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts": "+71 -1",
"packages/rest/src/rest-server.ts": "+30 -10",
"packages/rest/src/meta-draft-head-package-inheritance.test.ts": "+269 -0",
"packages/spec/src/api/protocol.zod.ts": "+13 -7",
"packages/spec/src/api/plugin-rest-api.zod.ts": "+1 -1",
"total": "9 files, +546 -103 (649 changed lines, under the 5000 human-merge threshold); all inside the claim's declared surface"
},
"mcp_calls": "0",
"api_writes": "3 REST writes, each through the fleet-write relay (scripts/pm/fleet-write/dispatch.mjs, POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft #22185; read-back identical, 13158 bytes); (2) label-write --assign os-justin -> POST /repos//issues/22185/assignees (read-back matches); (3) this os-dev-report comment -> POST /repos//issues/22128/comments. Plus git push (not REST). Reads: gh api GETs only.",
"deviations": [
"Merged origin/main (959c209) into the branch before deriving gates. dispatch-gates flagged the 7ef50a4 tree as STALE: 4 commits behind, 2 gate-source files changed. No file of this diff overlaps main's 20 changed files. It is a plain merge, no regeneration needed: main moved nothing in packages/spec.",
"The rest full-suite run overlapped a gate run in the same worktree that rebuilt packages/spec/dist. Its 12 load failures are not measurements, and those 12 files were re-run alone, green.",
"First rest queue entry used 'pnpm --filter @objectstack/rest test -- --maxWorkers=2' (a bare -- that vitest would drop). Killed by its recorded PID while still queued, before it ran, and re-queued as 'exec vitest run --project local --maxWorkers=2'.",
"GET /meta/:type/:name?package=all now serves the content a read with no ?package= serves (before: a row bound to a package literally named 'all', then the package-less row). Stated in the changeset and the PR body. Flagged for the seat's contract review, since Clause-② no was ruled before this content side was visible.",
"Worktree cleanup (rm -rf node_modules, git worktree remove) runs after this comment is posted, since posting needs the worktree's scripts/pm."
],
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door measured on the real stack (better-sqlite3, real routes): for a view whose active row is stored in package com.probe.pkg, GET /meta/view/case_grid/layers -> 200 overlay 'live' but GET /meta/view/case_grid/layers?package=all -> 404; GET /meta/view -> ['case_grid'] but GET /meta/view?package=all -> []. Named producer: objectui ResourceEditPage.tsx (objectui 9990f9e) scopes its layered and draft reads with the raw router ?package= (its own docblock: ownerPackageId 'does not fold all'), so the Studio editor opened under the list's 'show everything' scope asks /layers?package=all. evidence: rest-server.ts layeredPackageId = req.query?.package || undefined (:3971), list door :6096 and book lookup :6430 forward the literal 'all'; metaItemPackageBinding (:1788) is the fold they would share. Same family as this card's rider (doors not reading ?package= as the save does). Outside the claim's declared surface (rest-server.ts was claimed only for the item read's fold), so not changed here; the seat decides a sub-issue or the family close-out. dedupe words: package=all layers 404 · package=all list empty · ?package= fold meta doors · layeredPackageId all",
"carrier: 承接者:无 · noted, not filed · storedBodyForCarryForward (protocol.ts) still reads repo.get at the named key, so for a package-less draft save of a package-owned item of a type with a metadata redactor its comparison body is neither the inherited draft nor the package-bound active row (falls to the code layer). Read in source only, not measured. dedupe words: carry-forward redacted credential package-less draft inherited package",
"carrier: 承接者:无 · noted, not filed · lowercase metadata_conflict as the wire code persists in packages/client/src/index.ts (4 docblocks: :670 :837 :1806 :1864), packages/cli/src/commands/meta/delete.ts:115 (a description string), content/docs/concepts/metadata-lifecycle.mdx:147, docs/qa/platform-checklist/areas/studio-authoring.json:374,376; same family as the rider's spelling fix, which named only plugin-rest-api.zod.ts. sys_metadata_audit's code 'metadata_conflict' is the audit data value and correct. dedupe words: metadata_conflict lowercase wire code METADATA_CONFLICT spelling"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
✅ ACCEPT: PR #22185 at
07c229054. Parked for the contract reviewdomain:specseat 3 (#18883) ·os-justin· sessionsession_01RPo7FUd6bSnAfkWMAKi848· 2026-10-08T05:20Z · holder of claim6051828637. Report read:6052880125.Checked on GitHub and against
origin/main, not from the report:07c229054. It is a merge of the branch withorigin/main959c209d5, and it still merges cleanly withmainat this stamp.Fixes #22128, andClause-②: nosits at a line start in both the body and the changeset.@objectstack/metadata-protocol,@objectstack/restand@objectstack/spec.SysMetadataRepository.resolveWriteHeadis the one resolution of "the row a save at this address upserts". It covers 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 inheritance of the active row's package for a package-less draft, and the fallback to a package-unbound draft.putnow takes its lock row from that resolution.headAtreturns the same row.ObjectStackProtocolImplementation.storedHeadAtcallsheadAtinstead ofget, so the save's expected parent, the repository's lock and the read's servedversionresolve one row. This is triage's direction (6049925243), with the inheritance kept (⛔ not dropped).putpath's code is the same lookup moved into the helper. Its inheritance lookup now runs with the caller's transaction context; before, it ran with{ isSystem: true }alone.6051335917:?package=now goes throughmetaItemPackageBinding, the one fold the save and publish doors share; their two inline copies are removed.METADATA_CONFLICT.?state=draftserving the draft'sversion; the stale-If-Matchand env-local controls);?package=allcases;headAtcases beside the unchanged 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.NOT MEASURED(prerequisite exit 3:check:skill-examples,check:dual-build-cjs-loads, which CI answers). The artifact roster is 48 of 48 exit 0,check:error-code-provenanceincluded. Theobjectqlconsumers are 54 files, 683 tests green, with theprotocol-meta.test.tsfindOne pins among them.For the contract review:
GET /meta/:type/:name?package=allnow serves what a read with no?package=serves. Before, it looked for a row bound to a package literally namedall, then fell back to the package-less row.Clause-②: nowas ruled before that side was visible. The review judges whether it still holds.--tierClause ② SUSPECT surface (the twopackages/spec/src/apifiles) is the other reason the review is owed before enqueue.Out-of-scope findings, each dispositioned:
?package=allon the layered read and the list doors answers 404 /[]for an item stored in a package. This is class (a), measured on the real routes, and the producer is named (objectui'sResourceEditPage.tsx). Filed finding(rest):?package=allreaches the layered read and the metadata list as a literal package id:/meta/:type/:name/layers?package=allanswers 404 andGET /meta/:type?package=allanswers [] for an item stored in a package #22188 (finding, for triage).storedBodyForCarryForwardstill readsrepo.getat the named key. This is a read-only inference and was not measured. Acceptance notes: no carrier, and outside the filing classes.metadata_conflictas the wire-code spelling inpackages/client/src/index.tsdocblocks, a CLI description, one docs page and the QA checklist. This is a docs/description spelling: no wrong runtime answer, no metadata trap. Acceptance notes, carried to whichever PR next edits those docblocks.The PR carries
needs:contract-reviewfrom this act until a PASS on its landing head.objectstack-fleet commentedon Oct 8, 2026
Patch round 1: PR #22185, after the contract review's FAIL
6053073603. Same claim, same branch, the same devdomain:specseat 3 (#18883) · sessionsession_01RPo7FUd6bSnAfkWMAKi848· 2026-10-08T05:33Z · holder of claim6051828637(branchclaude/issue-22128-draft-head-resolution, unchanged).The review found the mechanism, the pins and the spec text right. It failed one item, ②: the new public
SysMetadataRepository.headAtwidens@objectstack/metadata-protocol's public surface. That needsClause-②: yes (widening)atminor, where the changeset declarednoatpatch. This seat's ACCEPT6052924965missed it, and so did triage's pre-shapeClause-②: no. The diff is the fact.The round, dispatched now:
@objectstack/metadata-protocol: minor, withrestandspecstayingpatch;Clause-②: yes (widening)at a line start in the changeset and the PR body;.changeset/21986-metadata-protocol-declines-stored-row-public.md's precedent.storedBodyForCarryForward(protocol.ts, which still readsrepo.getat the named key):6051335917) and in the declared surface.The PR keeps
needs:contract-reviewuntil a PASS on its new head.fix(metadata-protocol): the credential carry-forward reads the row th…
docs(changeset): @objectstack/metadata-protocol minor, Clause-②: yes …
objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22128,
"status": "done",
"round": "patch round 1 (contract review 6053073603, FAIL on 07c2290)",
"branch": "claude/issue-22128-draft-head-resolution",
"pr": "#22185",
"head": "ea3c748f8c42f6e5a0266b405a33430bc9a70477",
"session": "session_01RPo7FUd6bSnAfkWMAKi848",
"premise_still_valid": true,
"summary": "Item 1 (②): the changeset now grades @objectstack/metadata-protocol minor, with rest and spec still patch, and its body line is 'Clause-②: yes (widening)'. It ends with 'The only change to the public surface is one added method on the exported SysMetadataRepository: headAt', following .changeset/21986-metadata-protocol-declines-stored-row-public.md. The PR body's Clause line is replaced the same way, in one issue_patch. A widening is not a break, so no adr-0087 marker is needed: check-adr-0087-registration reads '1 non-breaking changeset(s)'. check-changeset-no-major's level axis, run with a pull_request event carrying the new body, reads 'yes (widening) ... minor or above'. Item 2 (③ OOS2): MEASURED, and the credential drops. A package-owned legacy datasource whose config.url holds a userinfo credential was read redacted, then saved back as a package-less draft. The stored draft lost the credential, while the env-local control kept it. Fixed in this PR with the same resolution: storedBodyForCarryForward's two reads now go through repo.headAt. headAt now answers { head, packageId }, where packageId is the binding the write targets, so the active fallback reads the active row in the package the draft is stamped into. Pinned on the real route, with an env-local control. No credential value appears in any GitHub artefact.",
"carry_forward_measurement": {
"tree_before": "head 07c2290 (round 0), a dist built from it; a temporary probe file, deleted, never committed",
"command": "pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/__probe22128cf.test.ts (real RestServer routes, better-sqlite3 :memory:, caller with manage_metadata + manage_platform_settings)",
"setup_note": "PUT of a datasource whose config.url embeds a userinfo password is now refused at the save door: 422 INVALID_METADATA on config.url. So the package-owned row was seeded straight into sys_metadata, with package_id com.probe.pkg and a checksum stamped as put stamps it. A row like this only exists from before the write gates.",
"output_before_redacted": [
"package-owned active rows [{label: live, packageId: com.probe.pkg, urlHoldsCredential: true}]",
"package-owned plain read 200; served url holds credential false; served url present true",
"package-owned draft save 1 (PUT ?mode=draft, no ?package=) 200 -> draft rows [{label: draft 1, packageId: com.probe.pkg, urlHoldsCredential: false}]",
"package-owned draft read 200; draft save 2 200 -> draft rows [{label: draft 2, packageId: com.probe.pkg, urlHoldsCredential: false}]",
"env-local control: active urlHoldsCredential true -> draft 1 true -> draft 2 true (packageId null)"
],
"verdict": "the credential DROPS on the first package-less draft save of a package-owned datasource (pre-existing on the first save; this PR's round-0 fix made the second save reachable too)",
"committed_pin_before_fix": "meta-draft-head-package-inheritance.test.ts on the pre-fix dist: Tests 1 failed | 8 passed (9); 'package-owned: the redacted read saved back as a package-less draft, twice, keeps the stored credential' -> AssertionError: expected [ false ] to deeply equal [ true ]",
"after_fix": "commit 4e63f11, metadata-protocol rebuilt (grep -c 'target.packageId' packages/metadata-protocol/dist/index.js = 1): meta-draft-head-package-inheritance.test.ts + meta-item-version-token-occ.test.ts + meta-publish-package-scope.test.ts -> Test Files 3 passed (3), Tests 42 passed (42); sys-metadata-repository.draft-package-inherit.test.ts + protocol.metadata-redaction.test.ts + protocol.credential-channel.test.ts -> 3 passed, 63 tests"
},
"resolution": {
"function": "SysMetadataRepository.resolveWriteHead (private), sys-metadata-repository.ts:620; public SysMetadataRepository.headAt at :578, now returning { head: MetadataItem | null; packageId: string | null }",
"callers_rewired": [
"SysMetadataRepository.put (:745, inside its transaction)",
"SysMetadataRepository.headAt (:583)",
"ObjectStackProtocolImplementation.storedHeadAt (protocol.ts:18075-18081, .head?.hash); its callers saveMetaItem's expected-parent read and readVersionToken unchanged",
"ObjectStackProtocolImplementation.storedBodyForCarryForward (protocol.ts:8650 own-state read, :8656 active fallback at the answered binding), new this round; restoredCredentialPathsFor and the active-save carry-forward read the exact key as before",
"REST metaItemPackageBinding (rest-server.ts:1788) for GET item / PUT / POST publish, unchanged this round"
]
},
"pins": {
"triage_and_rider": "unchanged from round 0 (7 real-route pins in packages/rest/src/meta-draft-head-package-inheritance.test.ts)",
"carry_forward_new": [
"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'",
"same describe > 'control: env-local, the same round trip keeps the stored credential, as before'"
],
"repository_unit": "sys-metadata-repository.draft-package-inherit.test.ts headAt describe: 6 cases (one new: 'a brand-new item drafted first: no head, and the package-less binding (nothing to inherit)'); the binding packageId is now asserted in the inherited, orphan, explicit-package and active cases"
},
"tests": "At head ea3c748 (run as one locked batch, each exit recorded; batch VERDICT command-exit 0): metadata-protocol vitest run --maxWorkers=2 -> Test Files 221 passed | 3 skipped (224), Tests 28259 passed | 19 skipped; metadata-protocol typecheck exit 0; rest vitest run --project local --maxWorkers=2 -> Test Files 262 passed (262), Tests 4938 passed | 326 skipped; rest typecheck (tsc --noEmit && check:test-typecheck) exit 0; objectql consumers (54 files naming saveMetaItem/getMetaItem/SysMetadataRepository) -> 54 passed, 683 tests. spec: no source change since round 0 (round-0 readings: 4 targeted files passed, typecheck exit 0); check:generated re-run green at ea3c748. CI at report time on ea3c748: 38 check runs completed (0 failed), 4 in_progress.",
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at ea3c748 -> 113 commands (identical set to round 0); --ran with 'CMD :: exit N' lines: '113 derived famil(ies) accounted for — 111 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)'",
"not_measured": [
"pnpm --filter @objectstack/spec run check:skill-examples :: exit 3 (PREREQUISITE NOT MET, no client-react dist)",
"pnpm check:dual-build-cjs-loads :: exit 3 (PREREQUISITE NOT MET, packages without dist)"
],
"all_other_111_derived": "exit 0",
"artifact_roster_48": "45 exit 0 locally (check:error-code-provenance, check:error-code-casing, check:engine-double-contract, check:spec-changes, check:meta-url-spelling among them); check-partof-closing-keyword (PR_BODY = the patched body), check-closing-target-claim (PR_NUMBER=22185, patched body) and check-single-claim-paths (PR_NUMBER=22185) each exit 0",
"changeset_gates": "check-changeset-no-major --base origin/main with GITHUB_EVENT_PATH = a pull_request event carrying the patched body: exit 0, 'LEVEL AXIS: this PR declares clause-② yes (widening), and it grades a package whose packages//src/ it moves at minor or above'; check-adr-0087-registration --base origin/main: exit 0, '1 non-breaking changeset(s) seen'",
"stale_tree_note": "the derivation flagged ea3c748 as 11 commits behind origin/main 5d1d1ac, with 2 gate-source files changed there: scripts/engine-double-contract.pinned.json (+10, no entry for this diff's files) and a removed audit probe. No file of this diff overlaps main's changes. Not merged, per the round's instruction, because the branch still merges cleanly."
},
"files_changed": {
"this_round_vs_07c229054": {
".changeset/22128-draft-head-resolution.md": "+7 -5",
"packages/metadata-protocol/src/protocol.ts": "+22 -9",
"packages/metadata-protocol/src/sys-metadata-repository.ts": "+19 -17",
"packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts": "+18 -8",
"packages/rest/src/meta-draft-head-package-inheritance.test.ts": "+92 -9"
},
"whole_pr_vs_959c209d5": "9 files, +664 -111 (775 changed lines): changeset +17/-0, protocol.mdx +1/-1, protocol.ts +49/-21, sys-metadata-repository.ts +120/-70, draft-package-inherit.test.ts +81/-1, meta-draft-head-package-inheritance.test.ts +352/-0, rest-server.ts +30/-10, plugin-rest-api.zod.ts +1/-1, protocol.zod.ts +13/-7; all inside the claim's declared surface"
},
"commits_this_round": [
"4e63f1122 fix(metadata-protocol): the credential carry-forward reads the row the draft save overwrites (#22128)",
"ea3c748f8 docs(changeset): @objectstack/metadata-protocol minor, Clause-②: yes (widening) for the added headAt; the carry-forward bullet (#22128)"
],
"mcp_calls": "0",
"api_writes": "2 REST writes this round, each through the fleet-write relay (scripts/pm/fleet-write/dispatch.mjs -> POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): (1) issue_patch -> PATCH /repos//issues/22185 (body only; live body read first and found byte-identical to the round-0 send; read-back identical, 16671 bytes); (2) this os-dev-report comment -> POST /repos//issues/22128/comments. Plus git push (not REST). No ready, label, assignee or merge writes. Reads: gh api GETs only.",
"deviations": [
"headAt's return shape changed from MetadataItem | null to { head, packageId } this round. The carry-forward's active fallback needs the binding the draft write targets, and taking it from the same resolution avoids a second public method or a second copy of the inheritance predicate. headAt is new in this PR and unreleased, so the widening stays one added method.",
"The carry-forward measurement could not create the stored credential through the save door, which now refuses a userinfo password in config.url (422 INVALID_METADATA). The legacy row was seeded into sys_metadata directly in the probe and in the committed pin.",
"Measured on datasource only (built in). flow's carry-forward runs the same code path, but its credentials normally live in the write-only credential channel (#20790), so it was not probed separately.",
"origin/main was not merged this round. The branch merges cleanly (git merge-tree exit 0, no overlapping files), so the derivation ran on a tree flagged 11 commits behind origin/main. See gates.stale_tree_note.",
"The probe file packages/rest/src/__probe22128cf.test.ts was temporary: deleted before the fix commit, never pushed.",
"Worktree cleanup (rm -rf node_modules, git worktree remove) runs after this comment is posted, since posting needs the worktree's scripts/pm."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: #22188 (filed by the seat) · the ?package=all reading on the layered read, the list read and the book lookup, measured in round 0 (layers?package=all -> 404, list ?package=all -> []); unchanged here",
"carrier: 承接者:无 · noted, not filed · lowercase metadata_conflict in client docblocks, a CLI description string, one docs page and the QA checklist; the review answered the acceptance note as the right disposition"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
Landed: PR #22185 →
3b493184e1. The card closescompleteddomain:specseat 3 (#18883) ·os-justin· sessionsession_01RPo7FUd6bSnAfkWMAKi848· 2026-10-08T07:07Z · holder of claim6051828637.Landed: PR fix(metadata-protocol): one head row per /meta write address — a second unpinned package-less draft save is accepted (#22128) #22185 merged through the merge queue at 2026-10-08T07:06Z as
3b493184e1. It has one parent,35396b5e9, and is an ancestor oforigin/main.Content check: all 9 files are blob-equal to the reviewed landing head
ea3c748f8. The trail on that head:6052924965;6053073603, on07c229054;6053088308;6053978836.Its check-runs read 38 success and 4 expected skips before it was made ready.
What now holds:
SysMetadataRepository.resolveWriteHead, exposed as the new publicheadAt, returns{ head, packageId }. It gives the row a save at an address upserts, with 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 package inheritance kept. The save's expected parent, the repository's lock and the item read'sversionall resolve that one row. A second unpinned package-less draft save of a package-owned item is accepted (ADR-0008, last-writer-wins), and?state=draftserves that draft'sversion.datasourcedropped the stored credential. That path is now pinned, with an env-local control.?package=. The item read folds it throughmetaItemPackageBinding, the one fold the save and publish doors share. TheSaveMetaItemRequestSchema.packageIddescribe states the inheritance, and the publish route readsMETADATA_CONFLICT.@objectstack/metadata-protocolminorwithClause-②: yes (widening), for the one added public method.restandspecarepatch.Carried elsewhere:
?package=allreaches the layered read and the metadata list as a literal package id:/meta/:type/:name/layers?package=allanswers 404 andGET /meta/:type?package=allanswers [] for an item stored in a package #22188:?package=allon the layered read, the list read and the book lookup.6053978836③) is the carry-forward's active fallback at an org-scoped save over an env-wide active row: the same shape on the organization axis.metadata_conflictin the client docblocks, a CLI description and one docs page: Acceptance notes, as the review agreed.This act removes
pm:dispatchedfrom the closed card.domain:spec,area:studioand the type label stay.feat(meta): the /meta item read serves the version token, If-None-Mat…
fix(metadata-protocol): one head row per /meta write address — a seco…
fix(runtime): the @objectstack/hono catch-all's PUT /meta honours If-…
fix(rest,runtime): ?package=all names no package on every /meta door …