From cd3fa758717d55cd4e7ec1804ad822faebe19c55 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:28:40 +0000 Subject: [PATCH 1/2] fix(runtime): stop the package-publish door disclosing driver text on seedApplied The route-level seed apply that runs for protocols which do not self-apply seeds is a second copy of metadata-protocol's applySeedBodies, and carried the same ADR-0112 defect #8333's P9 fixed there: caught driver text interpolated onto a client-facing payload. seedApplied rides on a 200 publish response as data, so no HTTP boundary's 5xx message withhold can reach it. Reproduced first, through HttpDispatcher.handlePackages with a real SeedLoaderService. The reproduction found two carriers, not one: the door's catch (a driver failure under the loader's dependency-graph read) and the per-read errors[] entries (a driver failure on the seed body read-back, which is the carrier a sys_metadata outage reaches first). The rule is imported from the producer rather than restated: metadata-protocol now exports clientFacingFailureText and seedRequestValidationError (enabling export only, no behaviour change there). The seed request parse becomes a safeParse whose rejection is minted as a real 422, so a malformed seed body is still quoted back to its author while undeclared driver text is withheld. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj --- packages/metadata-protocol/src/index.ts | 10 + packages/metadata-protocol/src/protocol.ts | 23 +- .../packages-seed-apply-disclosure.test.ts | 318 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 68 +++- 4 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 31dfb50729..0345ae8b51 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -5,6 +5,16 @@ export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeView // ObjectQL FALLBACK in `@objectstack/runtime`'s `callData` builds the SAME one // instead of minting a second not-found shape. See `recordNotFoundError`. export { recordNotFoundError } from './protocol.js'; +// [#8443] The ADR-0112 disclosure rule (#8086 / #8136 / #8333), exported for +// the SECOND seed-apply producer: `@objectstack/runtime`'s package-publish door +// keeps a fallback apply for protocols that do not self-apply, and it reports +// failure as data on the same `seedApplied` field. Both halves travel together +// because both are needed to apply the rule without losing authoring feedback: +// `clientFacingFailureText` withholds what was never declared, and +// `seedRequestValidationError` is what DECLARES the one population that must +// still be quoted (a malformed seed body). Exporting is enabling-only — no +// behaviour in this package changes. +export { clientFacingFailureText, seedRequestValidationError } from './protocol.js'; export { createMetadataProtocolPlugin, assembleMetadataProtocol } from './plugin.js'; export type { MetadataProtocolPluginOptions, AssembleMetadataProtocolOptions } from './plugin.js'; // [#6710] The declared authoring channel — the explicit expression of ADR-0005's diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 2adbd5755b..7b781657fa 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1640,8 +1640,19 @@ function overlayDeleteFailureMessage(err: unknown, type: string, name: string): * existing no-message fallback at every call site (`'delete failed'`, * `'cleanup failed'`), so the withheld case reuses the sentence the caller * could already receive rather than inventing a second vocabulary. + * + * [#8443] EXPORTED (via `index.ts`) for the same reason `recordNotFoundError` + * is: `@objectstack/runtime`'s package-publish door carries a seed-apply + * fallback for protocols that do not self-apply, and it reports failure as + * data on the same `seedApplied` field this package's `applySeedBodies` does. + * A second private restatement of the rule over there is exactly how the rule + * drifts out of sync — the withhold and the quote must be ONE decision, in one + * place, for both producers. `declaresClientRefusal` stays private on purpose: + * nothing outside this file needs the raw predicate, and an exported surface + * with no consumer is the "declared but nobody pulls it" shape AGENTS.md + * treats as debt. */ -function clientFacingFailureText(err: unknown, fallback: string): string { +export function clientFacingFailureText(err: unknown, fallback: string): string { if (declaresClientRefusal(err)) { const declared = (err as { message?: unknown } | null | undefined)?.message; if (typeof declared === 'string' && declared.length > 0) return declared; @@ -1680,8 +1691,16 @@ function clientFacingFailureText(err: unknown, fallback: string): string { * `ZodError`, so `seedApplied.error` was a multi-line JSON dump of raw zod * internals. This is the curated summary {@link zodIssuesToMetadataIssues} * already produces for every other authoring surface. + * + * [#8443] EXPORTED alongside {@link clientFacingFailureText}: the runtime + * package-publish door parses the SAME `SeedLoaderRequestSchema` in its own + * seed-apply fallback and hits the identical two-population catch, so it needs + * the identical declaration — same sentence, same `INVALID_METADATA`/422, same + * curated `issues`. Minting a second 422 over there would give one authoring + * mistake two different envelopes depending on which protocol served the + * publish. */ -function seedRequestValidationError(zodIssues: unknown): Error { +export function seedRequestValidationError(zodIssues: unknown): Error { const issues = zodIssuesToMetadataIssues(zodIssues); const summary = issues.slice(0, 3) .map((i: { path: string; message: string }) => `${i.path || ''}: ${i.message}`) diff --git a/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts b/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts new file mode 100644 index 0000000000..3a7ad49595 --- /dev/null +++ b/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8443 — the runtime package-publish door's own copy of #8333's **P9**. + * + * `POST /packages/:id/publish-drafts` keeps a route-level seed apply for + * protocols that do not self-apply seeds inside `publishPackageDrafts`. That + * fallback is a second copy of `metadata-protocol`'s `applySeedBodies`, and it + * carried the same defect after PR #8436 converted the original: caught driver + * text interpolated straight onto a client-facing payload. + * + * ## Why an HTTP boundary cannot save this one + * + * `seedApplied` rides on a **200** publish response as DATA. Every 5xx message + * withhold in the stack reads a *thrown* error's message; none of them can see + * a field on a successful body. That is the whole argument for fixing the + * producer, and it is why every case below asserts the CONTENTS of that field + * inside a 200 — never a status code, never that something threw. + * + * ## The defect, as measured on `origin/main` BEFORE the change + * + * The card was explicit that it was read from source, not reproduced. It was + * driven for real first, through `HttpDispatcher.handlePackages` with a real + * `SeedLoaderService`, and the reproduction found the door's catch is only ONE + * of two carriers on the same field: + * + * | # | injection | pre-change `seedApplied` | + * |:--|:--|:--| + * | B | `metadata.getObject` throws (the loader's dependency-graph read, unguarded in `resolveObjectDefinition`) | `error: "SQLITE_ERROR: no such table: sys_metadata"` | + * | C | `protocol.getMetaItem` throws (the seed body read-back) | `errors: ["read project_seed: SQLITE_ERROR: no such table: sys_metadata", …]` | + * | D | a malformed seed body | `error:` a multi-line JSON dump of raw zod internals | + * | E | a DECLARED 4xx refusal on the read-back | the authored sentence, verbatim — correct, and must stay | + * + * C is the carrier the card did not name, and it is the one a `sys_metadata` + * outage reaches FIRST: the read-back happens before the loader is ever + * constructed, so a fix confined to the door's catch would have left the + * commonest outage shape disclosing exactly as before. Both are fixed here. + * + * ## The rule, imported rather than restated + * + * A caught error's sentence may be quoted to a caller only when that error + * DECLARED itself a client-facing refusal (4xx `status`, ADR-0112). The + * predicate and the sentence live in `@objectstack/metadata-protocol` and are + * now exported (`clientFacingFailureText`), because two copies of "when may a + * caught sentence be quoted" is precisely how the rule drifts apart. + * + * D is the population the rule cannot simply be applied to — a raw `ZodError` + * declares nothing, so the withhold would have blanked the author's feedback. + * The cure is #8333's, not a loosened collector: the parse becomes a + * `safeParse` and its rejection is minted as a real 422 by the producer's own + * `seedRequestValidationError`, so it satisfies the positive list on its own + * merits. Section 3 pins that the envelope is the PRODUCER'S — one authoring + * mistake must not get two different sentences depending on which protocol + * served the publish. + * + * ## Reverse verification — direction predicted BEFORE running + * + * Predicted with `domains/packages.ts` reverted to `origin/main` (the + * `metadata-protocol` export kept, or the file would not compile): + * + * - RED: section 1 (B and C, the two withholds) and section 3 (D, the + * authoring envelope) = 4 cases. Each asserts the POSITIVE post-fix shape + * plus the absence of the driver line, so an unfixed door fails on the + * text rather than on a vague "it changed". + * - GREEN IN BOTH DIRECTIONS — section 0 (the positive control: it proves the + * injection points sit on the live path, and the live path is not what + * changed) and section 3's `[GUARD]` (a declared 4xx refusal quoted + * verbatim — true before and after). + * + * Predicted **4 failed | 2 passed**. Measured: PLACEHOLDER_PRIMARY. + * + * The `[GUARD]` earns its place under a DIFFERENT variant, which is the run + * that makes it load-bearing: with `clientFacingFailureText` forced to withhold + * unconditionally (never quoting, so the rule becomes a blanket blank), the + * predicted casualties are section 2's two quotes and section 3's guard — + * **3 failed | 3 passed**. Measured: PLACEHOLDER_VARIANT. Without them this + * file would be satisfied by "withhold everything", which deletes the + * self-correcting refusals #4277 exists for and the authoring feedback #8333 + * went out of its way to preserve. + * + * ⛔ Never a bare `toThrow()` here: this door does not throw, it REPORTS, and + * the whole defect is what the report says. + */ +import { describe, expect, it, vi } from 'vitest'; +import { seedRequestValidationError } from '@objectstack/metadata-protocol'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +/** + * The sqlite phrasing of "`sys_metadata` is not there". One dialect is a + * sufficient carrier: the dialect matrix and the proof that the shared + * `looksLikeInternalErrorLeak` heuristic is dialect-bounded belong to + * `metadata-protocol`'s `protocol.driver-text-disclosure.test.ts` (#8136). The + * rule under test here — "was a client refusal DECLARED" — is phrasing-blind by + * construction, so this file inherits that conclusion instead of re-deriving it. + */ +const DRIVER_TEXT = 'SQLITE_ERROR: no such table: sys_metadata'; + +/** Fragments that must never appear anywhere in a client-facing payload. */ +const LEAKED_FRAGMENTS = ['SQLITE_ERROR', 'no such table', 'sys_metadata']; + +/** The whole 200 body, the way the door ships it. */ +function expectNothingLeaked(payload: unknown): void { + const wire = JSON.stringify(payload) ?? ''; + expect(wire).not.toContain(DRIVER_TEXT); + for (const fragment of LEAKED_FRAGMENTS) expect(wire).not.toContain(fragment); +} + +/** + * [#7033 / #7023] `/packages` carries an anonymous-deny floor plus per-route + * capability predicates; every state-changing route demands `manage_metadata`. + * Without a caller these cases would stop at the 401 before reaching the + * behaviour they are named after. The gates themselves are pinned in + * `packages-capability-gate.test.ts`. + */ +const PKG_ADMIN = () => ({ + request: {}, + executionContext: { + userId: 'u_pkg_admin', + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, +}) as any; + +/** The malformed seed body's one planted defect — `mode` is a closed enum. */ +const BAD_MODE = 'sideways'; + +/** + * A self-correcting refusal of the shape `SysMetadataRepository` raises: it + * DECLARED 4xx, so the author must receive it whole. This is the sentence the + * over-block variant deletes. + */ +const DECLARED_REFUSAL = () => { + const e: any = new Error('[item_locked] seed "project_seed" is locked by another publish'); + e.code = 'ITEM_LOCKED'; + e.status = 403; + return e; +}; + +/** + * A dispatcher whose protocol does NOT self-apply seeds — the exact composition + * the door's fallback documents itself as existing for — driving the REAL + * `SeedLoaderService`. Only the engine, the metadata service and the protocol + * are doubled, so the seed apply chain under test is the shipping one. + */ +function makeDoor(opts: { + failGetObject?: boolean; + failGetMetaItem?: boolean; + malformedSeedBody?: boolean; + refusalOnGetMetaItem?: boolean; +} = {}) { + const records = [ + { name: 'Apollo', status: 'active' }, + { name: 'Gemini', status: 'planned' }, + ]; + const publishPackageDrafts = vi.fn().mockResolvedValue({ + success: true, publishedCount: 1, failedCount: 0, + published: [{ type: 'seed', name: 'project_seed', version: 'h' }], failed: [], + }); + const body = { + object: 'project', + externalId: 'name', + mode: opts.malformedSeedBody ? BAD_MODE : 'upsert', + records, + }; + const getMetaItem = vi.fn().mockImplementation(async () => { + if (opts.failGetMetaItem) throw new Error(DRIVER_TEXT); + if (opts.refusalOnGetMetaItem) throw DECLARED_REFUSAL(); + // The WRAPPER shape: the seed body lives under `.item`. + return { type: 'seed', name: 'project_seed', lock: null, editable: true, item: body }; + }); + // Mirror the real engine's array-form insert (bulk path). + const insert = vi.fn().mockImplementation(async (_object: string, rec: any) => ( + Array.isArray(rec) ? rec.map((r: any) => ({ id: `id_${r.name}` })) : { id: `id_${rec.name}` } + )); + const find = vi.fn().mockResolvedValue([]); + const getObject = opts.failGetObject + ? vi.fn().mockImplementation(async () => { throw new Error(DRIVER_TEXT); }) + : vi.fn().mockResolvedValue({ + name: 'project', + fields: { name: { type: 'text' }, status: { type: 'select' } }, + }); + + const kernel: any = { + getService: (name: string) => { + if (name === 'protocol') return Promise.resolve({ publishPackageDrafts, getMetaItem }); + if (name === 'objectql') { + return Promise.resolve({ + insert, find, update: vi.fn(), + registry: { getAllPackages: vi.fn().mockReturnValue([]) }, + }); + } + if (name === 'metadata') return Promise.resolve({ getObject }); + return null; + }, + context: { getService: () => null }, + }; + return { dispatcher: new HttpDispatcher(kernel), insert, getObject, getMetaItem }; +} + +async function publishDrafts(opts: Parameters[0] = {}) { + const door = makeDoor(opts); + const result = await door.dispatcher.handlePackages( + '/com.workspace/publish-drafts', 'POST', {}, {}, PKG_ADMIN(), + ); + // The field under test is DATA on a success body — assert that framing once + // here so every case below reads as "what the 200 said". + expect(result.response?.status).toBe(200); + const body: any = (result.response as any)?.body; + return { ...door, body, seedApplied: body?.data?.seedApplied }; +} + +// --------------------------------------------------------------------------- +// Section 0 — the positive control +// --------------------------------------------------------------------------- + +describe('#8443 · 0 · the fallback really runs (positive control)', () => { + it('a healthy engine loads the rows and reports them on the 200', async () => { + const { seedApplied, insert, getObject } = await publishDrafts(); + + expect(seedApplied?.success).toBe(true); + expect(seedApplied?.inserted).toBe(2); + // Rows actually reached the engine, batched into one bulk insert — so + // the injections below are perturbing a path that genuinely runs, not + // a dead branch. Without this, "no driver text" is indistinguishable + // from "nothing happened at all". + expect(insert).toHaveBeenCalledTimes(1); + expect(getObject).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Section 1 — the withhold, at both carriers on the same field +// --------------------------------------------------------------------------- + +describe('#8443 · 1 · undeclared driver text never rides the 200', () => { + it('the door catch — a driver failure under the loader dependency graph', async () => { + const { seedApplied, body, getObject } = await publishDrafts({ failGetObject: true }); + + // The injection reached the live path (it is the loader's own metadata + // read), and the failure is still reported — withholding must not turn + // into silence. + expect(getObject).toHaveBeenCalled(); + expect(seedApplied?.success).toBe(false); + expect(seedApplied?.error).toBe('seed apply failed'); + expectNothingLeaked(body); + }); + + it('the read-back — a driver failure reading the published seed body', async () => { + const { seedApplied, body, getMetaItem } = await publishDrafts({ failGetMetaItem: true }); + + expect(getMetaItem).toHaveBeenCalled(); + expect(seedApplied?.success).toBe(false); + // The stable operational sentence is unchanged; what changed is the + // per-read entry beside it. + expect(seedApplied?.error).toBe('seed apply: no readable seed bodies'); + expect(seedApplied?.errors?.[0]).toBe('read project_seed: the reason is in the server log'); + expectNothingLeaked(body); + }); +}); + +// --------------------------------------------------------------------------- +// Section 2 — the authoring population, quoted BECAUSE it declares 422 +// --------------------------------------------------------------------------- + +describe('#8443 · 2 · a malformed seed body still reaches its author', () => { + it('quotes the curated spec-validation summary, not a zod dump', async () => { + const { seedApplied, body } = await publishDrafts({ malformedSeedBody: true }); + + expect(seedApplied?.success).toBe(false); + expect(seedApplied?.error).toContain('[invalid_metadata]'); + // The author learns WHICH key — the whole reason this population may + // not be blanked. `seeds.0.mode` is the path through the request the + // loader parses. + expect(seedApplied?.error).toContain('seeds.0.mode'); + // ⛔ and NOT the raw `ZodError` stringification the field used to carry. + expect(seedApplied?.error).not.toContain('"code": "invalid_value"'); + expectNothingLeaked(body); + }); + + it('mints that envelope with the PRODUCER\'s declaration, not a local copy', async () => { + const { seedApplied } = await publishDrafts({ malformedSeedBody: true }); + + // The anti-drift pin, and the reason the helpers were exported rather + // than restated: the sentence a caller receives here must be the same + // one `metadata-protocol`'s own seed-apply path mints for the same + // rejection. A local restatement in runtime — however faithful the day + // it is written — goes red here the first time either side is edited + // alone. + const fromProducer = seedRequestValidationError([{ + code: 'invalid_value', + path: ['seeds', 0, 'mode'], + message: 'Invalid option: expected one of "insert"|"update"|"upsert"|"replace"|"ignore"', + }]); + expect(seedApplied?.error).toBe(fromProducer.message); + // The declaration that makes it quotable at all (ADR-0112). + expect((fromProducer as any).status).toBe(422); + expect((fromProducer as any).code).toBe('INVALID_METADATA'); + }); +}); + +// --------------------------------------------------------------------------- +// Section 3 — the over-block bound +// --------------------------------------------------------------------------- + +describe('#8443 · 3 · [GUARD] a DECLARED 4xx refusal is quoted verbatim', () => { + it('keeps a self-correcting refusal intact on the read-back', async () => { + const { seedApplied } = await publishDrafts({ refusalOnGetMetaItem: true }); + + // Green before and after this card — it is a BOUND, not evidence. Its + // job is to fail the "withhold everything" shortcut: measured under the + // over-broad variant (nothing ever quoted) this case goes red, which is + // what makes it load-bearing. The author must still be told the seed is + // locked and by what, because that is a problem they can fix. + expect(seedApplied?.success).toBe(false); + expect(seedApplied?.errors?.[0]).toBe( + 'read project_seed: ' + DECLARED_REFUSAL().message, + ); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 1505ab0ddc..7451a141f5 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -31,6 +31,13 @@ import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES } from '@objectstack/metada // this defect existed precisely because the lifecycle routes had no copy of it, // and a second copy would be the next place it drifts. import { isWritablePackage } from '@objectstack/metadata-protocol'; +// [#8443] ADR-0112's disclosure rule (#8086 / #8136 / #8333), and the DECLARED +// 422 that keeps the one quotable population quotable. Both imported from the +// producer for the reason the line above is: this door's seed-apply fallback is +// a second copy of `metadata-protocol`'s `applySeedBodies`, and a second +// private restatement of "when may a caught sentence be quoted" is where the +// two copies start answering differently. +import { clientFacingFailureText, seedRequestValidationError } from '@objectstack/metadata-protocol'; import { organizationIdForMetaWrite } from '../meta-write-org-scope.js'; import { setPackageDisabled } from '../package-state-store.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -378,7 +385,33 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin ); } } catch (e: any) { - (result as any).seedApplied = { success: false, error: e?.message ?? 'seed apply failed' }; + // [#8443] ADR-0112: `seedApplied.error` rides on a + // **200** publish response as DATA, so no HTTP + // boundary's 5xx message withhold can reach it — + // the same argument #8333's P9 makes about this + // door's twin inside `metadata-protocol`, which + // this fallback is a second copy of. Measured on + // `origin/main` before the change: the loader's + // dependency-graph read (`metadata.getObject`) is + // unguarded, so a `sys_metadata` outage escaped + // `applyPublishedSeeds` and this catch answered + // `"error": "SQLITE_ERROR: no such table: + // sys_metadata"` on a 200. + // + // The rule is IMPORTED, never re-spelled: quote the + // caught sentence only when the error declared + // itself a 4xx client refusal. The other population + // this catch receives — a malformed seed body — + // declares itself 422 at the `safeParse` inside + // `applyPublishedSeeds`, so the author still gets + // the curated issue summary. + (deps.logger ?? console).warn( + `[handlePackages] seed apply failed: ${e?.message ?? e}`, + ); + (result as any).seedApplied = { + success: false, + error: clientFacingFailureText(e, 'seed apply failed'), + }; } } // ADR-0045 §3: "Publish" makes the package live AND visible. @@ -981,7 +1014,22 @@ _context: HttpProtocolContext, item = await protocol.getMetaItem(args); if (item) break; } catch (e) { - readErrors.push(`read ${name}: ${(e as Error)?.message ?? String(e)}`); + // [#8443] The SAME rule as the catch at the door, applied to + // the sibling key of the same field: `readErrors` becomes + // `seedApplied.errors[]` on that 200 response, so it is a + // client-facing payload too. Measured before the change: with + // `sys_metadata` unreachable this read fails FIRST — before the + // loader is ever constructed — and answered `"errors": ["read + // project_seed: SQLITE_ERROR: no such table: sys_metadata"]`, + // so fixing only the door's catch would have left the commonest + // outage shape disclosing exactly as before. A DECLARED 4xx + // refusal (`[item_locked]`, `[writable_package_required]`, …) + // still reaches the author verbatim — that is the point of the + // positive list. + (deps.logger ?? console).warn( + `[applyPublishedSeeds] seed body read failed for "${name}": ${(e as Error)?.message ?? String(e)}`, + ); + readErrors.push(`read ${name}: ${clientFacingFailureText(e, 'the reason is in the server log')}`); } } // protocol.getMetaItem returns a WRAPPER: `{ type, name, item, lock, @@ -1008,7 +1056,18 @@ _context: HttpProtocolContext, const { SeedLoaderService } = await import('../seed-loader.js'); const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data'); const loader = new SeedLoaderService(ql, metadata, deps.logger ?? console); - const request = SeedLoaderRequestSchema.parse({ + // [#8443] `safeParse`, not `parse` — the same producer-side declaration + // #8333's P9 made next door, for the same reason. This catch's caller now + // withholds anything undeclared, and a raw `ZodError` declares nothing, so + // a malformed seed body would have been blanked to `seed apply failed` + // (measured before the change, it arrived as a multi-line dump of zod + // internals — authoring feedback, and the one population that must + // survive). Declaring it 422 at its own producer is what keeps BOTH true: + // the driver text is withheld and the author still learns which seed and + // which key. The envelope is minted by `metadata-protocol`'s own helper, so + // one authoring mistake cannot get two different envelopes depending on + // which protocol served the publish. + const parsedRequest = SeedLoaderRequestSchema.safeParse({ seeds: datasets, config: { defaultMode: 'upsert', @@ -1016,7 +1075,8 @@ _context: HttpProtocolContext, ...(organizationId ? { organizationId } : {}), }, }); - const r = await loader.load(request); + if (!parsedRequest.success) throw seedRequestValidationError(parsedRequest.error.issues); + const r = await loader.load(parsedRequest.data); return { success: r.success, inserted: r.summary.totalInserted, From c7dc9ab356dc70ce51974ccee6ca49ecd8fb17a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:52:23 +0000 Subject: [PATCH 2/2] test(runtime): record the measured reverse-verification numbers; add the changeset Both predictions held: 4 failed | 2 passed with the door reverted, and 3 failed | 3 passed under the over-broad variant (nothing ever quoted), the three being the two authoring quotes and the declared-refusal guard. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj --- .changeset/runtime-seed-apply-driver-text.md | 50 +++++++++++++++++++ .../packages-seed-apply-disclosure.test.ts | 14 ++++-- 2 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 .changeset/runtime-seed-apply-driver-text.md diff --git a/.changeset/runtime-seed-apply-driver-text.md b/.changeset/runtime-seed-apply-driver-text.md new file mode 100644 index 0000000000..af8480bc12 --- /dev/null +++ b/.changeset/runtime-seed-apply-driver-text.md @@ -0,0 +1,50 @@ +--- +"@objectstack/runtime": patch +"@objectstack/metadata-protocol": patch +--- + +fix(runtime): the package-publish door no longer discloses driver text on `seedApplied` (#8443) + +`POST /api/v1/packages/:id/publish-drafts` answered, on a **200**: + +```json +{ "success": true, "data": { "seedApplied": { + "success": false, "error": "SQLITE_ERROR: no such table: sys_metadata" } } } +``` + +The door keeps a route-level seed apply for protocols that do not apply seeds +inside `publishPackageDrafts` themselves. That fallback is a second copy of +`metadata-protocol`'s `applySeedBodies`, and it kept the ADR-0112 defect the +original was fixed for: a caught error's sentence interpolated onto a +client-facing payload. `seedApplied` rides on a success body as **data**, so no +HTTP boundary's 5xx message withhold can reach it — the disclosure had to be +closed at the producer. + +Driven for real before being changed, which found **two** carriers on that one +field rather than the one reported: + +- the door's `catch` — a driver failure under the seed loader's + dependency-graph read, which is unguarded; +- the per-read `errors[]` entries — a driver failure reading the just-published + seed body back. This is the carrier a `sys_metadata` outage reaches first, so + a fix confined to the `catch` would have left the commonest outage shape + disclosing exactly as before. + +Both now follow the rule already in force next door: a caught sentence is +quoted only when the error **declared** itself a client-facing refusal (4xx +`status`); anything else gets a stable line and the original goes to the server +log. + +**Authoring feedback is preserved, not blanked.** A malformed seed body used to +arrive in the same `catch` as a raw `ZodError` — undeclared, so the withhold +would have replaced a real authoring error with `seed apply failed`. The seed +request is now parsed with `safeParse` and its rejection minted as a declared +`INVALID_METADATA` / 422, so the author receives a curated summary naming the +seed and the key (strictly better than the multi-line dump of zod internals the +field used to carry). Self-correcting refusals such as `[item_locked]` continue +to reach the caller verbatim. + +`@objectstack/metadata-protocol` exports `clientFacingFailureText` and +`seedRequestValidationError` so the runtime door applies the producer's own +decision instead of restating it — **an enabling export only; no behaviour in +that package changes.** diff --git a/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts b/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts index 3a7ad49595..7fce671b99 100644 --- a/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts +++ b/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts @@ -67,16 +67,20 @@ * changed) and section 3's `[GUARD]` (a declared 4xx refusal quoted * verbatim — true before and after). * - * Predicted **4 failed | 2 passed**. Measured: PLACEHOLDER_PRIMARY. + * Predicted **4 failed | 2 passed**. Measured **4 failed | 2 passed**, and + * every red failed on the TEXT — `expected 'SQLITE_ERROR: no such table: + * sys_metadata' to be 'seed apply failed'` — not on a vague "it changed". * * The `[GUARD]` earns its place under a DIFFERENT variant, which is the run * that makes it load-bearing: with `clientFacingFailureText` forced to withhold * unconditionally (never quoting, so the rule becomes a blanket blank), the * predicted casualties are section 2's two quotes and section 3's guard — - * **3 failed | 3 passed**. Measured: PLACEHOLDER_VARIANT. Without them this - * file would be satisfied by "withhold everything", which deletes the - * self-correcting refusals #4277 exists for and the authoring feedback #8333 - * went out of its way to preserve. + * **3 failed | 3 passed**. Measured **3 failed | 3 passed**, those exact three. + * Without them this file would be satisfied by "withhold everything", which + * deletes the self-correcting refusals #4277 exists for and the authoring + * feedback #8333 went out of its way to preserve. + * + * Both predictions held; there is no missed prediction to record on this card. * * ⛔ Never a bare `toThrow()` here: this door does not throw, it REPORTS, and * the whole defect is what the report says.