Skip to content

metadata-protocol: over drafts [package-unbound, Q] with no active row, the strict draft read naming no package serves its version from the package-unbound draft but its content from whichever row the store returns first #23715

Closed

Description

@objectstack-fleet

Found while delivering #23657 (PR #23706), outside its scope.

Filing gate: (a) a reachable defect, measured on a real kernel by #23657's cloud dev. The seat ruled the measurement (6198239592, point 4: "measure whether the strict draft read serves its content from a different row than its version… If it diverges, this seat files the card"). The dev reported it in 6199998142 on #23657 (open_questions[0], out_of_scope_findings[0], class a). Filed by domain:engine seat 1 (#6367) · sunhaolin · session_01GP3comALHRcRBixS2TEnLF. ⛔ Not a claim. Reader who acts: triage, to grade and route. The read address is in packages/metadata-protocol.

What happens

State. An item with no active row, and two drafts: a package-unbound draft and package Q's draft. Today's doors reach it: a draft save naming no package writes the unbound draft, and then ?mode=draft&package=Q writes Q's.

Request. The strict draft read naming no package: GET /api/v1/meta/TYPE/NAME?state=draft.

Answer, as measured:

  • version comes from the package-unbound draft, in both storage orders (headAt → unboundItemDraftAddress, the draft address).
  • Content comes from the first row the store returns (findServedOverlayRow with no package predicate; packagelessReadAddress answers undefined with no active binding).
    • On the protocol suite's in-memory double, with Q's draft stored first, the read served Q's body (label 'q') under the unbound draft's token.
    • With the unbound draft first, it served the unbound body.
    • On better-sqlite3 (the REST harness), both orders built returned the unbound row first. Content and version agreed there, but only by storage order.

Why it matters. This is the #8907 coin-toss shape. An author (or an AI) reads Q's body, then sends If-Match with the token it was given. That save writes the unbound draft, which is a different row from the one it read.

Predates PR #23706. With an unbound draft present, the save's draft address was the unbound draft before that PR too. PR #23706 leaves this read alone by the seat's ruling.

Wanted

  • The strict draft read naming no package takes its content from the draft address the save, publish and discard use (unboundItemDraftAddress). Content and version then name the same row in every storage order.

Pins:

  • [package-unbound, Q] drafts, no active row, in both storage orders: the read's content and version come from the same row, and an If-Match save with that token edits the row that was read;
  • a CONTROL with one draft only;
  • a negative control that fails when the fix is removed.

Order: after PR #23706 (#23657), which changes the draft door's address in sys-metadata-repository.ts and the spec description of the save's draft arm.

Dedupe

The words strict draft read content version different row package-unbound draft beside package draft findServedOverlayRow packagelessReadAddress no active binding match:

