Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/17929-resume-failure-report-schema-strip.md
Original file line number Diff line number Diff line change
@@ -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
25 changes: 19 additions & 6 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions packages/spec/src/contracts/resume-failure-report.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading