Skip to content

metadata-protocol: the draft doors naming no package resolve three addresses — the save and read inherit the active binding, the discard reads the draft rows, the publish takes the first draft row a package-agnostic findOne returns #23495

Closed

Description

@objectstack-fleet

Filing gate: (a) a reachable defect a sibling change exposes.

Filed by domain:engine seat 1 · marchtian · session session_01Rerax7QTjKMPCUZxQUtPFR, at #23489's contract review. ⛔ Not a claim.

What happens (read from source at 52bfacaa04)

For one item, a request naming no package can reach three different draft addresses, depending on which door it uses:

  1. The draft save inherits the binding of the item's ACTIVE rows (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, resolveWriteHead). After fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489, the strict draft read, the draft preview and the draft token follow the same address.
  2. The draft discard resolves its address from the DRAFT rows (packagelessAddress(…, 'draft')).
  3. The per-item publish (promoteDraftForPublish, draftKey) takes whichever draft row a package-agnostic findOne returns first. That is the storage-order coin toss publishPackageDrafts can promote (and drain) ANOTHER package's draft row — the promote resolves the draft without the ADR-0048 package dimension #8907 removed from publishPackageDrafts, still present on the per-item door.

Two states that split the doors:

A second finding, from #23291's os-dev-report 6168967319 (out-of-scope finding 1), carried here: a package-less DRAFT save that inherits a package answers (env-wide, state=draft) and does not name the package. The direct save's receipt does name it.

Governing text

Wanted

  • The probe first, on a real kernel, in the two states above:

    • the draft read naming no package;
    • the publish (POST /meta/TYPE/NAME/publish);
    • the discard naming no package.

    Record which row each one reaches. Close any half the probe disproves, with the measurement.

  • The direction (review 6169548673 E1): the publish and the discard naming no package resolve their address as the draft save does, or are refused 409 RESOURCE_CONFLICT with the ?package= remedy when the address is ambiguous.

  • The draft-save receipt names the inherited package, as the direct save's does.

  • Pins:

    • both states;
    • both storage orders;
    • each door's address agreeing with the draft save's;
    • a negative control that goes red when the fix is removed.
  • Order: after PR fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489 lands, since it moves the draft read onto the save's address.

Dedupe

MCP search_issues on this repo, closed included: draft publish discard naming no package resolve different address package-less draft promoteDraftForPublish storage order → 37 hits.

None is this.

