Skip to content

fix(runtime)!: the publish-drafts door refuses a body that is not a JSON object 400 VALIDATION_ERROR (#28541) - #28593

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-i4gkt8
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-i4gkt8

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28541

Clause-②: no (narrowing) — a present body that is not a JSON object (an array, a string, a number, a boolean), which POST /packages/:id/publish-drafts answered 200 with the default note, is refused 400 VALIDATION_ERROR before anything is published. Nothing is widened.
Metadata-protocol: none
Skills-docs: none — no page under skills/** or content/docs describes what this door does with a body that is not an object; the generated reference already declares the body an object from the schema, which does not move.

BREAKING accept-set narrowing at the publish-drafts door (@objectstack/runtime).

维护者速读

  • 改了什么:「整包发布草稿」接口(POST /packages/:id/publish-drafts)收到一个存在但不是 JSON 对象的请求体(数组、字符串、数字、布尔)时,直接 400 VALIDATION_ERROR 拒收,用的是规范 PublishPackageDraftsRequestSchema 自己的整体报错,什么都不发布。
  • 为什么改:卡 packages: the publish-drafts door publishes with the default note when the whole body is not an object (array, string, number) #28541(PR fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) #28527 合约评审的遗留项):这种请求体里读不到任何成员,以前会照常发布全部草稿并写默认说明,调用方不知道自己的请求体被忽略了。
  • 风险与回滚:只收窄这一类请求体。没有请求体、空请求体、JSON null 仍按「没有请求体」照常 200;对象请求体的行为一字不变(字符串说明、非字符串 message 的拒收、旧 SDK 的 actor、坏 backfill 的唯一拒收)。回滚即 revert。
  • 先看:packages/runtime/src/domains/packages.ts(publish-drafts 分支里 declaredMessage 之前新加的整体校验);packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts(真实 SQLite 上的新用例);.changeset/28541-publish-drafts-non-object-body-refused.md。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-publish-drafts-request-conformance.test.ts src/domains/packages-publish-drafts-change-note.integration.test.ts → 15/15 通过。
  • 需要你做的:无,席位落地(收窄需先过合约评审)。

What changes

The door (packages/runtime/src/domains/packages.ts, the publish-drafts branch). Right before #28527's message parse (after the 404 for an unknown package), a body that is present (!== undefined, !== null) and is not a plain object (typeof !== 'object' or an array) is parsed with PublishPackageDraftsRequestSchema as a whole, and its issue is answered with deps.error(text, 400): the envelope the message refusal and the install door's schema refusals already use (status 400 derives VALIDATION_ERROR; no code is minted). The text names the path the install door's way, (body) for the root: Invalid publish-drafts body — (body): Invalid input: expected object, received array. Nothing was published: ….

The whole-body parse runs only for a non-object body. An object body never reaches it, so backfill keeps its one refusal (the protocol's) and a non-string message keeps #28527's sentence.

What stays exactly as it was, by measurement:

Pins.

  • packages-publish-drafts-request-conformance.test.ts: a new case over [], ['message'], 'x', '', 42, 0, true, false. Each: the schema refuses it, the door answers 400 VALIDATION_ERROR with a headline starting Invalid publish-drafts body — (body): , and neither publishPackageDrafts nor the app re-save is called. The suite header gains the whole-body sentence.
  • packages-publish-drafts-change-note.integration.test.ts (real door, real protocol, ObjectQL, on-disk SQLite): [], 'x', 42 are each refused 400 VALIDATION_ERROR; no publish history row and no package commit exist afterwards; the same draft then publishes with an object body and its row carries the note.
  • route-ledger.ts: the publish-drafts row note now says a present non-object body is refused and an absent one publishes with no note.

Tests

  • Red first (the two new cases run against the base door: packages.ts checked out from origin/main with the new tests in place, then restored from HEAD): both new cases red, []: expected 200 to be 400; 13 other cases green.
  • Green at the fix: the two suites and the route-ledger tests, 23/23.
  • Ablation (node scripts/ablation-replace.mjs, anchor if (body !== undefined && body !== null && (typeof body !== 'object' || Array.isArray(body))) { → if (false && …) {): anchor 1 → 0, blob 1f8629c8d362 → 4fe1e2242c0e; both new cases red, 13 controls green (string note, no body, actor stripped, undeclared members, non-string message refusal, bad backfill real-protocol refusal, actor on the app re-save); restored blob equals HEAD, git diff HEAD empty. The runtime tests import the door from src/, so no build sits between the mutation and the run.
  • pnpm --filter @objectstack/runtime typecheck exit 0 (with check:test-typecheck OK); pnpm --filter @objectstack/runtime test: Test Files 500 passed (500), Tests 8141 passed | 19 skipped (8160).

Acceptance notes

  • Booleans. The ruling names an array, a string or a number; true and false are also present non-object bodies the schema refuses, so they are refused too and pinned.
  • JSON null. Kept as "no body", as the order says, although the schema itself refuses null. The transport hands it on as null, and the existing pin publishes it.
  • Spec docblock. PublishPackageDraftsRequestSchema's docblock says "an absent body reads as {}" and describes the two members; nothing in it becomes false, so packages/spec is not touched.
  • Merge. PR feat(metadata-protocol): ensure door for a read-only base's paired customization package #28512 landed on packages.ts and route-ledger.ts after the branch point; origin/main was merged in (clean, no hunk shared) and every reading below is on the merged head.
  • Changeset. It narrows the sentence "no body, or no message, publishes with the default note" in the pending 28452-publish-drafts-message-refused changeset by a superseding sentence; that file is not edited.

Verification

All readings on head 5cb5e1b8 (the merge of origin/main at c6583207; nothing was committed after the union ran). origin/main moved 7 commits since, none touching the five files of this diff.

Builds and lint
  • pnpm build --concurrency=2 (full workspace, under the verify lock) on the merged head: VERDICT command-exit 0, Tasks 117 successful, 117 total.
  • eslint packages/runtime --no-inline-config (the pnpm lint command scoped to the touched package): exit 0.
Derived gates: 63 run, 63 exit 0

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands (the same list before and after the merge); each ran once with stdin closed, exit code captured before any pipe. --ran: ✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED. One first read was NOT MEASURED, not red: check:type-check-debt exit 3, its --re-measure closure build queued behind this PR's own dogfood run on the verify lock (VERDICT queue-timeout (exit 99)); re-run under the lock afterwards: exit 0, 126/129 workspace packages type-checked … 0 in the DEBT ledger. check-plugin-teardown-shape.mjs --self-test ran after git fetch --depth=1 origin 621a4876….

Hand gates: check-platform-checklist.mjs --changed-from origin/main exit 0; check-commit-card-trailers.mjs --range origin/main..HEAD exit 0.

Dogfood battery

Once, on the final head 5cb5e1b8, after the full build: bash scripts/pm/os-verify-lock.sh -c "pnpm --filter @objectstack/dogfood test" (no shard, no path).

  • os-verify-lock: VERDICT command-exit 0 · held the lock 1408s (23m28s) · waited 0s
  • Test Files 363 passed | 3 skipped (366)
  • Tests 2932 passed | 28 skipped (2960)
  • No failures.

Generated by Claude Code

@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/system-context.mdx (via handlePackagesRequest (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 213 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 98 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 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 cd065d3698db13969a28f46bf26f3e47e9ecc09c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4ed0b4d348b51cc19f60d829181a5d7a57af1b7a — the merge of head 5cb5e1b8e0f11aafb5937f68d4b322f9cbc5326d into base cd065d3698db13969a28f46bf26f3e47e9ecc09c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4ed0b4d348b51cc19f60d829181a5d7a57af1b7a && git checkout 4ed0b4d348b51cc19f60d829181a5d7a57af1b7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cd065d3698db13969a28f46bf26f3e47e9ecc09c 5cb5e1b8e0f11aafb5937f68d4b322f9cbc5326d && git checkout -B drift-repro cd065d3698db13969a28f46bf26f3e47e9ecc09c && git merge --no-ff 5cb5e1b8e0f11aafb5937f68d4b322f9cbc5326d

node scripts/docs-audit/affected-docs.mjs --json cd065d3698db13969a28f46bf26f3e47e9ecc09c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs cd065d3698db13969a28f46bf26f3e47e9ecc09c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5cb5e1b8e0f11aafb5937f68d4b322f9cbc5326d
Local-runs: none
Scope: packages/runtime/src/domains/packages.ts, packages/runtime/src/route-ledger.ts, packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts, packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts + packages/runtime/src/http-dispatcher.ts (error(), handlePackages), packages/runtime/src/error-envelope.ts (buildApiError), packages/plugins/plugin-hono-server/src/request-body.ts (readRequestBody), packages/adapters/hono/src/index.ts (the twin body read), packages/spec/src/api/protocol.zod.ts (PublishPackageDraftsRequestSchema docblock, on main), packages/client/src/index.ts (packages.publishDrafts), ui/packages/data-objectstack/src/metadata-client.ts (publishPackageDrafts), .changeset/28452-publish-drafts-message-refused.md (on main), scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs

Face: the narrowing face alone. reviewFace over the five committed paths returns no hits (no published-schema source and no governed path; Governed Surface Queue Guard is success on this head) and hands the decision to the changeset's Clause-②: line, which carries the (narrowing) arm on both carriers, so the record is at CONTRACT_REVIEW_TIER with the narrowing floor: every diff path outside the prose face. The one prose-face path, .changeset/28541-publish-drafts-non-object-body-refused.md, is read for ② below. PR #28593 is draft, author objectstack-fleet[bot], three commits on claude/project-thread-i4gkt8 over base c658320792 plus a merge of origin/main at that same commit; five files, +88/−1. Read against card #28541 and its claim 6554240344, the seat's order, the dev report 6554870967, merged PR #28527 and its review 6553704063 (③ residual), and merged PR #28447. Nothing was built, run or re-run locally; the record template was printed from scripts/pm/record-recognisers.mjs --template --face narrowing, which reads no board.

① Derived judgments

Accept-set change at POST /packages/:id/publish-drafts, derived from the one code hunk in packages/runtime/src/domains/packages.ts (the publish-drafts branch, placed after the 404 and before #28527's message parse):

  1. What moves from accept to refuse: exactly the bodies that are present and are not a plain object. The guard is body !== undefined && body !== null && (typeof body !== 'object' || Array.isArray(body)), and only a body it admits is handed to PublishPackageDraftsRequestSchema.safeParse as a whole; the schema is z.object(...) under zod 4, so every value the guard admits fails the parse. Moved from 200 (published, default note) to 400: an array ([], ['message']), a string ('x', ''), a number (42, 0), a boolean (true, false). The suite pins all eight. Judged correct and exactly the order's set, with booleans included (③ below).
  2. The boundary cases, each measured from the code. {} stays 200 with no note (the accepted-bodies pin still carries it, the guard admits it and nothing in it is read). An absent body: in-process undefined, admitted; on the wire, readRequestBody answers {} for a request with no body and for an empty or whitespace-only application/json body (if (text.trim() === '') return { body: {} }), and the twin read in packages/adapters/hono/src/index.ts does the same, so an absent or empty body never reaches the guard as anything but {} or undefined. JSON null reaches the handler as null (JSON.parse('null')), the guard admits it and the pre-existing pin "an absent body reads as {}" ([undefined, null]) still passes. An empty-string body as JSON ("") is a string and is refused; a raw empty body is {} and is not. A non-JSON media type with a body never reaches the door: the transport refuses it 400 INVALID_REQUEST at the read, unchanged. Judged correct and consistent with the order's "keep an absent body, an empty body and a JSON null as no body".
  3. Envelope identity with the siblings; no second envelope. The leg answers deps.error(text, 400) with no explicit code, exactly as the message leg beneath it and the install door's manifest.id, manifest.version and whole-body legs do; HttpDispatcher.error() hands it to apiErrorResponse, whose buildApiError derives the code as input.code ?? promoted ?? standardErrorCodeForHttpStatus(400), so VALIDATION_ERROR is derived and nothing in packages/spec moves. The headline is spelled the install door's way, the issue path or (body) for the root (Invalid publish-drafts body — (body): Invalid input: expected object, received array), and ends in the prescription (send a JSON object, or no body). Judged correct.
  4. Nothing is published and no history row is written on refusal. The leg returns before resolvePackageWriteActor, before protocol.publishPackageDrafts, before the ADR-0045 re-save loop. Pinned at two layers: over a protocol double (publishPackageDrafts and saveMetaItem never called, eight bodies) and over the real protocol on on-disk SQLite ([], 'x', 42: no publish row in sys_metadata_history, no row in sys_metadata_commit, and the same pending draft then publishes with an object body carrying its note). Judged correct.
  5. Controls unchanged. An object body never enters the new if, so PublishPackageDraftsRequestSchema.safeParse as a whole is never run on one: backfill keeps its one refusal (the protocol's, forwarded ...(body?.backfill !== undefined ? ...) exactly as before; the real-protocol case with five malformed shapes and publish called once is unchanged and green), a non-string message keeps fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) #28527's sentence (message: Invalid input: expected string, received number; the five-body pin is unchanged), { actor: 'x', message: 'note' } still answers 200 with the caller recorded and actor reaches neither the publish nor the ADR-0045 re-save (both pins unchanged, runtime: the package doors record whatever actor the request body names — publish, save, discard, revert, rollback, reassign and duplicate write a caller-chosen name into metadata history #28414 untouched). The 403, walled, closed-query, 501 and 404 legs precede the new leg and are untouched. Judged correct.
  6. The merge of main is clean and alters none of feat(metadata-protocol): ensure door for a read-only base's paired customization package #28512's hunks. 0ecc2c494 (PR feat(metadata-protocol): ensure door for a read-only base's paired customization package #28512) is an ancestor of c658320792, the merge's second parent, which is also the merge-base with current main; git diff c658320792 5cb5e1b8e on packages.ts is the 26 inserted lines of this leg and nothing else, and on route-ledger.ts the one row-note line. The ADR-0146 D3 import, the ensureCustomizationPackage protocol member, the install door's pair refusal and the ensure-customization door arrive through the merge byte-identical to main. Judged correct.
  7. Pins and ledger. packages-publish-drafts-request-conformance.test.ts: the header gains the whole-body sentence (present non-object refused with the schema's own issue; undefined and null a publish with no note) and a new case over the eight bodies asserting the schema refuses each, status, code, headline prefix Invalid publish-drafts body — (body): , and no producer call. packages-publish-drafts-change-note.integration.test.ts: the real-SQLite case above. route-ledger.ts: only the publish-drafts row's note string changes (a present non-object body is refused 400 with the schema's whole-body issue, an absent one publishes with no note); requestSchema, responseSchema, disposition, client and every other field are unchanged, so the client's ledger resolver and the ledger tests are unaffected. Judged correct.
  8. Public surface. No export, type or describe text moves anywhere; packages/spec is not in the diff, so no api-surface, authorable-surface, json-schema.manifest or declaration-map shard is owed. The .d.ts bytes of @objectstack/spec are unchanged. Judged correct.
  9. First-party callers never send a non-object. packages/client/src/index.ts packages.publishDrafts sends JSON.stringify(opts ?? {}), an object always; ui/packages/data-objectstack/src/metadata-client.ts publishPackageDrafts sends an object literal built from backfill and message only, with Content-Type: application/json. Every ui/ call site reached (StudioDesignSurface, PackagesPage, usePublishAllDrafts, PendingDraftsBar, AiChatPage) goes through that method; plugin-chatbot reads the response (publishHealth) and fetches nothing itself; packages/cli, skills/** and content/docs/** contain no caller of this door. No first-party caller breaks.

② Semver level

  • Clause-②: no (narrowing) is the correct arm, same spelling in the PR body and the changeset, arm closed before the em-dash reasoning so readClause2Line takes the arm: nothing is widened (no key, value or export added, no body previously refused is now accepted) and a body answered 200 is now refused. Matches the order and the claim.
  • Metadata-protocol: none is correct: the narrowing is on a REST request body, not on anything an author writes in metadata. Under check-changeset-no-major.mjs's level axis, none must grade every introduced entry patch and may move no authorable-surface/ or authorable-defaults/ baseline: the one entry is '@objectstack/runtime': patch and no baseline is in the diff. Consistent.
  • Level and carriers. @objectstack/runtime: patch only, which is right: packages/spec is untouched, so no @objectstack/spec line is owed (PR fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) #28527 carried one because its docblock changed; this diff changes no spec file). The changeset carries fix(runtime)!:, the **BREAKING** accept-set narrowing banner and exactly one marker, adr-0087: not-required (no-migration-prescription), the category check-adr-0087-registration.mjs admits when the body carries no FROM-to-TO consumer migration; the body carries none (nothing was ever stored for a refused body, and the one-line "send a JSON object, or no body" is the refusal's prescription to a caller, not a rewrite block). check-empty-changeset.mjs: non-empty frontmatter, and the diff adds its own changeset without editing the pending 28452-publish-drafts-message-refused.md (a foreign changeset it may not touch); it instead names and supersedes that changeset's sentence "no body, or no message, publishes with the default note", the same device the fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) #28527 review accepted for Studio: publishing takes a change note and item history names the person — the note reaches publish-drafts, the actor is the session user, and it is shown by name, not id #28406's changeset. Check Changeset is success on this head. Judged consistent.

③ Boundary flags

  • Dev deviation 2, booleans refused. The ruling says a present body "that is not a JSON object (an array, a string or a number)"; the parenthesis is a list of examples of the class, not the class, and true / false are present bodies that are not JSON objects by plain reading, which the schema refuses the same way. Refusing them is inside the ruling, not beyond it; leaving them at 200 would have kept a declared-not-enforced value on the same door. Accepted, and pinned.
  • Dev deviation 3, JSON null kept as "no body". The order names a JSON null among the three shapes to keep as "no body", so this is the ruling applied, not a deviation. Measured from code: the transport hands it on as null (JSON.parse('null')), the guard's body !== null admits it, and the pre-existing pin treats [undefined, null] as absent. One residual is recorded for the seat, not as a must-fix: PublishPackageDraftsRequestSchema.safeParse(null) fails, so a JSON null body is the one declared-refused value the door still answers 200 on; it is by the seat's own ruling, the PR body states it plainly, and it is for the seat to carry or close (Prime Directive 10), not for this PR to widen.
  • Dev deviation 5, no spec docblock change. The PublishPackageDraftsRequestSchema docblock on main says the door reads exactly two body members and hands them to publishPackageDrafts, describes message (parsed with the member, refused 400 naming it) and backfill (judged by the protocol), says unknown keys are stripped, and says "the body is optional on the wire; an absent body reads as {}". Every sentence stays true on this head: the members are read as described for an object body, an absent body still reads as {}, and the body being a JSON object is what z.object itself declares. Nothing misdescribes the door; the docblock is silent on the whole-body refusal rather than wrong about it. Not touching packages/spec is correct (it would also have raised the schema face for a sentence nothing requires). No must-fix.
  • Dev deviation 1, red-first measured after the fix commit by checking packages.ts out from origin/main under the new tests and restoring from HEAD (restore verified by an empty git diff HEAD). The measurement is the same measurement in a different order; accepted. Deviation 4 (commit trailers reworded before push) is process, not contract.
  • open_questions: [], out_of_scope_findings: [] in the report. Accepted.
  • Evidence read in the dev report. Red-first: both new cases red on the base door ([]: expected 200 to be 400), 13 controls green. Green at the fix: 23/23 across the two suites and the route-ledger tests. Ablation through scripts/ablation-replace.mjs on the anchor if (body !== undefined && body !== null && (typeof body !== 'object' || Array.isArray(body))) { to if (false && ...): anchor 1 to 0, blob changed, both new cases red, 13 controls green (string note, no body, actor stripped, undeclared members, non-string message refusal, real-protocol backfill refusal, actor on the re-save), restore blob equals head and git diff HEAD empty; the runtime tests import the door from src/, so no build sits between mutation and run. Transport probe (scratch, deleted): no body, no body as JSON, empty JSON body reach the handler as {}; null as null; [], "x", 42 as array, string, number; this matches readRequestBody as read above. Runtime typecheck exit 0; runtime suite 500 files, 8141 passed, 19 skipped; dogfood battery 363 files, 2932 passed, 28 skipped, no failure; 63 derived gate families run and reconciled with --ran. The ablation restores the exact prior behaviour and reds only the two new cases, so the evidence proves the mechanism, not the harness. Accepted.
  • Head check-runs at this read. Completed success: Governed Surface Queue Guard, Check Changeset, PR-body guards, Check PR Size, Dogfood Regression Gate, Build Core, the four Type Check lanes, TypeScript Type Check, Lint · ESLint, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · PM dispatch-gates, the three scoped lint lanes, Test UI, Live E2E UI. Still in_progress: the eight Test Core shards and Lint · docs & code guards. This record is the contract-tier verdict; the landing pre-check that every check is green on this head is the seat's, read before the PR enters the queue.
  • Docs drift check names content/docs/permissions/system-context.mdx through handlePackagesRequest; that page's row 55b describes the walled-catalog refusal at the /packages write doors, which still precedes the new leg, so the page stays accurate. skills/objectstack-platform/references/plugin-hooks.md names the door only as the source of metadata:reloaded, unaffected.
  • Author. user.login is objectstack-fleet[bot], so no off-relay author note is owed. The three branch commits carry no model identifier in title or body; none appears in the PR title, body, changeset or code comments.

④ Design judgment

  • Narrowing face, against the published spec on main. The narrowing takes no declared capability away at the consumer: PublishPackageDraftsRequestSchema on main is z.object(...), so a caller that honoured the declaration never sent an array, a string, a number or a boolean as the body, and neither first-party caller can. What the diff does is make the declaration's own refusal real at the door (Prime Directive 12: refuse off-spec input at the producer-facing door), closing the declared-not-enforced remainder the fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) #28527 review named under ③. The previous behaviour, publishing every pending draft of the package with the default note while the caller's body was silently ignored, is exactly the silent-tolerance class the four-axis framework's third axis forbids. Judged to hold.
  • The mechanism: a guard, then the schema as a whole, only for a non-object. Parsing the whole body for every request would have made the door a second judge of backfill with a second sentence and no issues[], displacing the protocol's one refusal, which the fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) #28527 review already found to be the right thing to keep. Gating the whole-body parse on "not an object" means an object body never meets it, so both halves of the ruling hold at once: the schema's own sentence for the non-object class, one refusal for backfill, fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) #28527's sentence for message. The refusal reuses the install door's issue-locating spelling (path || '(body)') and the sibling envelope rather than a restated rule; the file's body-validating doors keep one pattern. The guard admitting null is a deliberate reading of the ruling, recorded under ③. Judged the correct trade.
  • Scenario the card is accepted against. [] (and "x", 42) through the real door over the real protocol on SQLite: base 200 with the default note and a publish history row per draft; head 400 VALIDATION_ERROR with the whole-body issue, no history row, no commit row, the draft still pending, and the next object-body request publishes it with its note. Controls for every "unchanged" claim in the PR body ({}, no body, null, { message }, { actor }, bad backfill, non-string message) are pinned in the same suites and are green unchanged. The acceptance scenario holds.
  • No must-fix found on the face.

Implemented-by: claude/project-thread-i4gkt8
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-11-01T07:34Z · head 5cb5e1b8 · #28541

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main with no assignees, Fixes #28541, a patch changeset for @objectstack/runtime, and a title carrying ! for the narrowing.
    • check-governed-merges --pr 28593 reads NOT governed, 89 changed lines. Landing tier S.
    • The claim (6554240344) names this PR's branch, claude/project-thread-i4gkt8.
  2. Against the order:
  3. Contract review: owed for the narrowing. PASS at CONTRACT_REVIEW_TIER on this head, comment 6554944939. Booleans are inside the ruling, and the spec docblock is still true. The residual the review names (JSON null is refused by the schema but accepted by the door) carries over from fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) #28527's review and is not this PR's change.
  4. Gates: CI on head 5cb5e1b8 shows 35 success, 13 skipped and 0 red. The state is clean.
  5. Overlap: no conflict against main cd065d36. feat(metadata-protocol): ensure door for a read-only base's paired customization package #28512's hunks in packages.ts are byte-identical after the merge.
  6. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 1, 2026 07:35
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 1, 2026 07:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit d94dd3a Nov 1, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-i4gkt8 branch November 1, 2026 07:52
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/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

packages: the publish-drafts door publishes with the default note when the whole body is not an object (array, string, number)

1 participant