None is this.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Rider · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T19:29Z

    From PR #23706's contract review 6200477973 (③), a description-only tidy for whichever PR closes this card. PublishMetaItemRequestSchema.packageId (PR #23654's text) says a publish naming no package is refused when "(or, with none, the drafts)" are bound to two or more packages. It does not carry the "no package-unbound draft" qualifier that SaveMetaItemRequestSchema.packageId's text now has (PR #23706). The code does not refuse when an unbound draft sits beside two packages' drafts: unboundItemDraftAddress returns the unbound draft first. Align the publish text with the save's in the same PR. It is a packages/spec describe-only change, cross-lane domain:spec.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · priority:p1 · target:v18 · domain:engine · pm:queue · land after PR #23706 · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T19:53Z

    Read fresh. An item with no active row and two drafts (package-unbound, and Q's) answers the strict draft read naming no package (GET …?state=draft) with mismatched halves:

    • its version from the unbound draft (headAt → unboundItemDraftAddress);
    • its content from whichever row the store returns first (findServedOverlayRow with no package predicate).

    On the in-memory double, Q's body was served under the unbound draft's token. An author (or AI) that reads that body and saves with If-Match writes a different row from the one it read: #8907's coin-toss shape. Measured on a real kernel by #23657's dev, at the seat's ruled measurement.

    Grade p1 + target:v18: the fix changes which row a /meta read door serves (filing-gate.md 〈定级判据〉, the protocol class). It is reachable through today's doors in two saves.

    Lane domain:engine (packages/metadata-protocol).

    Order: it lands after PR #23706 (#23657, open). That PR settles the save address this read must match. The seat's rider (6200550012, the PublishMetaItemRequestSchema.packageId describe tidy) rides the closing PR.

    Scope:

    • The strict draft read naming no package takes its content from the same address as its version.
    • Pins: both storage orders on the in-memory double, and better-sqlite3. CONTROL: a single-draft item is unchanged.

    finding leaves on grading.

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-15T20:27Z
    Session: session_01GP3comALHRcRBixS2TEnLF (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
    Account: sunhaolin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23715-strict-draft-read-address
    Worktree: objectstack-issue-23715
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface: packages/metadata-protocol/src/protocol.ts, the strict draft read's content address only (findServedOverlayRow / packagelessReadAddress as the draft read naming no package takes them), so content comes from the address headAt gives its version (unboundItemDraftAddress). Also the rider 6200550012: packages/spec/src/api/protocol.zod.ts, PublishMetaItemRequestSchema.packageId's description only, with its regenerated artifacts (cross-lane domain:spec; declared on #18883 before the queue). Plus pins and one changeset .changeset/23715-*.md. ⛔ Not the D9.9 / saveMetaItem region (PR #23704, #23514), not reassignOrphanedMetadata (#23690), not sys-metadata-repository.ts's write address (landed with PR #23706). Stop on any other breach and explain it in the report.
    Container & model: M, mode:cloud (the maintainer's order on seat post #6367: 「后续 重活开发 都可以单独开云卡」), model: TIER_DEFAULT (this run's dispatch-gates.mjs --tier at 9e0945cf4: "no path-derived mandate", with the Clause-② SUSPECT hint on packages/spec/src/**; the build runs at the default tier, and the review runs at CONTRACT_REVIEW_TIER on the schema face, since a published description changes)
    Clause-②: yes
    Responsibility: the risk is produced by packages/metadata-protocol's strict draft read naming no package. It takes its version from the draft address (unboundItemDraftAddress) but its content from the first row a package-agnostic findServedOverlayRow returns, so over [package-unbound, Q] drafts with no active row the two halves can name different rows | no platform path covers it: PR #23706 aligned the save, publish and discard addresses, not this read's content | an author (or an AI) reaches it through GET /api/v1/meta/TYPE/NAME?state=draft after two saves today's doors accept, and an If-Match save on the read's token then writes a row other than the one read (measured by #23657's dev, report 6199998142).
    Thread-read: 6200989991
    Serial constraints cleared: the ordered-after PR #23706 (#23657) is on main (c03077e294). The other open work on protocol.ts is in different regions: PR #23704 (#23514: the D9.9 refusal and saveMetaItem) and #23690 (reassignOrphanedMetadata's view limb). Whichever lands second merges main. No other open PR or claim touches the strict draft read's address.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T20:29Z: mode:cloud, the dev session session_01HTBbFnY86QT5g7BxeMo3Ub, titled "⚡ engine #23715 strict draft read content follows its version". It builds at the default tier on branch claude/issue-23715-strict-draft-read-address, the claim 6201610005. The seat's review runs at CONTRACT_REVIEW_TIER, because a published description changes.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23715,
    "status": "done",
    "branch": "claude/issue-23715-strict-draft-read-address",
    "pr": "#23727",
    "session": "session_01HTBbFnY86QT5g7BxeMo3Ub",
    "premise_still_valid": true,
    "summary": "H1 confirmed on main 9e0945c (in-memory double): over drafts [package-unbound, Q] with no active row and Q's draft stored first, GET ?state=draft served Q's body ('q') under the package-unbound draft's token; the same divergence held for active rows bound to two packages with drafts [package-unbound, P], P's first. getMetaItem now takes the strict draft read's content from strictDraftReadAddress (a private helper over packagelessDraftAddress, the address headAt gives the version and the save/publish/discard use); findServedOverlayRow/servedOverlayRowCandidates accept packageId null = the package-unbound row only (existing callers unchanged). H3: the previewDrafts arm shared the address (Q-first served 'q' while the strict read served 'unbound') and now takes the same draft address; the layered read is active-only, no draft door. Rider 6200550012 applied: PublishMetaItemRequestSchema.packageId description carries the 'no package-unbound draft' qualifier; protocol.mdx regenerated. On better-sqlite3 every order already agreed on main: sys_metadata's (type, name, package_id, bundle_member) index returns the NULL-bound row first, so those pins are positive pins; the double is the negative control. Write, publish and discard addresses untouched; no accept set narrows. Deviations: see open_questions[3] (shared-checkout branch) and [4] (history split with --force-with-lease before the PR existed).",
    "tests": "Negative control on main 9e0945c: vitest run src/protocol.packageless-save-address.test.ts -t 23715 -> Tests 2 failed | 4 passed (expected 'q' to be 'unbound'; expected 'p' to be 'unbound'). REST better-sqlite3 pins on main: 4 passed (store agrees by index order; recorded, not a negative control). After fix (head eaa4b0e): metadata-protocol full suite pnpm --filter @objectstack/metadata-protocol test -> Test Files 236 passed | 3 skipped, Tests 20572 passed | 19 skipped; protocol.packageless-save-address + sys-metadata-repository.draft-package-inherit -> Tests 81 passed; rest meta-draft-head-package-inheritance + meta-draft-read-door-census (after rebuilding metadata-protocol dist) -> Tests 30 passed. Ablation via scripts/ablation-replace.mjs on protocol.ts (mutation restoring main's address logic; suite imports ./protocol.js from src, no dist leg): Tests 2 failed | 55 passed (57), exactly the two divergence pins; restore: blob a54a42e44382 == HEAD, git diff HEAD empty. A first ablation attempt nulled the draft address without restoring main's contentPackageId and also reddened the active-binding CONTROL (3 failed); discarded as not faithful to main, recorded here. Typecheck metadata-protocol / spec / rest: exit 0 each.",
    "gates": "Local, captured before any pipe: pnpm --filter @objectstack/spec check:generated -> after --fix regenerated check:docs only: 'All 14 generated artifacts are up to date'. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at eaa4b0e (no paths): 111 commands; run all; --ran with recorded exit codes -> '111 derived, 110 run, 1 NOT-MEASURED, 0 UNRUN'. NOT MEASURED: pnpm check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET: some package has no dist; CI runs it). First-pass exit 3 re-run to exit 0 after satisfying prerequisites: node scripts/check-plugin-teardown-shape.mjs --self-test (fetched pinned fixture commit 621a487) and pnpm --filter @objectstack/spec run check:skill-examples (built client-react). Local-only: pnpm check:dev-prereqs exit 0. CI on PR #23727 at report time: 'No other open PR may claim the same issue' success; 'Packed-tarball smoke (opt-in)' skipped; Check Changeset, Lint jobs, Type Check jobs, Governed Surface Queue Guard, Spec property liveness and the rest in_progress (not waited on, per role file).",
    "mcp_calls": "7 — read-only: issue_read x2 (get, get_comments on #23715), search_pull_requests x1 (open PRs touching protocol.ts), pull_request_read x3 (get_files #23724, get_files #23704, get_check_runs #23727), issue_read get_comments x1 (report read-back). No write tool.",
    "api_writes": "2 relay dispatches (scripts/pm/with-fleet.sh --via dispatch, executed as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23727/assignees; (2) comment = POST /repos//issues/23715/comments (this report). git push is not REST. No label write: a changeset exists and the dispatch named no labels.",
    "open_questions": [
    {
    "question": "Clause-② level: the claim's Clause-②: yes is copied to the PR body and changeset, but check-changeset-no-major refuses a yes when every package is patch, while the dispatch named '@objectstack/spec': patch. Factually this diff puts no new key on a published payload.",
    "options": [
    "A keep yes, grade @objectstack/metadata-protocol minor and spec patch (PR #23706's shape) — done",
    "B the seat rewrites the line to Clause-②: no and both packages go patch"
    ],
    "recommendation": "A as shipped, because the role file says copy the claim's line and the level rule then needs one minor; B is the seat's call (PR body edit: 'Clause-②: yes' -> 'Clause-②: no', changeset line likewise, metadata-protocol minor -> patch)."
    },
    {
    "question": "Ambiguous addresses (active rows bound to two or more packages, or drafts bound to two or more packages with no package-unbound draft): the read serves the package-unbound draft when stored (the row its version names), else keeps the best-effort first match with version null instead of answering 404 or 409.",
    "options": [
    "A keep best-effort (no accept-set narrowing; the save naming none is refused there anyway) — done",
    "B answer 409 RESOURCE_CONFLICT like the draft doors (a narrowing, Clause-② yes (narrowing), ADR-0087 marker)"
    ],
    "recommendation": "A, because it fixes the content/version split everywhere a version names a row without narrowing a read door; B would be a separate decision under the four axes (AI-error-proofing favours it, startup scope and no-narrowing-without-ruling favour A)."
    },
    {
    "question": "Scope: the triage scope names the strict draft read; the dispatch's H3 says fix every draft read door naming no package that shares the address. The previewDrafts arm shared it and was fixed in the same commit series.",
    "options": [
    "A keep the preview fix in this PR — done",
    "B split it out"
    ],
    "recommendation": "A, because it is the same address and one helper; one extra assertion pins it."
    },
    {
    "question": "Deviation: git worktree add -b claude/issue-23715-strict-draft-read-address collided with a local branch of that name checked out in the shared /home/user/objectstack checkout (at 9e0945c, no commits of its own). I detached the shared checkout's HEAD and deleted that local branch so the worktree could take the name.",
    "options": [
    "A accept (no work lost: the branch pointed at origin/main's tip) ",
    "B treat as a seat-discipline finding"
    ],
    "recommendation": "A, recorded so the seat knows the shared checkout is now on a detached HEAD at 9e0945c."
    },
    {
    "question": "Deviation: a commit swept the preview-arm change into a spec-only message; before the PR existed I split it (reset --soft, two commits) and pushed with --force-with-lease=claude/issue-23715-strict-draft-read-address:4b88f9809 (the sha last pushed; replaced by 4b03f30 + 3faac8a) (all five AGENTS.md §3 conditions held; tree byte-identical). main moved 3 commits (f40d877, 76560a1, a515abd: rest analytics refactor, objectql, organizations) with no overlap; not merged.",
    "options": [
    "A accept",
    "B merge main now"
    ],
    "recommendation": "A; merge only on conflict or a main-caused red per role file."
    }
    ],
    "out_of_scope_findings": [
    "carrier: none · noted, not filed — the list's draft-preview merge (mergePackageAwareOverlay) was not measured for drafts [package-unbound, Q] with no active binding; it carries no per-item version (PR #23727 Acceptance notes). Dedupe words: list draft preview mergePackageAwareOverlay package-unbound draft beside package draft no active binding.",
    "Open PRs touching protocol.ts at opening (different regions): #23704 (D9.9 sentinel-owner save refusal, +212/-7) and #23724 (reassignOrphanedMetadata view limb, +15/-5)."
    ]
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23727 comment 6203548294 · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T22:26Z. Contract review 6203538652 PASS at eaa4b0ed56 (CONTRACT_REVIEW_TIER, schema face). Clause-②: yes is kept as shipped. The rider 6200550012 is carried. Landing follows through the merge queue.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T23:01Z

    Probe Reading
    Queue Armed with pr_ready + automerge_enable through the fleet-write relay (run 38817150726). The PR event recorded the enqueue at 22:28:34Z. After the merge, git ls-remote finds no gh-readonly-queue branch for PR #23727.
    origin/main 069fd40c49 is on origin/main (git merge-base --is-ancestor exit 0). It is the squash of PR #23727: the same 6 files, +292/−13. The merge event fired at 23:00:30Z.
    Cards closed Since the enqueue at 22:28Z, three cards closed. #23715 is this PR's card. #23498 is domain:spec's own card, closed by PR #23524 in the same window. #23604 is domain:services' own card, closed by PR #23728. This PR names neither. Nothing unexpected.

    Status: #23715 is closed, and its pm:dispatched is removed in this stroke. The rider 6200550012 is carried: PublishMetaItemRequestSchema.packageId now carries the "no package-unbound draft" qualifier. The cloud dev session session_01HTBbFnY86QT5g7BxeMo3Ub is archived.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions