Skip to content

Commit 4d8617e

Browse files
committed
docs(spec): the ResumeFailureReport docblock stops inviting the parse that strips its code
`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 <noreply@anthropic.com>
1 parent 5ed7ad9 commit 4d8617e

3 files changed

Lines changed: 68 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`ResumeFailureReport`'s docblock no longer invites a caller to parse that member with `ResumeFailureDetailsSchema` — the one path that deletes the report's `code`, silently.
6+
7+
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.
8+
9+
- **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.
10+
- **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.
11+
- **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.
12+
13+
Clause-②: no

‎packages/spec/src/contracts/approval-service.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -571,12 +571,25 @@ export interface ApprovalRecallInput {
571571
* (`api/automation-api.zod.ts`), the structure the automation resume door
572572
* already publishes inside its `400 FLOW_FAILED` `error.details` (#15221, the
573573
* ruling's third carrier). They are inherited here, never re-spelled, so the
574-
* two cannot drift, and a caller that parses this member with
575-
* `ResumeFailureDetailsSchema` reads the same three facts it reads off that
576-
* door. What this adds is the one member a success envelope cannot leave to
577-
* its envelope: on the resume door the registered code is the answer's own
578-
* `code`; on a success answer nothing else names the failure class, so it
579-
* rides here as {@link code}.
574+
* two cannot drift. What this adds is the one member a success envelope
575+
* cannot leave to its envelope: on the resume door the registered code is
576+
* the answer's own `code`; on a success answer nothing else names the
577+
* failure class, so it rides here as {@link code}.
578+
*
579+
* ⛔ Which is exactly why `ResumeFailureDetailsSchema` is NOT the reader for
580+
* this member. That schema declares the three shared members and not
581+
* {@link code}, and it is a plain non-strict `z.object`: parsing a report
582+
* with it SILENTLY STRIPS the code — the parse SUCCEEDS, raises no
583+
* `unrecognized_keys` issue, logs nothing, and hands back an object whose
584+
* failure class is simply gone. The one member the paragraph above calls
585+
* indispensable is the one the act of validating removes, and nothing in the
586+
* result says so. On the resume door that schema is the right reader,
587+
* because there the registered code rides on the `error` envelope it parses
588+
* beside; here there is no envelope, so read {@link code} off the report
589+
* itself — it is typed `ErrorCode`, required, and needs no parse at all. Use
590+
* that schema on this member to read the three shared facts if you like,
591+
* ⛔ never as a way to obtain the report. Both halves are measured in
592+
* `contracts/resume-failure-report.pin.test.ts`.
580593
*
581594
* ⛔ No new error code is minted under the ruling. `code` is typed as
582595
* `ErrorCode` — the ADR-0112 vocabulary `ApiErrorSchema.code` parses

‎packages/spec/src/contracts/resume-failure-report.pin.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ function docblockOf(iface: string, member: string): string {
125125
return body.slice(docStart, memberAt);
126126
}
127127

128+
/** The docblock immediately above an exported interface, from the contract source. */
129+
function interfaceDocblockOf(iface: string): string {
130+
const declaredAt = CONTRACT_SOURCE.indexOf(`export interface ${iface} `);
131+
expect(declaredAt, `interface ${iface} is declared`).toBeGreaterThanOrEqual(0);
132+
const docStart = CONTRACT_SOURCE.lastIndexOf('/**', declaredAt);
133+
expect(docStart, `${iface} carries a docblock`).toBeGreaterThanOrEqual(0);
134+
return CONTRACT_SOURCE.slice(docStart, declaredAt);
135+
}
136+
128137
describe('[#16559] ResumeFailureReport — the resume failure a success answer carries (batch #76)', () => {
129138
it('1. the wire schema parses a report and hands the three shared members back out (declared once, measured)', () => {
130139
// 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
136145
expect(Object.keys(strandedParent).sort()).toEqual([...Object.keys(parsed), 'code'].sort());
137146
});
138147

148+
it('1. the wire schema strips that code SILENTLY, and the docblock no longer sends a caller down that path', () => {
149+
// [#17929] The type docblock used to invite a caller to parse this member
150+
// with the wire schema. The invitation deleted the one member the same
151+
// docblock calls indispensable, and the deletion is the quiet kind: a
152+
// non-strict `z.object` reports an undeclared key nowhere. Both halves of
153+
// the repair are pinned, because each rots on its own -- the silence is
154+
// the behaviour the prose must keep describing, and the prose is the only
155+
// thing standing between a reader and the path that loses the code.
156+
const parsed = ResumeFailureDetailsSchema.safeParse(strandedParent);
157+
expect(parsed.success, 'parsing a full report SUCCEEDS -- the strip does not refuse').toBe(true);
158+
expect(parsed.error, 'and it raises nothing: no `unrecognized_keys`, no issue at all').toBeUndefined();
159+
expect(parsed.data && 'code' in parsed.data, 'yet the failure class is gone from the output').toBe(false);
160+
161+
const reportDoc = interfaceDocblockOf('ResumeFailureReport');
162+
expect(reportDoc, 'the docblock warns that the schema strips the code')
163+
.toContain('SILENTLY STRIPS');
164+
expect(reportDoc, 'and names what the schema must not be used for')
165+
.toContain('never as a way to obtain the report');
166+
// The regression guard proper: the sentence that caused the card is gone.
167+
expect(reportDoc, 'the bare invitation to parse this member with the wire schema is gone')
168+
.not.toContain('a caller that parses this member with');
169+
// Anti-vacuity: the docblock this reads is the real one, still making the
170+
// claim the warning is about.
171+
expect(reportDoc, 'the docblock still says the code is the member a success envelope cannot leave to its envelope')
172+
.toContain('cannot leave to its envelope');
173+
});
174+
139175
it('1. the wire schema keeps refusing what the report refuses — repairable is required, status is the two terminal failures', () => {
140176
// Anti-vacuity for the identity above: the inherited members carry the
141177
// wire schema's constraints, not merely its names.

0 commit comments

Comments
 (0)