From 4d8617e6a06bf659e10ce437443d32f1f9d8a284 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 04:15:09 +0000 Subject: [PATCH] docs(spec): the ResumeFailureReport docblock stops inviting the parse that strips its code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ResumeFailureReport.code` is required, and the type's own docblock invited a caller to parse the member with `ResumeFailureDetailsSchema`. That schema declares the three shared members and not `code`, and it is a plain non-strict `z.object`, so the invited path strips the code silently: the parse succeeds, raises no `unrecognized_keys` issue and logs nothing, and the caller is left holding a failure report with no failure class — the one member the same docblock calls indispensable. Prose only. `ResumeFailureDetailsSchema` is correct where it is used (the resume door's `400 FLOW_FAILED` details, where the code rides on the error envelope beside it), and declaring `code` on it would widen a published accept surface and break the "declared ONCE" identity the contract pin asserts. Both halves are pinned in `contracts/resume-failure-report.pin.test.ts`: the silence of the strip, and the docblock carrying the warning instead of the invitation. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- ...7929-resume-failure-report-schema-strip.md | 13 +++++++ .../spec/src/contracts/approval-service.ts | 25 +++++++++---- .../resume-failure-report.pin.test.ts | 36 +++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 .changeset/17929-resume-failure-report-schema-strip.md diff --git a/.changeset/17929-resume-failure-report-schema-strip.md b/.changeset/17929-resume-failure-report-schema-strip.md new file mode 100644 index 00000000000..d6e8b50ccb2 --- /dev/null +++ b/.changeset/17929-resume-failure-report-schema-strip.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +`ResumeFailureReport`'s docblock no longer invites a caller to parse that member with `ResumeFailureDetailsSchema` — the one path that deletes the report's `code`, silently. + +The docblock said two things in one paragraph: that a caller "that parses this member with `ResumeFailureDetailsSchema` reads the same three facts it reads off that door", and that `code` is the one member a success envelope cannot leave to its envelope, because on a success answer nothing else names the failure class. Each sentence is true on its own; together they route a reader into losing exactly the member the second one calls indispensable. `ResumeFailureDetailsSchema` declares `runId` / `status` / `repairable` and not `code`, and it is a plain non-strict `z.object`, so the key is stripped — measured on this tree, `safeParse` of a full report answers `success: true` with `error: undefined` and hands back an object with no `code` at all. No refusal, no `unrecognized_keys` issue, nothing logged. + +- **Prose only — no schema moves, deliberately.** `ResumeFailureDetailsSchema` is the wire schema of the automation resume door's `400 FLOW_FAILED` `error.details`, where the registered code rides on the `error` envelope it is parsed beside. Declaring `code` on it would put a second spelling of the failure class on that door's answer, widen a published accept surface, and break the "declared ONCE" identity the contract pin asserts — the report minus its `code` IS `ResumeFailureDetails`. The defect is in the sentence that misdirects, not in the schema, which is correct where it is actually used. +- **What a consumer does instead:** read `code` off the report. It is typed `ErrorCode`, required, and needs no parse. That schema stays the right reader for the three shared members, and the right reader on the resume door. +- **Both halves are pinned** in `contracts/resume-failure-report.pin.test.ts`: that the strip is silent (parse succeeds, no issue raised, no `code` in the output), and that the docblock carries the warning and no longer carries the invitation. Prose is unassertable except by reading it, so the contract source is read — the pattern that file already uses for the absence rule. + +Clause-②: no diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index 72902555176..95f4ee4d264 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -571,12 +571,25 @@ export interface ApprovalRecallInput { * (`api/automation-api.zod.ts`), the structure the automation resume door * already publishes inside its `400 FLOW_FAILED` `error.details` (#15221, the * ruling's third carrier). They are inherited here, never re-spelled, so the - * two cannot drift, and a caller that parses this member with - * `ResumeFailureDetailsSchema` reads the same three facts it reads off that - * door. What this adds is the one member a success envelope cannot leave to - * its envelope: on the resume door the registered code is the answer's own - * `code`; on a success answer nothing else names the failure class, so it - * rides here as {@link code}. + * two cannot drift. What this adds is the one member a success envelope + * cannot leave to its envelope: on the resume door the registered code is + * the answer's own `code`; on a success answer nothing else names the + * failure class, so it rides here as {@link code}. + * + * ⛔ Which is exactly why `ResumeFailureDetailsSchema` is NOT the reader for + * this member. That schema declares the three shared members and not + * {@link code}, and it is a plain non-strict `z.object`: parsing a report + * with it SILENTLY STRIPS the code — the parse SUCCEEDS, raises no + * `unrecognized_keys` issue, logs nothing, and hands back an object whose + * failure class is simply gone. The one member the paragraph above calls + * indispensable is the one the act of validating removes, and nothing in the + * result says so. On the resume door that schema is the right reader, + * because there the registered code rides on the `error` envelope it parses + * beside; here there is no envelope, so read {@link code} off the report + * itself — it is typed `ErrorCode`, required, and needs no parse at all. Use + * that schema on this member to read the three shared facts if you like, + * ⛔ never as a way to obtain the report. Both halves are measured in + * `contracts/resume-failure-report.pin.test.ts`. * * ⛔ No new error code is minted under the ruling. `code` is typed as * `ErrorCode` — the ADR-0112 vocabulary `ApiErrorSchema.code` parses diff --git a/packages/spec/src/contracts/resume-failure-report.pin.test.ts b/packages/spec/src/contracts/resume-failure-report.pin.test.ts index 1c285f446a4..3c9499f3406 100644 --- a/packages/spec/src/contracts/resume-failure-report.pin.test.ts +++ b/packages/spec/src/contracts/resume-failure-report.pin.test.ts @@ -125,6 +125,15 @@ function docblockOf(iface: string, member: string): string { return body.slice(docStart, memberAt); } +/** The docblock immediately above an exported interface, from the contract source. */ +function interfaceDocblockOf(iface: string): string { + const declaredAt = CONTRACT_SOURCE.indexOf(`export interface ${iface} `); + expect(declaredAt, `interface ${iface} is declared`).toBeGreaterThanOrEqual(0); + const docStart = CONTRACT_SOURCE.lastIndexOf('/**', declaredAt); + expect(docStart, `${iface} carries a docblock`).toBeGreaterThanOrEqual(0); + return CONTRACT_SOURCE.slice(docStart, declaredAt); +} + describe('[#16559] ResumeFailureReport — the resume failure a success answer carries (batch #76)', () => { it('1. the wire schema parses a report and hands the three shared members back out (declared once, measured)', () => { // A strip-mode object drops undeclared keys silently and would parse @@ -136,6 +145,33 @@ describe('[#16559] ResumeFailureReport — the resume failure a success answer c expect(Object.keys(strandedParent).sort()).toEqual([...Object.keys(parsed), 'code'].sort()); }); + it('1. the wire schema strips that code SILENTLY, and the docblock no longer sends a caller down that path', () => { + // [#17929] The type docblock used to invite a caller to parse this member + // with the wire schema. The invitation deleted the one member the same + // docblock calls indispensable, and the deletion is the quiet kind: a + // non-strict `z.object` reports an undeclared key nowhere. Both halves of + // the repair are pinned, because each rots on its own -- the silence is + // the behaviour the prose must keep describing, and the prose is the only + // thing standing between a reader and the path that loses the code. + const parsed = ResumeFailureDetailsSchema.safeParse(strandedParent); + expect(parsed.success, 'parsing a full report SUCCEEDS -- the strip does not refuse').toBe(true); + expect(parsed.error, 'and it raises nothing: no `unrecognized_keys`, no issue at all').toBeUndefined(); + expect(parsed.data && 'code' in parsed.data, 'yet the failure class is gone from the output').toBe(false); + + const reportDoc = interfaceDocblockOf('ResumeFailureReport'); + expect(reportDoc, 'the docblock warns that the schema strips the code') + .toContain('SILENTLY STRIPS'); + expect(reportDoc, 'and names what the schema must not be used for') + .toContain('never as a way to obtain the report'); + // The regression guard proper: the sentence that caused the card is gone. + expect(reportDoc, 'the bare invitation to parse this member with the wire schema is gone') + .not.toContain('a caller that parses this member with'); + // Anti-vacuity: the docblock this reads is the real one, still making the + // claim the warning is about. + expect(reportDoc, 'the docblock still says the code is the member a success envelope cannot leave to its envelope') + .toContain('cannot leave to its envelope'); + }); + it('1. the wire schema keeps refusing what the report refuses — repairable is required, status is the two terminal failures', () => { // Anti-vacuity for the identity above: the inherited members carry the // wire schema's constraints, not merely its names.