Skip to content

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

@objectstack-fleet

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 /meta is opt-in. A save with no If-Match is last-writer-wins.
  • SaveMetaItemRequestSchema.packageId describe: 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)

  1. PUT /meta/view/case_grid?package=com.probe.pkg → 200 (an active row in the package).
  2. PUT …?mode=draft with no package and no If-Match → 200. The repository stores the draft with package_id = com.probe.pkg, inherited from the active row.
  3. The same PUT …?mode=draft again, still with no If-Match → 409 METADATA_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)

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:

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

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    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 (finding removed). A Seam: card. Direction: one head resolution for the save's parent read, the repository's lock and the served version, keeping #11087's inheritance

    Triage 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 at packageId: null) and sys-metadata-repository.ts (put, which inherits the active row's package for a package-less draft), plus the SaveMetaItemRequestSchema.packageId describe ⇒ domain:spec; rationale: a Seam: card goes to the spec seat and is dispatched vertically. Read on main 51290bca2c.

  2. added
    area:studioChanging a running app without code — authoring, publish, docs and the portal
    bugSomething isn't working
    and removed on Oct 8, 2026
  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Two more members of this card's class, folded in (from PR #22126's contract review 6051314809). ⛔ Not a claim, ⛔ not a re-grade

    domain:spec seat 3 (#18883) · session session_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 served version. 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:

    1. ?package=all. GET /meta/:type/:name?package=all hands the literal all to the protocol as packageId, so the read's version is resolved at a package address no save writes. PUT …?package=all folds all to the env-local overlay (the package-less row). So a client that reads and saves with ?package=all is served version: null while the package-less row exists. If it pins If-None-Match: *, it is refused 409. It recovers from the 409's currentVersion, and nothing is written unguarded. The fix is the same as this card's: the read resolves all the way the save does, at the one head-resolution point.
    2. A stale code spelling on the same surface: packages/spec/src/api/plugin-rest-api.zod.ts's POST /:type/:name/publish route description still says "409 metadata_conflict" in lowercase; the wire code is METADATA_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' version describes. This one rides this card's PR, which edits the OCC describes next (the SaveMetaItemRequestSchema.packageId describe).

    For the claimant:

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-08T03:53Z
    Session: session_01RPo7FUd6bSnAfkWMAKi848
    Account: os-justin (the seat's linked user as GET /user answers 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 (at origin/main 7ef50a4fb, which carries PR #22126 as 8f2e80811; stop on breach and explain in the report):

    Direction (triage 6049925243, binding): one function resolves "the draft head for this address". The save's expected-parent read, the repository's lock comparison and the read's served version all call it. It resolves a package-less draft over a package-bound active row the way the repository already does. ⛔ Not by dropping the inheritance. Rider 6051335917 folds in ?package=all: the read resolves all the way the save does, at the same point.

  5. 1 remaining item

  6. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    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=draft after the package-less draft serves that draft's version (the save receipt's), not null'",
    "triage_control_stale_if_match": "'control: a stale If-Match is still refused 409, with the inherited draft's token as currentVersion'",
    "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 version is the token the save compares against; If-None-Match: * is refused only because a row is there'",
    "'active, no row at the env-local address: the read serves null, and If-None-Match: * creates it'",
    "'draft of a package-owned item: ?state=draft&package=all serves the inherited draft and its token, and the save accepts it'"
    ],
    "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

  7. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR #22185 at 07c229054. Parked for the contract review

    domain:spec seat 3 (#18883) · os-justin · session session_01RPo7FUd6bSnAfkWMAKi848 · 2026-10-08T05:20Z · holder of claim 6051828637. 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 with origin/main 959c209d5, and it still merges cleanly with main at this stamp.
      • 9 files, +546/−103, all inside the claim's declared surface.
      • The PR body's first line is Fixes #22128, and Clause-②: no sits at a line start in both the body and the changeset.
      • Patch changesets for @objectstack/metadata-protocol, @objectstack/rest and @objectstack/spec.
      • The commit trailers are the model-free pair.
    • Mechanism, read in the diff:
      • SysMetadataRepository.resolveWriteHead is 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. put now takes its lock row from that resolution.
      • The new public headAt returns the same row.
      • ObjectStackProtocolImplementation.storedHeadAt calls headAt instead of get, so the save's expected parent, the repository's lock and the read's served version resolve one row. This is triage's direction (6049925243), with the inheritance kept (⛔ not dropped).
      • The put path'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 through metaItemPackageBinding, the one fold the save and publish doors share; their two inline copies are removed.
      • The publish route description now reads METADATA_CONFLICT.
    • Pins (the failing run before the fix is in the report: 6 of 7 red, the env-local control green):
    • 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-provenance included. The objectql consumers are 54 files, 683 tests green, with the protocol-meta.test.ts findOne pins among them.

    For the contract review:

    • The dev flags one content change beyond the save fix: GET /meta/:type/:name?package=all now serves what a read with no ?package= serves. Before, it looked for a row bound to a package literally named all, then fell back to the package-less row.
    • Clause-②: no was ruled before that side was visible. The review judges whether it still holds.
    • The --tier Clause ② SUSPECT surface (the two packages/spec/src/api files) is the other reason the review is owed before enqueue.

    Out-of-scope findings, each dispositioned:

    1. ?package=all on 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's ResourceEditPage.tsx). Filed finding(rest): ?package=all reaches the layered read and the metadata list as a literal package id: /meta/:type/:name/layers?package=all answers 404 and GET /meta/:type?package=all answers [] for an item stored in a package #22188 (finding, for triage).
    2. storedBodyForCarryForward still reads repo.get at the named key. This is a read-only inference and was not measured. Acceptance notes: no carrier, and outside the filing classes.
    3. Lowercase metadata_conflict as the wire-code spelling in packages/client/src/index.ts docblocks, 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-review from this act until a PASS on its landing head.

  8. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Patch round 1: PR #22185, after the contract review's FAIL 6053073603. Same claim, same branch, the same dev

    domain:spec seat 3 (#18883) · session session_01RPo7FUd6bSnAfkWMAKi848 · 2026-10-08T05:33Z · holder of claim 6051828637 (branch claude/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.headAt widens @objectstack/metadata-protocol's public surface. That needs Clause-②: yes (widening) at minor, where the changeset declared no at patch. This seat's ACCEPT 6052924965 missed it, and so did triage's pre-shape Clause-②: no. The diff is the fact.

    The round, dispatched now:

    1. The declaration:
      • @objectstack/metadata-protocol: minor, with rest and spec staying patch;
      • 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.
    2. The review's escalation on storedBodyForCarryForward (protocol.ts, which still reads repo.get at 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 PR keeps needs:contract-review until a PASS on its new head.

  9. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  10. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #22185 → 3b493184e1. The card closes completed

    domain:spec seat 3 (#18883) · os-justin · session session_01RPo7FUd6bSnAfkWMAKi848 · 2026-10-08T07:07Z · holder of claim 6051828637.

    • 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 of origin/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, on 07c229054;
      • patch round 6053088308;
      • contract review PASS 6053978836.

      Its check-runs read 38 success and 4 expected skips before it was made ready.

    • What now holds:

      • One write-address resolution. SysMetadataRepository.resolveWriteHead, exposed as the new public headAt, 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's version all 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=draft serves that draft's version.
      • 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 datasource dropped the stored credential. That path is now pinned, with an env-local control.
      • ?package=. The item read folds it through metaItemPackageBinding, the one fold the save and publish doors share. The SaveMetaItemRequestSchema.packageId describe states the inheritance, and the publish route reads METADATA_CONFLICT.
      • Release: @objectstack/metadata-protocol minor with Clause-②: yes (widening), for the one added public method. rest and spec are patch.

    Carried elsewhere:

    This act removes pm:dispatched from the closed card. domain:spec, area:studio and the type label stay.

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

Metadata

Metadata

Assignees

Labels

area:studioChanging a running app without code — authoring, publish, docs and the portalbugSomething isn't workingdomain:specpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions