Skip to content

Commit 12333ba

Browse files
fix(runtime)!: the publish-drafts door refuses a non-string change note 400 VALIDATION_ERROR naming message (#28452) (#28527)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XAZVBCz7kire8DBAJx1hUc4)_ Fixes #28452 Clause-②: no (narrowing) — a body whose `message` is not a string (a number, an object, an array, `null`), 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 the door does with a non-string note; the generated reference (`content/docs/references/api/protocol.mdx`) already declares `message` a string from the schema's `describe`, which does not move. **BREAKING** accept-set narrowing at the publish-drafts door (`@objectstack/runtime`). ## 维护者速读 - **改了什么**:「整包发布草稿」接口(`POST /packages/:id/publish-drafts`)现在用规范里已声明的 `PublishPackageDraftsRequestSchema` 的 `message` 成员校验变更说明;不是字符串(数字、对象、数组、null)时直接 400 `VALIDATION_ERROR` 拒收并点名 `message`,什么都不发布。另翻转 #28447 留下的「已知分歧」测试针、路由台账那行的说明和 spec 注释。 - **为什么改**:卡 #28452(分诊 6547072197):以前非字符串的说明被悄悄丢掉,发布照常进行、每条历史记录写默认说明,调用方根本不知道自己的说明被忽略了。 - **风险与回滚**:只收窄这一种请求体;字符串说明(包括空串)、不带说明、带旧 SDK 的 `actor` 都照旧 200;坏的 `backfill` 仍由协议层拒一次,答复与以前完全一样。仓内没有任何调用方发送非字符串说明(SDK 与 UI 只在给了字符串时才带 `message`)。回滚即 revert。 - **先看**:`packages/runtime/src/domains/packages.ts`(publish-drafts 分支里新加的 `declaredMessage` 校验与转发行);`packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts`(真实 SQLite 上的新用例);`.changeset/28452-publish-drafts-message-refused.md`。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-publish-drafts-change-note.integration.test.ts src/domains/packages-publish-drafts-request-conformance.test.ts` → 13/13 通过。 - **需要你做的**:无,席位落地(收窄需先过合约评审)。 ## What changes **The door** (`packages/runtime/src/domains/packages.ts`, the `publish-drafts` branch). After the 404 for an unknown package and before the publish, the door parses `body.message` with `PublishPackageDraftsRequestSchema.shape.message` (the same member-of-the-declaration read the install door does with `ManifestSchema.shape.id` / `.version`). A note the member refuses is answered with `deps.error(text, 400)`, the envelope the install door's schema refusals already use (status 400 derives `VALIDATION_ERROR`; no code is minted). The text names the path: `Invalid publish-drafts body — message: Invalid input: expected string, received number. Nothing was published: …`. The forwarded note is now the parsed value. **What stays exactly as it was**, by measurement: - `backfill` is not judged at the door. Parsing the whole body here would refuse a bad `backfill` a second time with a different sentence and without the protocol's `issues[]`, so the protocol's own shape judgement stays the one refusal (same status, code, headline and `issues[]`; the existing real-protocol case in the conformance suite still passes with `publishPackageDrafts` called once). - Unknown keys are not read: `{ actor: 'x', message: 'n' }` answers 200 and records the caller (SDK's deprecated `opts.actor`). - A body that is not an object (or absent) has no `message` and publishes with the default note, as before. Only the `message` member narrows. - ⛔ No `actor` handling changed (#28414 is closed; the card's Not-here line). **Pins and prose flipped.** - `packages-publish-drafts-request-conformance.test.ts`: the known-divergence case ("dropped by the door, not refused") becomes a refusal case over `{message: 42}`, `{message: {}}`, `{message: []}`, `{message: null}` and `{actor: 'x', message: 7}`: 400, `VALIDATION_ERROR`, headline names `message`, `publishPackageDrafts` and the app re-save never called. Its header's `message` bullet and the "does not claim the door parses the body" sentence are updated. - `packages-publish-drafts-change-note.integration.test.ts` (real door + real protocol + ObjectQL + on-disk SQLite): `42`, `{}`, `[]` are each refused 400 `VALIDATION_ERROR`; no `publish` history row and no package commit exist afterwards; the same draft then publishes with a string note and that row carries it. - `route-ledger.ts`: the publish-drafts row note now says `message` is parsed with the schema's member and a non-string is refused 400. - `packages/spec/src/api/protocol.zod.ts`: the `PublishPackageDraftsRequestSchema` docblock said "`message` — forwarded only when it is a string"; this diff makes that false, so it now says the door refuses a note the member refuses. JSDoc only: no `describe`, key or type moves, so the generated reference and the authorable surface do not move. - The changeset carries a sentence superseding "Nothing changes on the wire" in the pending `28406-publish-drafts-request-schema.md` changeset (not edited: it is not this PR's). ## Tests - **Red first** (the two new cases written and run against the unchanged base door, before the fix was written): both new cases red with `{"message":42}: expected 200 to be 400`; 11 other cases green. - **Green** at the fix: the two files 13/13. - **Ablation** (`node scripts/ablation-replace.mjs`, anchor `if (!declaredMessage.success) {` → `if (false && !declaredMessage.success) {`, which restores the string-only forward since a failed parse forwards no `data`): anchor 1 → 0, blob `ba0e3795ad83` → `5906236be414`; both refusal cases red, 11 controls (string note, no body, `actor` stripped, undeclared members, bad `backfill` real-protocol refusal) green; restore 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; `pnpm --filter @objectstack/runtime test`: Test Files 498 passed (498), Tests 8085 passed | 19 skipped (8104). ## Acceptance notes - **Clause-② arm.** The order and the claim expected `yes (narrowing)`. `scripts/pm/clause2-line.mjs` reads `yes (narrowing)` as "widens one surface and narrows another"; this diff widens nothing, so the measured line is `no (narrowing)`, with the `**BREAKING**` banner and the ADR-0087 `not-required (no-migration-prescription)` marker in the changeset. - **File surface beyond the claim.** `packages/spec/src/api/protocol.zod.ts` (the docblock sentence this diff made false) and an `@objectstack/spec` patch line in the changeset. The claim's file surface did not list them; the seat may amend it. - **Placement.** The refusal sits after the 404 and the 501 capability probe, beside the protocol's backfill judgement, so both body-member refusals answer at one point of the door's order (the install door orders its body legs the same way relative to its id gate). ## Verification All readings on head `af581fe0` (the only commit; nothing landed after the union ran). - **Derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 84 families, each run with its exit code captured before any pipe; `--ran` reconciliation: `✓ dispatch-gates --ran: 84 derived famil(ies) accounted for — 84 run, 0 NOT-MEASURED`. Two first reads were container prerequisites, re-read after meeting them: `check-plugin-teardown-shape.mjs --self-test` exit 3 → `git fetch --depth=1 origin 621a487…` → exit 0 (48 cases pass); `check:dual-build-cjs-loads` exit 3 → full `pnpm build --concurrency=2` (116/116 tasks) → exit 0. - **Hand gates:** `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0; `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD` exit 0; `pnpm exec eslint --no-inline-config` over the five touched source files exit 0 (neither `@objectstack/runtime` nor `@objectstack/spec` has a `lint` script; the repo-wide `pnpm lint` is CI's). - **Full dogfood** (owed by the `!` and the `(narrowing)` arm): after the full build, under the lock, `pnpm --filter @objectstack/dogfood test` → Test Files 363 passed | 3 skipped (366), Tests 2932 passed | 28 skipped (2960); no failure. - **CI** families (Test Core, lint farm, type-check lanes) are read off this PR's check-runs by the seat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kzB44oaLQ7JxbMKk9 --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kzB44oaLQ7JxbMKk9)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c0a97b commit 12333ba

6 files changed

Lines changed: 99 additions & 17 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(runtime)!: `POST /packages/:id/publish-drafts` refuses a change note that is not a string with `400 VALIDATION_ERROR` naming `message`, instead of publishing with the default note (#28452)
7+
8+
Clause-②: no (narrowing) — a body whose `message` is not a string (a number, an object, an array, `null`), which the door answered `200` with the default note, is refused `400 VALIDATION_ERROR` before anything is published. Nothing is widened.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the door now enforces the `message?: string` that PublishPackageDraftsRequestSchema already declares. Nothing was stored for the refused bodies (the note was dropped), so there is nothing for a migration to rewrite. -->
12+
13+
**BREAKING** accept-set narrowing at the publish-drafts door (`@objectstack/runtime`).
14+
15+
The door parses `message` with `PublishPackageDraftsRequestSchema`'s own member. A note the declaration refuses used to be dropped silently: the publish went ahead, every history row recorded the sentence naming the package, and the caller never learnt its note was ignored. Now the request is refused, nothing is published and no history row is written, and the same drafts publish once the note is sent as a string.
16+
17+
Unchanged: a string note (empty too) publishes with that note; no body, or no `message`, publishes with the default note; unknown keys are still ignored, so `@objectstack/client`'s `packages.publishDrafts` still works when it sends its deprecated `opts.actor`; a malformed `backfill` is still refused once, by the protocol's own judgement, with the same answer as before.
18+
19+
This supersedes the sentence "Nothing changes on the wire: the door reads the body as it did before this declaration" in the pending `@objectstack/spec` changeset for `PublishPackageDraftsRequestSchema`: from this release the door refuses a non-string `message`. The `@objectstack/spec` patch is that declaration's docblock, which now says so.

‎packages/runtime/src/domains/packages-publish-drafts-change-note.integration.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,26 @@ describe('publish-drafts records the change note and the session user (ADR-0146
160160
expect(commit?.actor).toBe('u_alice');
161161
});
162162

163+
it('a note that is not a string is refused 400 VALIDATION_ERROR naming `message`: nothing is published and no history row is written', async () => {
164+
const env = await boot();
165+
await draftViews(env.protocol, ['notes_open']);
166+
167+
for (const message of [42, {}, []]) {
168+
const body = { message };
169+
const result = await env.dispatcher.handlePackages(`/${PKG}/publish-drafts`, 'POST', body, {}, caller('u_alice'));
170+
const response: any = result.response;
171+
expect(response?.status, JSON.stringify(body)).toBe(400);
172+
expect(response?.body?.error?.code, JSON.stringify(body)).toBe('VALIDATION_ERROR');
173+
expect(response?.body?.error?.message, JSON.stringify(body)).toMatch(/^Invalid publish-drafts body .*\bmessage: /);
174+
}
175+
const history = (await env.engine.find('sys_metadata_history', { where: {} })) as any[];
176+
expect(history.filter((r) => r.operation_type === 'publish')).toEqual([]);
177+
expect(await env.engine.find('sys_metadata_commit', { where: { package_id: PKG } })).toEqual([]);
178+
// The draft is still pending: the same draft publishes once the note is a string.
179+
const { history: published } = await publish(env, { message: 'Open notes list' });
180+
expect(published.map((r) => [r.name, r.change_note])).toEqual([['notes_open', 'Open notes list']]);
181+
});
182+
163183
it('a caller with no user id records no actor, never the body’s', async () => {
164184
const env = await boot();
165185
await draftViews(env.protocol, ['notes_open']);

‎packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
* written without (`route-ledger.ts`, the field's JSDoc), and the reason the
1111
* `POST /packages/:id/publish-drafts` row may carry
1212
* `requestSchema: 'PublishPackageDraftsRequestSchema'`. The request schema
13-
* declares the body; it does not claim the door parses the body with it. So
14-
* each member's behaviour at the door is pinned here as measured:
13+
* declares the body, and each member's behaviour at the door is pinned here as
14+
* measured:
1515
*
16-
* - `message` — a string reaches `publishPackageDrafts` as sent; a non-string
17-
* is dropped (the publish goes ahead with no note), not refused;
16+
* - `message` — a string reaches `publishPackageDrafts` as sent; a body the
17+
* schema refuses for its note (a number, an object, an array, `null`) is
18+
* refused `400 VALIDATION_ERROR` naming `message` before anything is
19+
* published;
1820
* - `backfill` — reaches the protocol as sent, and a body the schema refuses
1921
* is refused by the protocol's shape judgement `400 VALIDATION_ERROR`
2022
* before anything is published (the real protocol, below);
@@ -144,14 +146,21 @@ describe('publish-drafts request body conforms to PublishPackageDraftsRequestSch
144146
expect(bodyMembersSent(sent)).toEqual({});
145147
});
146148

147-
it('a note the schema refuses is dropped by the door, not refused: the publish goes ahead with no note', async () => {
148-
// Measured divergence: the schema declares `message` a string and the
149-
// door forwards only a string, but answers 200 for anything else. A
150-
// door that parses the body with the schema flips this pin to a 400.
151-
expect(PublishPackageDraftsRequestSchema.safeParse({ message: 42 }).success).toBe(false);
152-
const { response, sent } = await publishWith({ message: 42 });
153-
expect(response.status).toBe(200);
154-
expect(bodyMembersSent(sent)).toEqual({});
149+
it('a note the schema refuses is refused 400 VALIDATION_ERROR naming `message`, before anything is published', async () => {
150+
// The door parses `message` with the schema's own member, so a body the
151+
// schema refuses for its note is refused here, not published with no note.
152+
for (const body of [{ message: 42 }, { message: {} }, { message: [] }, { message: null }, { actor: 'x', message: 7 }]) {
153+
expect(PublishPackageDraftsRequestSchema.safeParse(body).success, JSON.stringify(body)).toBe(false);
154+
const publishPackageDrafts = vi.fn();
155+
const door = makeDoor({ publishPackageDrafts });
156+
const result = await door.dispatcher.handlePackages(`/${PKG}/publish-drafts`, 'POST', body, {}, PKG_ADMIN());
157+
const response = result.response as any;
158+
expect(response.status, JSON.stringify(body)).toBe(400);
159+
expect(response.body?.error?.code, JSON.stringify(body)).toBe('VALIDATION_ERROR');
160+
expect(response.body?.error?.message, JSON.stringify(body)).toMatch(/^Invalid publish-drafts body .*\bmessage: /);
161+
expect(publishPackageDrafts, JSON.stringify(body)).not.toHaveBeenCalled();
162+
expect(door.saveMetaItem, JSON.stringify(body)).not.toHaveBeenCalled();
163+
}
155164
});
156165

157166
it('the ADR-0045 app re-save after the publish records the caller too: a body `actor` reaches no write', async () => {

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ import {
150150
PackageInstallBodySchema,
151151
PackageInstallManifestSchema,
152152
PackageInstallRequestSchema,
153+
PublishPackageDraftsRequestSchema,
153154
} from '@objectstack/spec/api';
154155

155156
/**
@@ -1914,6 +1915,38 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
19141915
if (packageIsUnknown(registry, id)) {
19151916
return { handled: true, response: deps.error(`Package '${id}' not found`, 404) };
19161917
}
1918+
// [#28452] The note is parsed with the declaration's own
1919+
// member, `PublishPackageDraftsRequestSchema.shape.message`,
1920+
// and a note it refuses (a number, an object, an array,
1921+
// `null`) is refused here `400` (`VALIDATION_ERROR`, derived
1922+
// from the status as the install door's legs derive it) before
1923+
// anything is published. It used to be dropped, so the publish
1924+
// went ahead and every history row recorded the default note,
1925+
// and the caller never learnt its note was ignored.
1926+
//
1927+
// ⛔ Only `message` is judged here. `backfill` keeps its one
1928+
// refusal, the protocol's shape judgement below (same status,
1929+
// code and `issues[]` as before); parsing it here too would be a
1930+
// second judge with a second sentence. Unknown keys are not
1931+
// read at all, so a body `actor` (the SDK's deprecated
1932+
// `opts.actor`) still publishes. Placed beside that backfill
1933+
// judgement, after the 404, so both body-member refusals sit at
1934+
// one point of the door's order.
1935+
const declaredMessage = PublishPackageDraftsRequestSchema.shape.message.safeParse(body?.message);
1936+
if (!declaredMessage.success) {
1937+
const located = declaredMessage.error.issues
1938+
.map((issue) => `message: ${issue.message}`)
1939+
.join('; ');
1940+
return {
1941+
handled: true,
1942+
response: deps.error(
1943+
`Invalid publish-drafts body — ${located}. Nothing was published: \`message\` is the `
1944+
+ 'change note every history row of this publish records; send it as a string, or leave '
1945+
+ 'it out to record the sentence naming the package.',
1946+
400,
1947+
),
1948+
};
1949+
}
19171950
try {
19181951
// [ADR-0146 D1] Who published: the identity this request
19191952
// was authorized as, never a name the body supplies — see
@@ -1924,9 +1957,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
19241957
...(actor !== undefined ? { actor } : {}),
19251958
// [ADR-0146 D1] The author's change note: the package
19261959
// commit and every item history row this publish writes
1927-
// carry it. A string, as the single-item publish door
1928-
// reads `message`; anything else sends no note.
1929-
...(typeof body?.message === 'string' ? { message: body.message } : {}),
1960+
// carry it. The parsed value: a string, or absent.
1961+
...(declaredMessage.data !== undefined ? { message: declaredMessage.data } : {}),
19301962
// [#24253] Passed through as sent: the protocol judges
19311963
// its shape and what it names, and refuses the whole
19321964
// request before anything is published.

‎packages/runtime/src/route-ledger.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
523523
{ route: 'POST /packages/:id/publish-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.publishDrafts',
524524
requestSchema: 'PublishPackageDraftsRequestSchema',
525525
responseSchema: 'PublishPackageDraftsResponseSchema',
526-
note: 'dispatch() answers through the { success, data } envelope, so the named schema is the `data` — the protocol result AFTER the door\'s own mutations (seedApplied back-fill, ADR-0045 unhiddenApps/unhideError, rebindError). Fillable because packages-publish-drafts-response-conformance.test.ts drives THIS handler and parses the payload it answers; the producer half is pinned in objectql\'s publish-package-drafts-response-conformance.test.ts. `probes` is deliberately opaque in the declaration, upgraded to a modeled schema only when a consumer needs a field of it. `requestSchema` is the body the door reads (`message`, `backfill`; a body `actor` is ignored, so the schema strips unknown keys rather than refusing them), filled because packages-publish-drafts-request-conformance.test.ts drives THIS handler and pins what each body member does' },
526+
note: 'dispatch() answers through the { success, data } envelope, so the named schema is the `data` — the protocol result AFTER the door\'s own mutations (seedApplied back-fill, ADR-0045 unhiddenApps/unhideError, rebindError). Fillable because packages-publish-drafts-response-conformance.test.ts drives THIS handler and parses the payload it answers; the producer half is pinned in objectql\'s publish-package-drafts-response-conformance.test.ts. `probes` is deliberately opaque in the declaration, upgraded to a modeled schema only when a consumer needs a field of it. `requestSchema` is the body the door reads (`message`, parsed with the schema\'s own member so a non-string note is refused 400 VALIDATION_ERROR before anything is published; `backfill`, judged by the protocol; a body `actor` is ignored, so the schema strips unknown keys rather than refusing them), filled because packages-publish-drafts-request-conformance.test.ts drives THIS handler and pins what each body member does' },
527527
{ route: 'POST /packages/:id/discard-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.discardDrafts',
528528
responseSchema: 'DiscardPackageDraftsResponseSchema',
529529
note: 'Enveloped — the named schema is the `data`: `discardPackageDrafts`\'s declared return, transcribed describe-only. Conformance: spec `api/package-lifecycle.test.ts`' },

‎packages/spec/src/api/protocol.zod.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1497,7 +1497,9 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({
14971497
* is the `:id` path segment, never a body member. The door reads exactly two
14981498
* body members and hands them to `publishPackageDrafts`:
14991499
*
1500-
* - `message` — forwarded only when it is a string;
1500+
* - `message` — parsed with this declaration's member; a note it refuses (a
1501+
* number, an object, an array, `null`) is refused `400 VALIDATION_ERROR`
1502+
* naming `message` before anything is published;
15011503
* - `backfill` — forwarded as sent whenever the key is present; the protocol
15021504
* judges its shape before anything is published and refuses the whole
15031505
* request `400 VALIDATION_ERROR` with every bad entry in `issues[]`.

0 commit comments

Comments
 (0)