Skip to content

feat(client): meta.publishItem names the package whose draft it promotes, sent as ?package= - #23680

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23658-client-publish-package
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23658-client-publish-package

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23658
Clause-②: yes (widening)

What this does

client.meta.publishItem(type, name, opts?) takes opts.packageId?: string, sent as ?package=ID on POST /api/v1/meta/:type/:name/publish.

Why

Since PR #23654 (57994c311f), a per-item publish that names no package is refused with a ?package= remedy, and nothing is published:

  • 409 RESOURCE_CONFLICT for an item two or more packages hold;
  • 404 NO_DRAFT when the item's one package holds no draft and another package's draft is stored.

The SDK could not send the remedy. This is the publish half of what #23293 did for deleteItem.

Measured first: the two mechanism hypotheses

(a) One declaration: holds. publishItem is declared once, on the unscoped ObjectStackClient.meta (index.ts, the meta object at :1812). The environment-scoped client.environment(id).meta (:7698 on the base) declares getTypes, getItems, getItem, saveItem, deleteItem and getHistory, and no publishItem. No method is added there. A wire row pins the absence, with deleteItem on the same object as its positive control.

(b) The doors read ?package=: the REST door does, and the dispatcher serves no per-item publish door.

  • REST: the publish handler reads it through metaItemPackageBinding(req.query?.package) (the save and delete doors' function) and spreads packageId into publishMetaItem's request. The real-door rows below drive this door.
  • Dispatcher (the createHonoApp catch-all): measured on the runtime dist/ built at 57994c311f. HttpDispatcher.handleMetadata('/view/x/publish', ctx, 'POST', {}, { package: 'pkg' }) answers 404 ROUTE_NOT_FOUND (Route Not Found: /meta/view/x/publish), and the spy publishMetaItem is called 0 times.
    • Control on the same instrument: PUT /view/x?package=pkg answers 200, and saveMetaItem receives packageId: "pkg".
    • createMetaDomain's handler and handleMetadata both call handleMetadataRequest, so this is the body the catch-all reaches past its gates.
    • The per-item publish is a RestServer-owned route (rest-route-ledger.ts, source: 'route-manager'), and the runtime route ledger has no row for it. So the ?package= forwarding question does not arise on the dispatcher. See Acceptance notes.
    • NOT MEASURED: the full dispatch() leg. My probe got 401 UNAUTHENTICATED there even with identity stubbed, and the PUT control got the same 401, so that leg never reached the domain.

Pins: packages/client/src/meta-publish-item-package.test.ts (new file)

A new file rather than beside the twin. The twin's real-door rig is local to meta-delete-item-carriers.test.ts, and importing it from that test file would register every suite of that file a second time. A helper module under src/ would sit in the build program, which excludes only *.test.ts. So the rig is copied here, narrowed to the publish door. It imports exactly the workspace packages the twin imports, so check:test-source-alias's @objectstack/client row is unchanged.

Wire rows (a fake fetch):

  • packageId: 'pkg' sends ?package=pkg on the publish URL, and the body names no package;
  • undefined, '', {} and no options send no query;
  • message still rides the body, with and without a package, and the key is sent once;
  • an id that needs encoding is encoded byte-identically to saveItem and deleteItem;
  • the scoped meta declares no publishItem.

The real door (RestServer over the real ObjectStackProtocolImplementation on SqliteWasmDriver, the twin's rig; the only stub is the auth boundary; rows read straight out of sys_metadata as package:state:label):

  • Two packages, naming none: active rows in com.qa.alpha and com.qa.beta, plus a pending com.qa.alpha draft. Refused RESOURCE_CONFLICT / 409, the rows are unchanged, and no packageId reached the protocol.
  • Two packages, naming one: the same item with { packageId: 'com.qa.alpha', message } succeeds. com.qa.alpha's draft is now its active row, com.qa.beta's row is untouched, and the protocol received packageId and message.
  • The only draft is another package's: an item bound to com.qa.alpha whose only draft is com.qa.beta's. Naming none is refused NO_DRAFT / 404 and the rows are unchanged; { packageId: 'com.qa.beta' } succeeds, and that draft becomes com.qa.beta's active row.

Ablation (one-shot, at 0405b4f4cd)

  • Mutation: node scripts/ablation-replace.mjs replaced the params.set('package', …) line with a marker. On-disk proof: anchor 1 to 0; marker 0 to 1; set-line count 2 to 1 (getDiagnostics keeps its own); blob 6fcd25d to c5a8538.
  • Traps: the tool's own restore trap, plus an outer trap … EXIT INT TERM that restores from HEAD on an absolute path.
  • No rebuild leg: the test imports the client from ./index (source), not through exports.
  • Direction declared before the run: 5 red, 3 green. Observed: 5 failed, 3 passed.
    • Red: the ?package= wire row; the message row (expected '' to be '?package=com.qa.alpha'); the encoding row; two packages naming one (refused RESOURCE_CONFLICT / 409 by the door); the NO_DRAFT row (refused NO_DRAFT / 404).
    • Green: the negative control, the scoped absence, and two packages naming none.
  • Restore: proven by the tool and again by the outer trap. The blob is back to 6fcd25d, equal to HEAD's; git diff HEAD and porcelain are empty.

Verification

All build and test runs went through scripts/pm/os-verify-lock.sh, with NODE_OPTIONS=--max-old-space-size=4096.

At 0405b4f4cd:

  • turbo run build --filter=@objectstack/client^...: 33/33.
  • pnpm --filter @objectstack/client typecheck: exit 0 (tsc --noEmit, plus check:test-typecheck: OK, 0 errors).
  • The new and changed tests by path (meta-publish-item-package.test.ts, client.test.ts): 2 files, 220 passed.
  • The new file alone, verbose: 8/8 passed.
  • The full client suite: 52 files, 671 passed.

main merged in at dbdcf90560 (origin/main a4b87ef556; the incoming overlap is metadata-protocol's protocol.ts, an object-type read filter, not the publish path):

  • turbo run build --filter=@objectstack/client...: 34/34.
  • pnpm --filter @objectstack/spec check:generated: all 14 artifacts up to date.
  • Client typecheck: exit 0.
  • The full client suite: 52 files, 671 passed.

Lint: pnpm lint is narrowed to eslint over the two changed TypeScript files, with three pieces of evidence:

  • eslint --no-inline-config on both files: exit 0;
  • --print-config shows the repo config applies (6 and 5 rules on; the files are not ignored);
  • a positive control via --stdin (a restricted @objectstack/spec root import appended to the new file) exits 1 with no-restricted-imports.

CI's lint-eslint lane runs the full pnpm lint.

Gates (local, at dbdcf90560)

Derivation: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands at dbdcf90560.

Run: all 63, each exit code captured before any pipe. Four roster families whose roster sits in one of this diff's directories ran too: check-changeset-fixed, check:authz-resolver, check:error-code-casing and check:filter-alias-parity.

  • 67 of 67 exit 0.
  • check:type-check-debt first refused with exit 3: its closure build got no lock turn, so nothing was measured. I re-ran it whole under the lock: OK, 26 raw tsc errors, none above the recorded number.

--ran: 63 derived, 63 run, 0 NOT MEASURED, 0 unrun.

Verdict lines:

  • check-adr-0087-registration: no declared-breaking changeset (1 non-breaking seen).
  • check-changeset-no-major, check-empty-changeset: pass.
  • check-changeset-fixed: 72 public packages in sync.
  • check:test-source-alias, check:cross-package-test-inputs: OK.
  • check:published-files: pass.
  • check:dts-closure: 120/120 declaration files present.
  • check:nul-bytes: OK.
  • check:skill-examples: 263 prose examples type-check.
  • check:dual-build-cjs-loads: pass.

At 0405b4f4cd: the same 63 ran, 61 exit 0 and 2 exit 3 (PREREQUISITE NOT MET): check:skill-examples wanted client-react's dist/, and check:dual-build-cjs-loads met unbuilt siblings. Both measured green at dbdcf90560.

Newer main: main moved 2 commits after dbdcf90560, to 8d1fb1fb2d. Those are 22 paths, none in packages/client; the derivation names scripts/check-route-envelope.mjs as changed there. CI runs on the merge ref.

CI-owned, not re-run locally: the path-scheduled jobs (Test Core, Build Core, the dogfood jobs, Temporal Conformance), the type-check lanes, the artifact-roster families and the full pnpm lint. Their check-runs are the verdict.

Acceptance notes

  • The dispatcher serves no per-item publish door. On a host that serves only createHonoApp's dispatcher catch-all, without RestServer, client.meta.publishItem answers 404 ROUTE_NOT_FOUND whatever the package. The runtime route ledger has no DELETE /meta/:type/:name row either, so deleteItem lands on the same tail.
    • It is a located refusal on a transport that does not own the route: route rule 1, one route one owner; rule 3, absence is loud.
    • Not filed: no contract says the dispatcher serves it. Carrier: none.
  • The rig is a copy. The real-door rig here is a copy of the delete twin's (about 90 lines), for the reason under Pins.
  • Refusal prose is not pinned. The real-door rows assert the refusals' code and status, the stored rows and the protocol probe, not the door's sentences, because no consumer parses that prose.

Generated by Claude Code

…tes, sent as ?package=

A per-item publish that names no package is refused 409 RESOURCE_CONFLICT
for an item two or more packages hold, and 404 NO_DRAFT when the item's
one package holds no draft and another package's draft is stored; both
refusals prescribe ?package=. publishItem gains opts.packageId, sent as
?package= with metaSaveQuery's guard: only a non-empty id reaches the
wire. message still rides the body.

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
… real publish door

Wire rows over a fake fetch: packageId reaches the publish URL as
?package=, undefined and '' send nothing, message still rides the body.
Real-door rows over RestServer, the real protocol and SqliteWasmDriver: a
publish naming no package of an item two packages hold is refused 409
RESOURCE_CONFLICT, and naming one promotes that package's draft only; an
item whose only draft is another package's is refused 404 NO_DRAFT, and
naming the draft's package promotes it.

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09dd537193f2a6dd493c8344e251d47bb6ba23bd → packageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 15, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: dbdcf905607586b503ad1e971bb674563ed0fa1b
Local-runs: none

The review is owed by the Clause-②: yes (widening) line alone (the declaration face). There is no packages/spec/src/** path and no governed path (check-governed-merges --pr 23680: NOT governed, 0 of 3 paths), so this seat writes it itself.

It rests on:

Nothing was built or run.

① Derived judgments

  • Public surface: one widening, right. client.meta.publishItem(type, name, opts?)'s inline options type gains packageId?: string.
    • The query is built as metaSaveQuery builds it: URLSearchParams.set('package', …) under a truthy guard. undefined and '' send nothing, and an options-less publish is byte-identical to before.
    • message keeps riding the body.
    • No export, type or other method moves, and the environment-scoped meta gains nothing (hypothesis (a): it declares no publishItem; a wire row pins the absence, with deleteItem as the positive control).
  • The door reads what the SDK now sends, read on main:
  • Pins (packages/client/src/meta-publish-item-package.test.ts, new):
    • Wire rows: ?package= on the publish URL; no query for undefined and ''; message in the body, sent once, beside the package and without it; url-encoding in step with the siblings; the scoped meta's absence.
    • The real door (RestServer over the real protocol on SqliteWasmDriver, client: meta.deleteItem cannot name a package — after #23165 a delete of a name two packages hold answers 409, and the SDK has no way to pass the ?package= remedy #23293's rig copied):
      • an item two packages hold: naming none is refused 409, and naming one promotes that package's draft into that package's row and no other;
      • the only draft another package's: naming none is refused 404 NO_DRAFT, and naming its package promotes it.
    • Ablation: dropping the params.set line turned 5 rows red, as declared (the three wire rows that carry a package, and both named real-door rows). The negative control, the scoped absence and "naming none" stayed green. The restore was proven twice.
    • Copying the rig (about 90 lines) rather than importing it is justified in the report: the twin's rig is local to a test file, and importing it would re-register that file's suites.

② Semver level

③ Boundary flags

  • Consumers: SDK callers, including the CLI and AI authors, publishing a multi-package item. No caller in this repo passes the option yet, and none is owed.
  • Open questions: none.
  • Out-of-scope finding, 「承接者:无」 (in the PR's Acceptance notes): the dispatcher catch-all (createHonoApp) serves no POST /meta/:type/:name/publish and no DELETE /meta/:type/:name. The dev measured 404 ROUTE_NOT_FOUND, protocol not called; the routes are RestServer-owned. A host composing the dispatcher without RestServer answers a located 404 to both SDK methods whatever the package. That is route rule 1 (one owner) and rule 3 (loud absence), with no deployment named, so nothing is filed.
  • Deviations:
    • a third real-door row (NO_DRAFT), inside the claimed file;
    • lint was narrowed with three pieces of evidence (CI's Lint · ESLint runs the full set);
    • origin/main a4b87ef556 was merged before the PR opened, with no overlap on the publish path;
    • the model-free commit trailers per AGENTS.md.

Implemented-by: claude/issue-23658-client-publish-package
Reviewed-by: session_01Q9PZ5Pzqcus4woVvTZwWNU

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

✅ ACCEPT: PR #23680 at dbdcf90560 (#23658), round 0. Every check on this head is green or a roster skip. The TIER_DEFAULT record is 6193948392 (PASS) · 2026-10-15T14:02Z

domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6191624811. Report: the os-dev-report 6193545767 on #23658.

Checklist, read against GitHub and origin/main, not against the report:

  • PR shape: draft, base main, title feat(client):, assignee sunhaolin. Line 1 is Fixes #23658, the body's only closing keyword. Line 2 is Clause-②: yes (widening).
  • Scope: 3 files, +473 / −2, exactly the claim's surface: meta.publishItem and its docblock in packages/client/src/index.ts, one new pin file, and one changeset.
    • ⛔ Not touched: metadata-protocol, packages/rest, packages/runtime, saveItem and deleteItem.
    • check-governed-merges --pr 23680: NOT governed. git merge-tree is clean against origin/main 09dd537193.
  • The fix, the pins and the semver level are judged in the record 6193948392.
  • Hypotheses:
    • (a) holds: one declaration; the scoped meta has no publishItem, and none is added.
    • (b), measured: the REST door reads ?package= through metaItemPackageBinding after refuseRepeatedQueryParams. The dispatcher serves no per-item publish route at all, so there is no forwarding gap to fix (out of scope, not filed).
  • Gates:
    • Local, at dbdcf90560: 63 derived, 63 run, 0 unmeasured, all exit 0, plus 4 roster families, exit 0. The client suite: 52 files, 671 passed. The client typecheck exits 0.
    • Gate evidence: the check-runs pinned to dbdcf90560, read at this stamp. 40 distinct checks: 34 success, including Lint · ESLint, the Type Check family, all six Test Core shards, Build Core and Check Changeset, and 6 skipped. All 6 skips are roster names: Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix), Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance. Nothing is running and nothing failed.

Accepted deviations:

  • A third real-door row (404 NO_DRAFT) beyond the card's two, in the claimed file. The docblock names both refusals, so both are pinned.
  • The rig is copied, not imported (the twin's rig is file-local).
  • Lint was narrowed with its three pieces of evidence.
  • origin/main a4b87ef556 was merged once before the PR opened, with no overlap on the publish path.

Out-of-scope, Acceptance notes, 「承接者:无」: the dispatcher catch-all serves no POST /meta/:type/:name/publish and no DELETE /meta/:type/:name (404 ROUTE_NOT_FOUND; RestServer-owned routes). No deployment composing the dispatcher without RestServer is named, so nothing is filed.

Landing: pr_ready + automerge_enable through the relay, in this act.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants