Skip to content

Commit 5c28cc7

Browse files
os-billclaude
andauthored
docs(spec): the ResumeFailureReport docblock stops inviting the parse that strips its code (#18585)
Fixes #17929 Clause-②: no ## What changed The `ResumeFailureReport` docblock in `packages/spec/src/contracts/approval-service.ts` stops inviting a caller to parse that member with `ResumeFailureDetailsSchema`, and says loudly what that path actually does. **Prose and its pin only — no schema shape moves.** The docblock made two true statements in one paragraph: - a caller "that parses this member with `ResumeFailureDetailsSchema` reads the same three facts it reads off that door"; - `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 is true alone. Together they route a reader into losing exactly the member the second one calls indispensable — and the loss is the quiet kind. ## The legs, measured on this tree Taken first-hand against `5ed7ad9df8`, not carried over from the card: 1. **`code` is required** — `packages/spec/src/contracts/approval-service.ts:617`, `code: ErrorCode;`. The card's line number, unmoved. 2. **The docblock did invite the parse** — verbatim as it stood at `:573-576`: > 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. 3. **The schema does not declare `code`** — `packages/spec/src/api/automation-api.zod.ts:440`: `lazySchema` around a plain `z.object` with `runId` / `status` / `repairable`, no `.strict()` anywhere on it. 4. **The strip is SILENT** — measured, not inferred from the schema shape: `ResumeFailureDetailsSchema.safeParse(report)` answers `success: true` with `error: undefined`, and the returned object has no `code` key. No refusal, no `unrecognized_keys` issue, nothing logged. That silence is the whole defect; a loud refusal would be a lesser card. ## The fork, decided on the merits Two repairs existed and they are not equivalent: fix the prose, or declare `code` on `ResumeFailureDetailsSchema`. **The prose is the defective artefact**, for three independent reasons: - **The schema is correct where it is used.** It is the wire schema of the automation resume door's `400 FLOW_FAILED` `error.details`, and on that door the registered code rides on the `error` envelope the details sit inside. Declaring `code` on the details would put a second spelling of the failure class on that same answer — the duplication the #16472 family ruling avoided by declaring the structure once. - **It would contradict a landed pin.** `contracts/resume-failure-report.pin.test.ts` asserts a type-level identity: the report minus its `code` IS `ResumeFailureDetails`. Adding `code` to the details breaks that identity, so the "declared ONCE" claim would have to be re-litigated, not merely extended. - **It would widen a published accept surface** (`Clause-②` would become `yes`) on a schema whose prose is already before the maintainer on another card. See the serial note below. So the repair is the sentence, plus the mechanism that keeps the sentence honest. ## Tests `packages/spec/src/contracts/resume-failure-report.pin.test.ts` gains one case pinning **both halves** — the silence, and the prose that now warns about it: - `safeParse` of a full report succeeds, raises no issue at all, and yields no `code`; - the docblock carries the warning and names what the schema must not be used for; - the sentence that caused this card is **gone** (the regression guard proper); - anti-vacuity: the docblock still makes the claim the warning is about. Prose is unassertable except by reading it, so the contract source is read — the pattern this file already uses for the absence rule. **Reverse verification, two legs, each from the committed state, each proved on disk before it was run:** | leg | mutation (landing proved by grep count) | pin verdict | |---|---|---| | A — restore the defect | docblock reverted to its `5ed7ad9df8` text (invitation back: 1, `SILENTLY STRIPS`: 0) | **exit 1**, `1 failed, 6 passed` — `the docblock warns that the schema strips the code` | | B — kill the silence | `ResumeFailureDetailsSchema` switched to `z.strictObject` (`z.object`: 0, `z.strictObject`: 1) | **exit 1**, three cases red — `parsing a full report SUCCEEDS -- the strip does not refuse: expected false to be true` | Both restored with `git checkout HEAD -- path`, each verified by blob hash against the HEAD blob (`95f4ee4d…` / `c2abbdd6…`) and by an empty `git diff HEAD`; the restored tree runs the pin green again (7 passed) and `git status --porcelain` is empty. Neither mutation ships. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` off the merge base, reconciled with `--ran`: ``` ✓ dispatch-gates --ran: 83 derived famil(ies) accounted for — 79 run, 4 NOT-MEASURED (4 DERIVED from a recorded exit 3). 83 derived, 79 run, 4 NOT-MEASURED, 0 UNRUN ``` - `pnpm --filter @objectstack/spec build` → 0; `typecheck` → 0 (`tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck`, which compiles the pin file); `vitest run --project local` → 0, **483 files / 13776 tests passed**. - `pnpm lint` (repo-wide `eslint . --no-inline-config`) → **0**. No narrowing was needed, so no narrowing has to be justified. - **4 NOT MEASURED**, each a `PREREQUISITE NOT MET` (exit 3, which is not a finding): `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`. All four read built output of packages outside this diff and need a whole-repo build; declared to CI. - **One red that is not this PR's**: `pnpm check:cross-package-test-inputs` exits 1 on a tree where `packages/spec` has been built, and its finding names `packages/cli/test/init-created-files-summary.e2e.test.ts` descending `packages/spec/dist/` — no path of this diff. Already filed as #18353 / #18440; not re-filed here. ## Declared deviation — file surface The dispatch's declared surface was the docblock, the schema only if the repair required it, and a changeset. The repair required **no** schema edit, and none was made. It did take one file beyond the declaration: `packages/spec/src/contracts/resume-failure-report.pin.test.ts`, the pin that exists for this exact contract and already reads this exact file's prose for the absence rule. Same directory, same card, same gate family, no new verification surface, and no in-flight branch touches it (checked against every remote `claude/issue-*` head whose name names this area). Called out here so the deviation is visible rather than inferred. ## Acceptance notes - **Noted, not filed:** the same schema name is cited in four other docblocks (`packages/runtime/src/domains/automation.ts`, `packages/client/src/index.ts:5491`). Every one of those is about the **resume door**, where the schema is the right reader and the code is on the envelope — so none of them carries this defect. Read and left alone. - `packages/spec` ships `dist`, and the repaired prose really does ship: both `dist/contracts/index.d.ts` and `dist/contracts/index.d.mts` carry the new sentence after a build, with a positive control (a sentence already in that docblock) hitting the same two files. Hence a `patch` changeset rather than `skip-changeset`. ## Serial note — #17541 `#17541` concerns `ResumeFailureDetailsSchema.repairable`'s `.describe()` and sits in the decision box, unassigned and with no PR. This PR changes **no** shape and **no** `.describe()` on that schema — it writes no bytes under `packages/spec/src/api/` at all. Nothing here pre-empts that direction — it is out of scope here and stays open. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bd2c60 commit 5c28cc7

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)