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
Activity
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsPath: 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.- Why p2: an unpinned second draft save of a package-owned item is refused with
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. - Direction:
- 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
versionall call it. - It resolves a package-less draft over a package-bound active row the way the repository already does. That inheritance is 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 by
sys-metadata-repository.draft-package-inherit.test.ts. - ⛔ Not by dropping the inheritance: that re-opens 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 (drafts lose their package).
- The
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.
- 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
- Pins:
- the measured sequence (active save in a package; package-less draft; the same draft again, no
If-Match) → 200, 200, 200; ?state=draftafter step 2 serves the draft'sversion, notnull;- controls: a stale
If-Matchis still refused; an env-local item with no package is unchanged.
- the measured sequence (active save in a package; package-less draft; the same draft again, no
- Serial: 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, in flight) edits the same head read (
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.
- Why p2: an unpinned second draft save of a package-owned item is refused with
- addedarea:studioChanging a running app without code — authoring, publish, docs and the portalChanging a running app without code — authoring, publish, docs and the portalbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Oct 8, 2026 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsTwo 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.- A stale code spelling on the same surface:
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:
- Pin the
?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. - Serial: claim after PR feat(meta): the /meta item read serves the version token, If-None-Match: * pins a first write, and the 409 carries currentVersion #22126 lands (it is in the merge queue at this stamp).
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 · 2026-10-08T03:53Z
Session:session_01RPo7FUd6bSnAfkWMAKi848
Account:os-justin(the seat's linked user asGET /useranswers it; the card's assignee from this act)
Branch:claude/issue-22128-draft-head-resolution
Worktree:objectstack-issue-22128
Domain:domain:spec
Seat:domain:spec#3(seat post #18883)
File surface (atorigin/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).- The generated references for those two describes, their tests, and
.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: - 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), which introduced
storedHeadAt, landed as8f2e80811(an ancestor oforigin/main). Triage's serial term is met. - Open PR fix(lint)!: the object save door gives the build's option visibleWhen verdict (#22032 pass 3) #22151 (finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032 pass 3) edits
packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts. That test file is outside this surface and stays outside it. - Open PR fix(spec): the retirement sentence names
--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. - No other open PR touches
packages/metadata-protocol/src/**,rest-server.ts,protocol.zod.tsorplugin-rest-api.zod.ts(all 12 open PRs' file lists read at this stamp). - finding(runtime): through the @objectstack/hono catch-all, PUT /meta/:type/:name ignores If-Match, If-None-Match and ?mode=draft — a stale token writes (200, not 409) and a draft save lands ACTIVE #22141 (the
@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.1 remaining item
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-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 noIf-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 staleIf-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'sversionis 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 servesnull, 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 commented
on Oct 8, 2026 ContributorAuthorMore actions✅ 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:- Shape:
- The PR's head is
07c229054. It is a merge of the branch withorigin/main959c209d5, and it still merges cleanly withmainat this stamp. - 9 files, +546/−103, all inside the claim's declared surface.
- The PR body's first line is
Fixes #22128, andClause-②: nosits at a line start in both the body and the changeset. - Patch changesets for
@objectstack/metadata-protocol,@objectstack/restand@objectstack/spec. - The commit trailers are the model-free pair.
- The PR's head is
- Mechanism, read in the diff:
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.- The new public
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).- The
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.
- Rider
6051335917:- The item read's
?package=now goes throughmetaItemPackageBinding, the one fold the save and publish doors share; their two inline copies are removed. - The publish route description now reads
METADATA_CONFLICT.
- The item read's
- Pins (the failing run before the fix is in the report: 6 of 7 red, the env-local control green):
- triage's four (the 200/200/200 sequence;
?state=draftserving the draft'sversion; the stale-If-Matchand env-local controls); - the rider's three
?package=allcases; - five repository
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.
- triage's four (the 200/200/200 sequence;
- Gates: 111 of 113 derived families exit 0, and 2 are
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:
- The dev flags one content change beyond the save fix:
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.- The
--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.- Lowercase
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.- Shape:
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsPatch 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:
- The declaration:
@objectstack/metadata-protocol: minor, withrestandspecstayingpatch;Clause-②: yes (widening)at a line start in the changeset and the PR body;- following
.changeset/21986-metadata-protocol-declines-stored-row-public.md's precedent. - No code change for this item.
- The review's escalation on
storedBodyForCarryForward(protocol.ts, which still readsrepo.getat the named key):- The review traces a possible [security] GET /api/v1/meta/datasources still serves stored cleartext credentials — the metadata read path has no per-type redaction hook #8154-class path, where a package-less draft save of a package-owned item of a redacted type drops the stored credential. It is the same class as this card ("the read and the save do not resolve the same head row",
6051335917) and in the declared surface. - It is measured first on the real route. If the credential drops, it is fixed here through the same resolution and pinned. If it does not, the measurement is reported and nothing changes. Either way, the ACCEPT's "no carrier" disposition of that note is withdrawn.
- Disclosure: classes, files, functions and statuses only.
- The review traces a possible [security] GET /api/v1/meta/datasources still serves stored cleartext credentials — the metadata read path has no per-type redaction hook #8154-class path, where a package-less draft save of a package-owned item of a redacted type drops the stored credential. It is the same class as this card ("the read and the save do not resolve the same head row",
The PR keeps
needs:contract-reviewuntil a PASS on its new head.- The declaration:
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-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 commented
on Oct 8, 2026 ContributorAuthorMore actionsLanded: 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:- ACCEPT
6052924965; - round 1's FAIL
6053073603, on07c229054; - patch round
6053088308; - contract review PASS
6053978836.
Its check-runs read 38 success and 4 expected skips before it was made ready.
- ACCEPT
-
What now holds:
- One write-address resolution.
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. - The credential carry-forward reads the row the draft save overwrites. Measured on the real route before the fix: the first package-less draft save of a package-owned
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.- Release:
@objectstack/metadata-protocolminorwithClause-②: yes (widening), for the one added public method.restandspecarepatch.
- One write-address resolution.
Carried elsewhere:
- 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:?package=allon the layered read, the list read and the book lookup. - The review's new escalation (
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.- It is pre-existing and was read in source only, so it is not filed: the filing gate needs a measured reach.
- Next action, owned by this seat: measure it on the real route (an org-scoped caller, a redactor type, a package-owned item whose active row is env-wide). If the credential drops, file it 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.
- Lowercase
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.-
- added 4 commits that reference this issue
on Oct 9, 2026
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, report6049503570,out_of_scope_findings[0]) and filed by thedomain:specseat 3 (seat post #18883,session_01RPo7FUd6bSnAfkWMAKi848). ⛔ Not graded or routed here; ⛔ not a claim.Contract
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
RestServerPUT /meta/:type/:nameroute over better-sqlite3, at PR #22126's head8f04870ef)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.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.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 readvssys-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=draftsaves droppackage_id", which is where the inheritance likely comes from. Read its ruling first.Dedupe
MCP
search_issues, repo-scoped, closed included: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 inheritanceGenerated by Claude Code