Dedupe words: draft discard publish naming no package address; promoteDraftForPublish draftKey findOne storage order per-item publish; draft save receipt inherited package env-wide.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · priority:p1 + target:v18 · domain:engine · pm:queue · probe first · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-14T13:59Z

    Read fresh. It was read from source at PR #23489's review, not measured. For one item, a package-less request can reach three draft addresses (save/read follow the active binding; discard follows the draft rows; per-item publish takes a package-agnostic findOne's first row).

  2. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-15T08:16Z
    Session: session_01GP3comALHRcRBixS2TEnLF
    Account: sunhaolin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23495-draft-doors-one-address
    Worktree: objectstack-issue-23495
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface: packages/metadata-protocol/src/protocol.ts, the draft doors' address resolution only: the draftKey read in promoteDraftForPublish, the draft-side address in discardDraftInItsScope / packagelessAddress, and the draft-save receipt line in saveMetaItem. Plus new tests (a real-kernel probe and pins) and one changeset .changeset/23495-*.md. Stop on a breach and explain it in the report.
    Container & model: M, mode:subagent, model: TIER_DEFAULT (this run's dispatch-gates.mjs --tier: "no path-derived mandate"; a probe-first card with a door direction to judge)
    Clause-②: yes
    Responsibility: the risk is produced by metadata-protocol's own draft doors (the per-item publish's package-agnostic draft read and the discard's draft-row address) | no platform path covers it: the draft save's inheritance (resolveWriteHead) reaches the save, read and token only | an author on /api/v1/meta/TYPE/NAME draft doors naming no package over a package-bound item reaches it, and the second state is reachable through ?mode=draft&package=Q. Use today is unmeasured, so the card's probe comes first.
    Thread-read: 6170130346
    Serial constraints cleared: open PRs on the same file, none in this region: #23628 (#23586, the updateData / deleteData conflict region), #23626 (#23577, the sealed-write region and saveMetaItem's tenant-authored-write refusal) and #23504 (#23473, the uninstall pre-flight). Sibling in this batch: #23514 (this file's object-ownership region, written in parallel; whichever lands second merges main). Queue card #23630 member 1 (the promote doors' D9.9 question) shares promoteDraftForPublish. Fold refused: its defect is ownership, not address. It is serial behind this card.

    Amended 2026-10-15T10:5xZ by this seat (same session): Clause-②: no → yes. The contract review 6190278990 on PR #23654 (① item 11) found a widening: a draft discard naming no package, of an item bound to one package whose drafts are bound to two or more, answered 409 on main (as the published DeleteMetaItemRequestSchema.packageId description stated) and removes that package's draft on the head. The narrowings stand; the changeset and the PR body carry Clause-②: yes (narrowing).


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 23495,
      "status": "done",
      "branch": "claude/issue-23495-draft-doors-one-address",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/23654",
      "session": "session_01GP3comALHRcRBixS2TEnLF (subagent: the dispatching PM session's id; the fleet-write relay derived the same id from the container)",
      "premise_still_valid": true,
      "summary": "The probe found a split in both card states on a real stack (better-sqlite3, the real ObjectQL, protocol and RestServer routes, via the harness of packages/rest/src/meta-draft-head-package-inheritance.test.ts), so no half closes with the reading. At main 23e689dc7, in state 1 (active row bound to P, drafts [package-unbound, P]), the publish naming no package promoted the package-unbound draft into a package-unbound active row; the save, the read and the discard named P's draft. In state 2 (the only draft bound to Q), the read answered 404 NO_DRAFT and the save wrote P's draft, but the publish promoted Q's draft into a Q active row and the discard removed it. H1, H2 and H3 held. The fix routes both doors through one helper, packagelessDraftAddress in protocol.ts: the draft save's address read from the active rows. For an item bound to one package it is P's draft, else the package-unbound draft, which is published INTO P through the new repository option promoteDraft({adoptInto}), as the save adopts it. With no draft at that address, the publish answers 404 NO_DRAFT naming the remaining drafts and ?package=, and the discard answers 200 reset:false naming them. Two or more packages are refused 409 RESOURCE_CONFLICT by the shared builder, which gains a publish verb. An item never published into a package takes the package-unbound draft, else the one package's draft, else 409. The draft save receipt names the inherited package. The spec descriptions for PublishMetaItemRequestSchema.packageId and DeleteMetaItemRequestSchema.packageId were corrected and the docs regenerated. This is a narrowing, so the changeset is BREAKING minor with Clause-②: no (narrowing) and ADR-0087 not-required (no-migration-prescription), and it owes the seat a contract-tier review before the queue.",
      "tests": "All runs are at HEAD c313d36d7, under scripts/pm/os-verify-lock.sh, and each verdict was read from the VERDICT line. (1) The full metadata-protocol suite ran in two halves of 120 and 119 files: 'Test Files 117 passed | 3 skipped (120) / Tests 18223 passed | 19 skipped' and 'Test Files 119 passed (119) / Tests 2340 passed (2340)', both command-exit 0. (2) Consumer files that drive a draft door: objectql 20 files (the first run had 3 red in protocol-publish-package-drafts.test.ts, because its engine double lacked find, which the draft address reads; the double was widened, and the rerun read '1 passed, 17 tests'); rest 29 files, 1481 tests; runtime 13 files, 1115 tests; all command-exit 0. (3) Typecheck exited 0 for metadata-protocol, rest (+check:test-typecheck), objectql (+check:test-typecheck) and spec; tsc --listFiles in metadata-protocol counted 4/4 of the edited test files. (4) New pins: 8 cases in protocol.packageless-save-address.test.ts (state 1 in both storage orders, state 2, the package-unbound draft adopted into P, two packages 409, and the no-active-row corners); 3 adoptInto pins in sys-metadata-repository.draft-package-inherit.test.ts; 3 real-stack REST pins. Two pins of the removed first-row pick were replaced, not respelled: protocol-publish-drafts-package-scope.test.ts (match-any is now 409, plus a pin that an absent key is not null) and protocol.org-scoped-write-refused.test.ts (the draft-row lock is now parity with the draft save's lock). (5) Ablations went through scripts/ablation-replace.mjs, which proved anchor 1->0 on disk and that the restored blob equals HEAD with an empty git diff HEAD. All were red, the expected direction: A1, the draft address ignoring the active rows: 6 red / 350 green, with the corner cases for an item never published into a package staying green as predicted. A2, adoptInto removed in the repository: 2 red. A3, the receipt line deleted: 2 red. A1 on the dist-resolved REST suite: built, ablation-dist-preflight found the marker in dist/index.js and dist/index.cjs, 3 of 15 red; restore leg rebuilt, preflight --absent held, the tree was clean against HEAD, and 15 of 15 passed. The pristine marker reading before the mutation was 0 hits in src and dist.",
      "gates": "Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at c313d36d7, no paths: 113 commands, all run locally, each exit captured before any pipe. --ran with exit codes reads: '113 derived, 110 run, 3 NOT-MEASURED, 0 UNRUN'. 109 exited 0, including check:generated ('All 14 generated artifacts are up to date'), check:docs, check:api-surface, check:engine-double-contract (1009 pinned), check:durability-log-level, check:doc-authoring, check:cross-package-test-inputs, check:test-source-alias, check-adr-0087-registration ('[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)'), check-changeset-no-major and check-empty-changeset. NOT MEASURED (prerequisite refusals in this container, not findings): check-plugin-teardown-shape --self-test (exit 3, shallow clone, the pinned positive control is unreachable); spec check:skill-examples (exit 3, client-react not built); check:dual-build-cjs-loads (exit 3, the whole repo not built); check-engine-split-ratio --days 90 (exit 2, shallow clone refuses a windowed metric). CI runs all four. CI on PR #23654 at c313d36d7 when this report was written: 24 check-runs; 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped, and the rest in_progress, including Check Changeset, the Type Check lanes, the Lint lanes and Governed Surface Queue Guard. I did not wait for them. main moved to c7dd48ab4; a local merge-tree is clean, and main was not merged, per the seat's notice.",
      "mcp_calls": "0 — no MCP GitHub tool used (reads went through gh api REST GETs)",
      "api_writes": "2 relay strokes, each POST /repos/objectstack-ai/objectstack/dispatches with event fleet-write, executed as objectstack-fleet[bot]. Stroke 1 (run 38720400548) made 2 writes: POST /repos/objectstack-ai/objectstack/pulls (draft #23654) and POST /repos/objectstack-ai/objectstack/issues/23654/assignees [sunhaolin]; the read-back confirmed the body byte-identical and the assignee present. Stroke 2 is this report: POST /repos/objectstack-ai/objectstack/issues/23495/comments. git push is not REST. 0 label writes: the dispatch named no label and skip-changeset does not apply.",
      "files_changed": [
        ".changeset/23495-draft-doors-one-address.md",
        "content/docs/references/api/protocol.mdx (regenerated by check:generated --fix)",
        "packages/metadata-protocol/src/protocol.ts",
        "packages/metadata-protocol/src/sys-metadata-repository.ts",
        "packages/metadata-protocol/src/protocol.packageless-save-address.test.ts",
        "packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts",
        "packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts",
        "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts",
        "packages/objectql/src/protocol-publish-package-drafts.test.ts",
        "packages/rest/src/meta-draft-head-package-inheritance.test.ts",
        "packages/spec/src/api/protocol.zod.ts"
      ],
      "line_budget": "919 changed lines (+817 / -102) across 11 files, under the 3000 human-merge threshold (dispatch-gates reading); no governed surface",
      "deviations": [
        "File surface extended beyond the claim, declared in the PR body. Five files: sys-metadata-repository.ts (the shared refusal's publish verb and promoteDraft adoptInto, the producer of the promoted row's binding); spec protocol.zod.ts (two .describe texts that would otherwise be false); the regenerated protocol.mdx; packages/rest/src/meta-draft-head-package-inheritance.test.ts (the real-stack pins, cross-lane: the seat files the declaration); packages/objectql/src/protocol-publish-package-drafts.test.ts (an engine double widened to answer find).",
        "PR body line reads 'Clause-②: no (narrowing)': the claim's value no, plus the arm the dispatch requires for a narrowing; the changeset carries the same line.",
        "main was not merged before opening the PR (AGENTS.md §10 asks for one pre-PR pull), per the seat's overlap notice; the merge-tree against c7dd48ab4 is clean.",
        "The commit trailers use the model-free pair (AGENTS.md). The harness reminder asked for a model-named Co-Authored-By, which I did not use."
      ],
      "open_questions": [
        {
          "question": "An item never published into a package (no active row): the draft save naming no package writes the package-unbound draft. With only Q's draft stored, that save mints a SECOND draft beside Q's (measured: drafts [SAVED@null, Q-draft@Q]), while this PR's publish and discard act on Q's draft and the read is best-effort. Should the draft save naming no package, with no active binding and exactly one package's draft, inherit that draft's package?",
          "options": [
            "A: inherit it in resolveWriteHead, so the save, read, publish and discard all name Q's draft (a save-door change in sys-metadata-repository.ts; ADR-0070 TL;DR 1 direction; small)",
            "B: leave the save as #11087 states it and keep this PR's rule (unbound draft first, else the one package's draft) — today's behaviour for the save; the doors disagree only in this corner",
            "C: refuse the publish and discard naming no package for an item with no active binding unless the package-unbound draft exists (strict save address; breaks clients that draft with ?package=Q and publish via client.meta.publishItem, which cannot name a package)"
          ],
          "recommendation": "A, on its own card. Real need: measured reachable through the doors. Long-term: it removes the last package-less authoring destination among the draft doors (ADR-0070 TL;DR 1). AI-mistake axis: one address for every draft door. Scope: it is a one-branch change at the producer. C narrows a flow the SDK cannot remedy. This PR takes B so as not to change the save door outside the claim."
        },
        {
          "question": "client.meta.publishItem has no package option, so the remedy the two narrowed answers name (?package=) is not reachable through that SDK method (REST and in-process are). Add one?",
          "options": [
            "A: add opts.packageId to publishItem, sent as ?package=, mirroring saveItem/deleteItem (a widening in @objectstack/client)",
            "B: leave it; callers use REST for the narrowed cases"
          ],
          "recommendation": "A: the save and delete methods already carry the option, and a refusal whose remedy the SDK cannot send is a trap for an agent author."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none (承接者:无) · noted in PR #23654 Acceptance notes, not filed — the runtime authoring gate's #9612 package closure narrows only on a caller-stated package, so a publish naming no package that resolves P does not narrow to P's closure while the direct save naming no package does (pre-existing, unchanged).",
        "carrier: none (承接者:无) · noted, not filed — the draft discard receipt 'Draft discarded — TYPE/NAME' does not name the package, unlike the direct delete's; the card asked only for the save receipt.",
        "carrier: none (承接者:无) · noted, not filed — client.meta.publishItem has no package option (see open_questions[1]); dedupe words: publishItem package option, SDK publish ?package=, per-item publish client packageId."
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23654 comment 6190398724 · domain:engine seat 1 · 2026-10-15T10:55Z · contract review 6190278990 (PASS) · Clause-② amended no → yes · filed #23657 and #23658 · landing waits for every check on fd2cd0764a.


    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