Skip to content

Commit cba4721

Browse files
Phase 5 — the three resilience pillars and the public authoring surface (#44)
* Phase 5a — retry engine, its two adapters, and the shared FakeTransport (#40) * feat(core): phase 5a — the retry engine, its two adapters, and the shared FakeTransport. Ships the retry pillar per product-spec/09-retry-and-resilience.md (RETRY-1..RETRY-45) and appendix C's RECOV-17..RECOV-34, following docs/superpowers/specs/2026-07-26-phase5a-retry-design.md. Per-requirement disposition in docs/superpowers/plans/2026-07-26-phase5a-retry-checklist.md. Executed out of numeric order, and 7a's first three tasks come with it. 5a's plan Prerequisite consumes 7a's config/{clock,http-date,retryable}.ts — Task 8 needs the Clock seam, Task 4 the shared RFC 1123 parser, Task 2 the retryable-status set — and its Global Constraints ban shipping private copies. Those three files are therefore built here verbatim from 7a's plan Tasks 1-3 (CFG-15..17, CFG-29..31, CFG-35). 7a's Tasks 4-10 are untouched, and none of the three enters the public barrel; 7a Task 10 still owns that decision. New packages/core/src/retry/, eight files, no folder barrel: - classify.ts — the two orthogonal axes (RETRY-1..8, 37). Retryability is an allow-list over an iterative, identity-tracking cause walk, which is what makes RETRY-25's fatal-error exclusion vacuous rather than coded: an unlisted throwable was never opted in. RETRY-23 vs RETRY-24 keys off the abort reason's name — AbortSignal.timeout() produces TimeoutError, a caller abort AbortError — which draws the line more precisely than the class hierarchy the reference describes. - backoff.ts, pacing.ts — the pure math and the server-hint parser. Totality is pacing.ts's defining property (RETRY-16): it never throws, and every failure path returns null, never 0, because 0 means "retry immediately" and is the opposite of what a server sending a malformed header is asking for. - settings.ts — RETRY-12's defaults, RECOV-34's construction validation, and totalTimeoutMs opt-in per RETRY-28's instruction to a unifying port. - engine.ts — one attempt loop, reached by both adapters, so RETRY-13/14 and RECOV-30's "must not drift" is structural rather than a discipline. - attempt-stamp.ts, retry-step.ts, retry-dispatch.ts — per-attempt stamping and the two ~30-line adapters. retryStep() closes PIPE-36 structurally: it is a factory returning a descriptor with stage: 'RETRY' baked in, so there is no class to subclass and no way to relocate it out of its pillar. Plus recovery/idempotency-key.ts (RECOV-32) and testing/fake-transport.ts, closing the roadmap's twice-punted FakeTransport deferral. countingResponse() counts release by both routes it can happen — cancel() for an abandoned response, pull()-to-EOF for one toHttpError() drained — because a helper counting cancel() alone reads zero on exactly the RETRY-35 path it exists to prove. Response instances are frozen, so the body stream is the only sanctioned observation point. StepContext gains signal and options (Task 1, additive). Cursor already carried both and threaded them into terminal dispatch, but no step could read either: RETRY-26's cancellable wait and RETRY-32 were unimplementable without the signal, and PIPE-17's "readable by any step" MUST was unsatisfied outright without the options — which is also the wire RETRY-41's per-call maxRetries override (HTTP-35) had been missing since Phase 1 designed the knob. Two dispositions worth reading before changing this code. RETRY-36's remap applies only to responses the engine discards: a response surviving the gates is returned live and unread, because toHttpError() drains the body and drops the headers irreversibly, and 4c's pillar signature must return a Response. And RETRY-41's "clamp a negative retry count to the default" is implemented as a rejection — it collides head-on with HTTP-35, also a MUST, which rejects precisely so the value cannot be silently reinterpreted; the port takes HTTP-35's line on both surfaces. Both are in the design's deviation ledger. Also tightens RequestOptionsBuilder.maxRetries to require a non-negative integer (changeset included). It rejected only value < 0, so Infinity and NaN reached a consumer as a retry ceiling that never terminates: unlike a negative value, which still fails a downstream >= 1 guard, a non-finite one makes "attempt >= ceiling" permanently false and the loop unbounded. Guarded at three layers — the setter, the step's per-call derivation, and a precondition in runWithRetry, the one choke point both adapters share. Not included, each recorded rather than left silent: RETRY-29 (MAY, unscheduled — it widens the classifier's input to server-controlled values and wants its own trust decision), RECOV-33 (Phase 7a Task 9), the two structured log events and RETRY-40's log-the-failure clause (Phase 7b Task 9 — 5a runs before 7b, and 7b needs this commit's FakeTransport, so the cycle only breaks in this direction), and public-barrel promotion of the step-authoring surface (Phase 5c, once the preset exists). open-items.md carries the review findings deliberately left open, including the same teardown-masking shape in Phase 3b's toHttpError. Nothing reaches the public barrel: packages/core/etc/core.api.md and packages/core/src/index.ts are byte-identical. 867 unit tests, plus a node-conformance case for the three runtime-divergent surfaces this phase touches — the TimeoutError naming the classifier keys off, the suppressed-trail shape across the native/fallback split, and the real timer/abort race inside defaultClock.sleep. Full gate sequence green. * fix(core): escape the TSDoc '>' that fails the API surface check. * feat(core): phase 5b — the redirect pillar step and its marker guard. (#41) Ships the redirect pillar per product-spec/10-redirect-handling.md (REDIR-1..REDIR-27), following docs/superpowers/specs/2026-07-26-phase5b-redirect-design.md, and closes the roadmap's PIPE-40 deferral. Per-requirement disposition in docs/superpowers/plans/2026-07-26-phase5b-redirect-checklist.md. New packages/core/src/redirect/, seven files, no folder barrel: - codes.ts — the recognized {301,302,303,307,308} set and per-code method eligibility (REDIR-1..5). 303 is the only status branched on, so the four method-preserving codes cannot drift apart. - cross-origin.ts — the RFC 6454 origin tuple compared against the SEED (REDIR-8) and the credential-suppression marker (REDIR-11). - settings.ts — REDIR-17's defaults, REDIR-26's defensive copy, REDIR-27's configurable header. maxHops: 0 needs no special branch; it is the one value the ordinary cap gate always fails. - decide.ts — the pure per-hop decision. No I/O, no clock, no side effects beyond the Request it returns. - redirect-step.ts — the REDIRECT pillar. Every dispatch, including the first, takes a fresh ctx.fork(); ctx.next()'s single-invocation guard would trip on hop two (PIPE-15). stage: 'REDIRECT' is baked into the descriptor, which is how PIPE-36 is satisfied structurally. - strip-marker-step.ts — a POST_AUTH guard plus withRedirect(). - errors.ts — NonReplayableBodyError, SchemeDowngradeError. The cross-origin signal is a real header, not an in-process marker. A WeakSet<Request> is unforgeable and never touches the wire, but stage order is REDIRECT -> RETRY -> AUTH and 5a's attempt-stamping builds a fresh per-attempt Request copy when enabled — an identity-keyed signal would silently stop matching exactly when a retry sits between redirect and auth, which is when cross-origin credential suppression matters most. Stamping preserves headers, so a header survives that copy. REDIR-11 names its own porter caveat: in the reference only the auth step strips the marker, so a pipeline with none forwards it to the transport. 5b ships before 5c, so that is not a future concern here — it is a live leak this phase would otherwise ship. stripCrossOriginMarkerStep() occupies 4c's inert POST_AUTH slot, so nothing in 4c or 5c had to change, and it stays installed as a redundant backstop once 5c's auth step becomes the marker's real consumer. Two origin-shaped checks, two deliberately different reference points, easy to conflate. Cross-origin classification compares against the SEED for the whole chain (REDIR-8), so a foreign host cannot hand the credential back by redirecting to the seed's own origin. The downgrade guard compares the CURRENT hop against its target (REDIR-15), so an HTTPS->HTTP->HTTPS chain flags only the hop that actually downgraded. Location resolution ends with an explicit http:/https: gate. WHATWG URL parses javascript:, data:, file:, and mailto: without complaint and the downgrade guard waves all of them through (none is http:), so without the gate the step would dispatch a server-supplied javascript: target. The catch around new URL(raw, base) is a genuinely narrow path, not the general garbage guard it looks like: with a base supplied, a non-URL string resolves as a relative reference rather than throwing. One normative conflict, resolved and recorded rather than silently picked. PIPE-40 and REDIR-22 disagree, both at MUST, about the non-replayable-body path: PIPE-40 lists it among the responses "returned unclosed", REDIR-22(b) lists the same trigger among those "closed before the error propagates". REDIR-6 settles the control flow — that path "MUST fail with a clear error" — so it throws, and a response never returned cannot be returned unclosed; §10 also governs the redirect step's own lifecycle over the cross-cutting default, and closing is the safer reading, since the alternative leaks a body with no caller holding a reference. 5b closes and throws. One of the two spec sentences needs an erratum either way; deferred to Phase 10 and recorded in the design's Deviation Ledger and at open-items G1. REDIR-20's "fully override" is read as scoped to code/method eligibility only, not as license to bypass userinfo stripping, credential hygiene, the downgrade guard, replayability, or loop/cap detection — those are unconditional MUSTs elsewhere in the same chapter, and a predicate opting to follow a 307 with a single-use body still cannot make that body re-sendable. A judgment call on ambiguous wording; narrow to reverse, and flagged for Phase 9. One file lands outside redirect/. Review pass 1 found both close-before-throw paths replacing the very error they were meant to propagate, because Response.close() rethrows whatever cancelling the body raised. The fix needed releaseQuietly/withReleaseFailure, module-private inside 5a's retry/engine.ts; rather than a second copy of a helper whose identity guard is load-bearing they move to recovery/release.ts and both call sites import them. Behavior-neutral for 5a — the diff is one import added and the two functions removed verbatim, and 5a's suite passes untouched. The third close, releasing a superseded hop before the next drive, stays bare: there is no primary error to preserve and PIPE-40 makes the release itself part of the contract. REDIR-28's structured events, and REDIR-15's separate "surface it observably" obligation on a permitted downgrade, are NOT implemented. 5b executes before 7b, so an observability/logger.js import would not resolve, and 7b needs this step for its own retrofit test — the dependency cannot run the other way. 7b's Task 9 owns them, named in redirectStep()'s TSDoc. Two of the four events stay blocked even after that, behind a reason discriminant decide()'s 'return-current' variant does not carry; open-items G3. Nothing reaches the public barrel: core.api.md and src/index.ts are unchanged, and redirect/ gets no index.ts. 5c's promotion task is the first point any pillar-authoring surface goes public. Phase 5b's open and deferred items are registered as open-items.md section G, with its cross-phase deferrals in section D. That pass also found 4c and 5a were never registered at all; their absence there means "not reviewed", not "nothing found", and the file's header now says so. Gates: typecheck, lint, build, bun test --coverage (991), api, lint:publish, verify:dual-consumption, verify:consumer-types, verify:seam-1, verify:runtime-floor, audit, and test:node on both matrix legs — 20.3.0 and lts/* (v24.20.0) — all run on the pinned bun 1.3.14 rather than the local toolchain. The floor leg matters for this change specifically: Node 20.3.0 has no native SuppressedError, so recovery/release.ts takes the fallback branch there and the native one on 24. * feat(core): phase 5c — the auth pillar step and the public authoring surface. (#42) Ships the authentication layer per product-spec/11-authentication.md (AUTH-1..AUTH-38), following docs/superpowers/specs/2026-07-26-phase5c-auth-design.md, and closes four roadmap deferrals: PIPE-35's seedFrom, AUTH-29's marker-consumption side (5b produced the marker), PIPE-24/PIPE-39's standard-resilience preset, and public-barrel promotion of the pillar-authoring surface. Per-requirement disposition in docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md. New packages/core/src/auth/, fifteen files, no folder barrel: - scheme.ts, requirement.ts, descriptor.ts, resolve.ts — the descriptor/ resolver model (AUTH-1..7). Pure data shapes and pure functions, no classes, so AUTH-7's "stateless, concurrency-safe, deterministic" falls out of the structure rather than being asserted about it. Tier selection is perCall ?? operation ?? client: the first tier PRESENT, never the first that succeeds, so a present-but-unsatisfiable override fails rather than silently demoting to a weaker one. - credential.ts — BearerToken, ApiKeyCredential, NameKeyCredential, TokenProvider (AUTH-8..11). Two shapes for two equality requirements. - challenge.ts — the RFC 7235 parser (AUTH-12/13), total by construction. - md5.ts — RFC 1321, hand-rolled. Web Crypto excludes MD5 on security grounds and RFC 7616 still requires it for interop, so the alternatives were an npm dependency (SEAM-1) or node:crypto (portability). - basic.ts, digest.ts, static-key.ts, composing-handler.ts — the stamping handlers (AUTH-14..26). - bearer-cache.ts — the single-flight three-zone token cache (AUTH-34..37). - auth-step.ts — the AUTH pillar (AUTH-27..33, 36, 38). - preset.ts — standardResilience() (PIPE-24/39). AUTH-27 mandates exactly one auth step, yet AUTH-30 names "the challenge hook" and AUTH-34..37 name "the bearer auth step" as if three things. Reconciled as one step, one pluggable challengeHook, and a scheme-dependent default body. AUTH-30's contract — consult the hook, close the original on a non-null replacement, re-drive once through a fresh chain copy, no nested re-challenge — governs every scheme uniformly; AUTH-23..26 and AUTH-34..37 describe what the DEFAULT does per resolved scheme. It is the only reading that satisfies all four and leaves both named mechanisms a home. Basic and Digest never stamp preemptively. Both are phrased entirely in terms of answering a parsed challenge, and Digest structurally cannot stamp before seeing the server's realm/nonce. OAUTH2 and API_KEY do; NO_AUTH never does. Flagged as an interpretation rather than a certainty — §11 states it neither way — and routed to Phase 9's sweep against any reference fixtures it turns up. The cross-origin marker suppresses the WHOLE hop, not just the outbound pass. 5b marks a cross-origin re-issue; this step is its intended consumer. It reads the marker, clears it unconditionally before either branch so it cannot reach the wire, skips both the HTTPS guard and stamping — and declines to answer a 401 on that hop. Answering would stamp exactly the credential the outbound pass withheld, onto a server-chosen foreign host, over a URL whose guard was deliberately skipped. The joint 5b+5c conformance test asserts a credential present on hop one, absent on the cross-origin hop, and re-stamped on return to the seed origin, which is also PIPE-2's per-redirect-hop clause. RequestOptions gains auth?: AuthDescriptor, giving AUTH-4's most-specific tier a genuinely per-call source through StepContext.options (PIPE-17). The operation tier still has none; no per-operation layer exists in this roadmap. PipelineBuilder.seedFrom(runtime, 'flatten' | 'nest') has no default mode, per PIPE-35's MUST that the choice be explicit rather than accidental. Runtime gains a transport getter, without which flatten is not implementable at all. THE PUBLIC BARREL CHANGES, for the first time since Phase 1. 5c is the first point a caller can assemble a working pipeline, which is why every prior phase withheld this. Promoted: Stage, STAGE_ORDER, PILLAR_STAGES, Step, StepContext, Next, StepDescriptor, PipelineBuilder, Runtime, retryStep, redirectStep, authStep, standardResilience — plus every type those signatures name, because a promoted function whose parameter type is @internal is an API a caller cannot call. Everything else under auth/ stays internal: a caller builds an AuthStepSettings from the exported factories, never handler internals. Review pass 1 found the promotion was not in the shipped artifact. The literal string "@internal" inside an explanatory comment above the context export made TypeScript's stripInternal — which substring-matches a declaration's whole leading comment range — delete the export outright, so ExecutionContext and its three members never reached dist/index.d.ts. api:ci was green because api-extractor had recorded the ae-forgotten-export warning as text INSIDE the committed report and was comparing report to report. Three changes, because the bug class matters more than the instance: the comment is reworded, ae-forgotten-export is now logLevel "error" so a forgotten export fails the gate, and verify-consumer-types.mjs compiles a consumer naming the whole promoted surface — the check this phase's plan claimed as its pass condition and which did not exist. Runtime's constructor is now private behind a createRuntime friend hook. Promoting the class published a field-wise constructor that bypassed every invariant build() enforces: new Runtime([authStep(a), authStep(b)], t) compiled and ran both, a direct AUTH-27 violation with no PillarCollisionError. Same reasoning CLAUDE.md already gives for every model in http/. BearerToken is a class, not an interface. AUTH-8 requires every credential type to redact its secret in any string or diagnostic form, and a frozen object literal prints and JSON-serializes its token in full. Making it nominal also closed an AUTH-9 bypass nobody had filed: TokenProvider returns BearerToken, so a provider could hand back an object literal and skip blank-token validation. Three defects review pass 2 found in this phase's own code, each now with a regression test. A rejecting Response.close() REPLACED the primary error on both AUTH-32 paths — a typed, catchable, @throws-documented PlaintextCredentialError silently becoming a teardown failure — in a file written one phase after 5b solved exactly that with releaseQuietly/ withReleaseFailure; both sites now use them, and RECOV-12's "never masks the primary" holds. A one-shot request body skipped the challenge hook entirely, so AUTH-36's eviction never fired and a revoked never-expiring token was re-sent forever; the fast path is deleted and only the replay dispatch is gated on replayability, which is what AUTH-31 actually says. And an unvalidated bearerMarginMs of NaN made every expiry comparison false, serving an expired token indefinitely; both margin doors now validate, per retrySettings' and redirectSettings' precedent. Single-flight no longer hands one caller's AbortSignal to shared work. A coalesced fetch is by definition not owned by one call, so A's abort was cancelling B's token fetch while B's own signal was inert. The shared fetch now takes no caller signal and each caller races the shared promise against its own; TokenProvider is back to the design doc's zero-argument shape, and a provider bounds itself with its own AbortSignal.timeout. A background refresh no longer kills the host process. An earlier shape re-threw an InvariantViolation out of the fire-and-forget catch, reasoning that programmer errors must crash loudly. But the throwable there comes from caller-supplied provider code, and re-raising it into a detached promise terminates the consumer's process asynchronously and unattributably — while the request that triggered it was served the still-valid cached token. AUTH-37 says a failed background refresh MUST NOT fail the in-flight request, full stop. The crash-loudly rule governs our own invariants where we detect them. A non-ASCII Digest realm no longer throws out of the step. Headers.setInbound accepts obs-text and Headers.set rejects it, so echoing a server's UTF-8 realm into Authorization threw HeaderValidationError — meaning AUTH-21's UTF-8 branch hashed correctly but could never reach the wire. parseDigestChallenge now declines such a challenge, so canHandle is false and the 401 surfaces unchanged per AUTH-33; a non-header-safe configured username fails fast at construction instead. RFC 7616 username* (RFC 5987) encoding is deferred and recorded. Review pass 3 read whole files rather than the diff and found a test whose NAME asserted behavior pass 2 had deleted, proving by mutation that it passed under both shapes. The same mutation sweep found four documented behaviors with no test that could fail: the abort-listener cleanup a comment promises, the Proxy-Authorization half of AUTH-28's replay guard — where a proxy credential could go out over plaintext with the suite green — the per-credential margin override, and AUTH-34's own 30-second default, now bracketed at 29999/30001 ms because a one-sided assertion admitted any margin above 20s. isProxy is gone from the handler interfaces: no implementation read it, and AUTH-25's choice lives in the step, where the header name is actually picked. Deferred, each recorded rather than left silent: standardResilience() gains loggingStep in Phase 7b Task 9 — 5c executes first, so an observability/logging-step.js import would not resolve, and the plan's own 2026-07-29 correction says to skip its retrofit blocks; AUTH-37's log-and-continue half, which has nowhere to go until a Logger exists; re-verification of the preemptive-stamping reading at Phase 9; RFC 7616 username*; and a per-operation AuthTiers source, which is unscoped. DigestChallengeUnsupportedError was cut instead — its only justification was a caller driving digestHandler() directly, which is internal, and removing an exported error class later would be a breaking change. open-items.md gains G10..G13: the context family and Step promoted beyond the plan's list as an accepted risk with Phase 7a as the trigger, the cut error leaf, AUTH-37's deferred logging, and two pre-existing cleanups this phase deliberately did not take. 1247 unit tests across 94 files, plus a node-conformance suite for the four runtime-divergent surfaces this phase touches — crypto.subtle.digest against RFC 7616 vectors, crypto.getRandomValues for AUTH-20's client nonce, btoa's UTF-8-vs-Latin-1 encoding for Basic, and the AbortSignal listener add/remove and Promise.race settling order the coalescing race rests on. Every one fails silently rather than loudly if Bun and Node disagree: a wrong digest is still well-formed hex. Full gate sequence green, including api:ci against the regenerated report and test:node on both matrix legs. * docs: add changeset for retry pillar and engine.
1 parent 63ed1b7 commit cba4721

102 files changed

Lines changed: 16747 additions & 84 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@dexpace/core": patch
3+
---
4+
5+
Tighten `RequestOptionsBuilder.maxRetries` validation: a defined value must now be a non-negative
6+
integer. `Infinity`, `NaN`, and fractional values were previously accepted and now throw
7+
`RequestOptionsValidationError`, the same way a negative value already did.
8+
9+
A retry ceiling is a count of wire sends, so a non-finite one is as out of range as a negative one —
10+
and worse in effect: a negative value still fails a downstream `>= 1` guard, while `Infinity` or
11+
`NaN` makes a retry driver's `attempt >= ceiling` test permanently false and its loop unbounded.
12+
HTTP-35's requirement is that an out-of-range retry count is a loud error at the call site that
13+
supplied it, never a value reinterpreted somewhere downstream; this closes the half of that
14+
requirement the setter did not implement.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
---
2+
'@dexpace/core': patch
3+
---
4+
5+
Add the retry pillar for product-spec §9 (`RETRY-1``RETRY-45`) and appendix C's `RECOV-17``RECOV-34`, plus
6+
the Phase 7a `config/` prerequisite slice and the shared `FakeTransport`. No public API change.
7+
8+
Everything this adds lives under `packages/core/src/{retry,config,testing}/` and none of it is re-exported
9+
from `src/index.ts``packages/core/etc/core.api.md` is byte-identical before and after. `patch` rather than
10+
an empty changeset because files under `packages/` did change: the published tarball carries the new
11+
`dist/retry/*.js`, `dist/config/*.js`, and `dist/testing/*.js`, and a consumer stepping through the package in
12+
a debugger will see them. (The one behavior change a caller can observe from outside — tightening
13+
`RequestOptionsBuilder.maxRetries` to a non-negative integer — ships under its own changeset.)
14+
15+
Public-barrel promotion of `retryStep` and the step-authoring surface is deliberately **not** in this release.
16+
A caller cannot assemble a working pipeline until the standard-resilience preset exists, and publishing
17+
`retryStep` alone would freeze `StepDescriptor`/`Stage`/`PipelineBuilder` shapes that still had latitude to
18+
move. Phase 5c owns that promotion.
19+
20+
## What landed
21+
22+
`packages/core/src/retry/`, eight files, no folder barrel:
23+
24+
- **`classify.ts`** — the two orthogonal axes (`RETRY-1``RETRY-8`, `RETRY-37`). Retryability is an
25+
ALLOW-list over an iterative, identity-tracking cause walk; `isResendable` is the second axis over
26+
`Body.replayable` and Phase 1's `isIdempotent`.
27+
- **`backoff.ts`, `pacing.ts`** — the pure math and the server-hint parser, split away from the imperative
28+
loop.
29+
- **`settings.ts`**`RETRY-12`'s defaults, `RECOV-34`'s construction validation, and `totalTimeoutMs` as an
30+
opt-in.
31+
- **`engine.ts`** — the one attempt loop both adapters reach.
32+
- **`attempt-stamp.ts`, `retry-step.ts`, `retry-dispatch.ts`** — per-attempt stamping and the two thin
33+
adapters: the `RETRY` pillar step and the recovery-chain wrapper.
34+
35+
Plus `recovery/idempotency-key.ts` (`RECOV-32`) and `testing/fake-transport.ts`, which closes the roadmap's
36+
twice-punted `FakeTransport` deferral.
37+
38+
Two files outside those folders changed, both additively. `StepContext` gains `signal` and `options`
39+
(`PIPE-13`/`PIPE-17`): `Cursor` already carried both and threaded them into terminal dispatch, but no step
40+
could read either, so `RETRY-26`'s cancellable wait and `RETRY-32` were unimplementable and `PIPE-17`'s
41+
"readable by any step" MUST was unsatisfied outright — which is also the wire `RETRY-41`'s per-call
42+
`maxRetries` override (`HTTP-35`) had been missing since Phase 1 designed the knob.
43+
44+
## Executed out of numeric order: the Phase 7a prerequisite slice
45+
46+
`config/clock.ts` (`CFG-15``CFG-17`), `config/http-date.ts` (`CFG-29``CFG-31`), and `config/retryable.ts`
47+
(`CFG-35`) are built here, verbatim from Phase 7a's plan Tasks 1–3, because 5a's Global Constraints ban
48+
shipping the private copies that would otherwise be needed: Task 8 consumes the `Clock` seam, Task 4 imports
49+
the shared RFC 1123 parser, and Task 2 re-exports the shared retryable-status set instead of defining it a
50+
second time. Phase 7a's Tasks 4–10 are untouched, and none of the three enters the public barrel — 7a's Task
51+
10 still owns that decision.
52+
53+
## Design calls worth recording
54+
55+
- **One retry loop, reached by both adapters.** `RETRY-13`/`RETRY-14` and `RECOV-30` require the pillar stack
56+
and the recovery-chain stack not to drift. `runWithRetry` is the single choke point both call, so the
57+
schedule, the classifier, and the budget cannot diverge — structural, not a discipline. Every piece of
58+
per-call state is a local (`RETRY-42`/`RECOV-28`), so concurrent invocations sharing one config cannot
59+
clobber each other's attempt count or start instant.
60+
- **`RETRY-25`'s fatal-error exclusion needs no code.** Because classification is an allow-list, a
61+
stack-overflow `RangeError` is non-retryable for never having been opted in, not for having been screened
62+
out. A caller `AbortError` is likewise non-retryable for free (`RETRY-23`), while `TimeoutError` is
63+
explicitly listed (`RETRY-24`) — keying off the abort reason's `name` draws that line more precisely than
64+
the class hierarchy the reference describes.
65+
- **The pacing parser is total, and a failure never maps to `0`.** `RETRY-16` makes never-throwing the
66+
defining property; every malformed, negative, or out-of-range value maps to `null` ("no hint", fall back to
67+
backoff). `0` is reserved for a validly-parsed instant already in the past (`RETRY-17`) — mapping a
68+
malformed header to `0` would hammer a server that just asked for room. `X-RateLimit-Reset` receives
69+
`RECOV-25`'s positive [100%, 120%] jitter so a fleet released at one reset instant does not stampede; a
70+
literal `Retry-After` receives none (`RETRY-20`).
71+
- **`RETRY-36`'s remap applies only to responses the engine DISCARDS.** A response surviving the gates is
72+
returned live and unread: `toHttpError()` drains the body and drops the headers irreversibly, and 4c's
73+
pillar signature must return a `Response`. This is also why the pacing hint is read BEFORE the retire step
74+
— that ordering is load-bearing, not stylistic.
75+
- **`RETRY-27`'s budget clause is implemented as three separate checks, deliberately.** A delay that would
76+
push cumulative elapsed time past the budget SUPPRESSES the retry and surfaces the last failure; the
77+
`Math.min` clamp beside it is the requirement's separately-listed belt-and-braces clause and narrows
78+
nothing except across clock drift between two `elapsed()` reads. It ships because the requirement lists it
79+
separately, not because a test can drive it.
80+
- **A non-finite retry ceiling is guarded at three layers.** Unlike a negative value, which still fails a
81+
downstream `>= 1` guard, `Infinity` or `NaN` makes `attempt >= ceiling` permanently false and the loop
82+
unbounded. The setter, the step's per-call derivation, and a `runWithRetry` precondition each reject it —
83+
the precondition being the one choke point both adapters pass through.
84+
- **`RETRY-41`'s "clamp a negative retry count to the default" is implemented as a REJECTION.** It collides
85+
head-on with `HTTP-35`, also a MUST, which rejects precisely so the value cannot be silently reinterpreted
86+
downstream. The port takes `HTTP-35`'s line on both surfaces; recorded in the design's Deviation Ledger.
87+
- **The inter-attempt wait delegates to `Clock.sleep`.** `CFG-17` already races the timer against the signal,
88+
clears it on both exits (`RETRY-45`'s scheduler hygiene, which has no scheduler object to own in this
89+
port), and rejects promptly for a signal that aborted earlier. Hand-rolling a second `setTimeout`-plus-
90+
listener would put the wait outside the injected seam and force real timers into a suite that must stay
91+
deterministic. Cancellation RESOLVES rather than propagates, so the loop's next iteration observes the
92+
signal and stops through its own `RETRY-32` path.
93+
- **`RETRY-33`'s "every terminal path returns an Outcome" is honored literally.** An attempt that throws is
94+
folded into a failure outcome carrying the trail rather than left to surface as a bare rejected promise,
95+
which would drop `RETRY-34`'s suppressed attempts on the floor. The trail folds through Phase 4b's
96+
`suppress()` helper, not `new SuppressedError(...)`: the native class reached Node only in 24.0.0 and this
97+
package's floor is `>=20.3`. Argument order is controlled explicitly — native `using` disposal builds the
98+
pair the other way round, making the LATER error primary.
99+
- **`RETRY-30`'s trampoline requirement is satisfied by the language.** An `await` loop is already iterative,
100+
so N retries build no continuation chain and no stack growth.
101+
- **`PIPE-36` is satisfied structurally.** `retryStep()` is a factory returning a descriptor with
102+
`stage: 'RETRY'` baked in — no class to subclass, no way for a caller to relocate a shipped pillar family
103+
out of its pillar. 4c deferred this to "whichever future phase ships the first real pillar step family";
104+
this is that phase.
105+
- **`countingResponse()` counts release by BOTH routes it can happen**`cancel()` for an abandoned
106+
response, `pull()`-to-EOF for one `toHttpError()` drained. A helper counting `cancel()` alone reads zero on
107+
exactly the `RETRY-35` path it exists to prove.
108+
109+
## Known gaps, each recorded rather than left silent
110+
111+
- **`RETRY-29`** (opt-in server-driven retry-classification override) is a `MAY` and is unscheduled: it
112+
widens the classifier's input surface to server-controlled values and wants an explicit trust decision, not
113+
a default.
114+
- **`RECOV-33`** (client-identity header step) belongs with the `CFG-*` work and is Phase 7a's Task 9.
115+
- **`RETRY-40`'s "log the failure" clause and the two SHOULD-level structured events** (`retry.attemptFailed`,
116+
`retry.exhausted`) are not implemented here. 5a executes before 7b, so an `observability/logger.js` import
117+
would not resolve; 7b in turn needs this phase's `FakeTransport`, so the cycle only breaks in this
118+
direction. Phase 7b's Task 9 owns them, named in `engine.ts`'s retrofit note.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
'@dexpace/core': patch
3+
---
4+
5+
Add the redirect-following pillar step for product-spec §10 (`REDIR-1``REDIR-27`) and close `PIPE-40`. No
6+
public API change.
7+
8+
Everything this adds lives under `packages/core/src/redirect/` and none of it is re-exported from
9+
`src/index.ts``packages/core/etc/core.api.md` is byte-identical before and after. `patch` rather than an
10+
empty changeset because files under `packages/` did change: the published tarball carries the new
11+
`dist/redirect/*.js`, and a consumer stepping through the package in a debugger will see them.
12+
13+
One file landed outside `redirect/`: `packages/core/src/recovery/release.ts`, which is
14+
`releaseQuietly`/`withReleaseFailure` extracted unchanged from `retry/engine.ts`. The redirect step needs
15+
the same "a teardown failure never becomes primary" discipline `RECOV-12` already required of retry, and
16+
the helper's identity guard is subtle enough that a second copy would drift. `engine.ts` now imports what
17+
it used to define; its behavior and its suite are unchanged.
18+
19+
What landed: `codes.ts` (the recognized `{301,302,303,307,308}` set and per-code method eligibility),
20+
`cross-origin.ts` (the RFC 6454 origin tuple compared against the seed, plus the credential-suppression
21+
marker header), `settings.ts` (validated, frozen policy with a defensively copied allowed-method set),
22+
`decide.ts` (the pure per-hop decision), `redirect-step.ts` (the `REDIRECT` pillar adapter), and
23+
`strip-marker-step.ts` (a `POST_AUTH` guard plus `withRedirect()`). Two new operational error leaves,
24+
`NonReplayableBodyError` and `SchemeDowngradeError`, both `@internal` for now.
25+
26+
Four design calls worth recording:
27+
28+
- **The cross-origin suppression signal is a real header, not an in-process marker.** A `WeakSet<Request>`
29+
keyed by object identity is unforgeable and never touches the wire, but stage order is
30+
`REDIRECT → RETRY → AUTH` and 5a's attempt-stamping builds a fresh per-attempt `Request` copy when
31+
enabled — an identity-keyed signal would silently stop matching exactly when a retry sits between
32+
redirect and auth, which is when cross-origin credential suppression matters most. Stamping preserves
33+
headers, so a header survives the intermediate copy.
34+
- **A second, always-bundled step strips that marker independently of whether an auth step exists.**
35+
`REDIR-11` itself names the porter caveat: in the reference only the auth step strips the signal, so a
36+
pipeline with none forwards it to the transport. 5b ships before 5c, so that is not a future concern
37+
here — it is a live leak this phase would otherwise ship. `stripCrossOriginMarkerStep()` occupies 4c's
38+
inert `POST_AUTH` extension slot, so nothing in 4c or 5c had to change.
39+
- **Two origin-shaped checks, two deliberately different reference points.** Cross-origin classification
40+
compares against the **seed** origin for the whole chain (`REDIR-8`), so a foreign host cannot hand the
41+
credential back by redirecting to the seed's own origin. The scheme-downgrade guard compares the
42+
**current hop** against its target (`REDIR-15`), so an HTTPS→HTTP→HTTPS chain flags only the hop that
43+
actually downgraded. Conflating them silently breaks one or the other.
44+
- **A failing release never replaces the error it was supposed to let through.** `Response.close()`
45+
rethrows whatever cancelling the body raised, so the two error paths that close before propagating
46+
(`decideOrClose`, and the `'fail'` branch's `SchemeDowngradeError`) route through
47+
`withReleaseFailure`: the decision error stays primary and the release failure rides along as
48+
`suppressed`. The third close — releasing a superseded hop before the next drive — is deliberately
49+
left bare, because there is no primary error to preserve and `PIPE-40` makes the release itself part
50+
of the contract.
51+
- **Location resolution ends with an explicit `http:`/`https:` gate.** WHATWG `URL` parses
52+
`javascript:`, `data:`, `file:`, and `mailto:` without complaint, and the downgrade guard waves all of
53+
them through (none is `http:`). Without the gate the step would dispatch a server-supplied
54+
`javascript:` target. The `catch` around `new URL(raw, base)` is a genuinely narrow path, not the
55+
general garbage guard it looks like: with a base supplied, a non-URL string resolves as a relative
56+
reference rather than throwing.
57+
58+
One normative conflict, resolved and recorded rather than silently picked: **`PIPE-40` and `REDIR-22`
59+
disagree, both at `MUST`, about the non-replayable-body path.** `PIPE-40` lists it among the paths whose
60+
in-flight response is "returned unclosed"; `REDIR-22`(b) lists the same trigger among those "closed before
61+
the error propagates". `REDIR-6` settles the control flow — that path "MUST fail with a clear error" — so it
62+
throws, and a response never returned cannot be returned unclosed. 5b closes and throws; the contradiction
63+
is in the design's Deviation Ledger and deferred to Phase 10, which owns the erratum either way.
64+
65+
Two known gaps, both recorded in the phase checklist:
66+
67+
- **`REDIR-28`'s structured hop/loop/downgrade log events, and `REDIR-15`'s "surface it observably" clause
68+
on a permitted downgrade, are not implemented here.** Phase 5b executes before Phase 7b, so
69+
`redirect-step.ts` cannot import `observability/`, and 7b needs this step for its own retrofit test —
70+
the dependency cannot run the other way. Phase 7b's Task 9 owns them, named in `redirectStep()`'s TSDoc.
71+
- **`REDIR-20`'s predicate override is read as scoped to code/method eligibility only.** A configured
72+
predicate replaces the built-in follow decision; it does not bypass userinfo stripping, credential
73+
hygiene, the downgrade guard, the replayability gate, or loop/cap detection, all of which the same spec
74+
document states as unconditional `MUST`s. Logged in the design's Deviation Ledger for Phase 10 and
75+
flagged for re-confirmation at Phase 9's conformance sweep.

0 commit comments

Comments
 (0)