Skip to content

Phase 6b: redirect — the follower and the standard constructors - #78

Merged
Wahbeh-Mohammad merged 5 commits into
mainfrom
23-phase-6b-redirect
Sep 19, 2026
Merged

Wahbeh-Mohammad merged 5 commits into
mainfrom
23-phase-6b-redirect

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Part of #23. First PR of phase 6b's three-PR stack — the code — and the last lane of phase 6: cut from main at e61864f, which holds phase 6a (#72–#74) and phase 6c (#75–#77), so this stack needs no reconciliation pass and builds on both directly. Its tests are the next PR up and the phase record the one after.

What lands

The synchronous redirect follower and the two phase-level constructors phase 4c postponed — 32 files, +1,180 / −33 — REDIR-1–REDIR-26 and REDIR-28 (REDIR-27 declined for v1), plus PIPE-39 / PIPE-32 / PIPE-24 closed here.

  • Dexpace::Redirect::Step at Stages::REDIRECT — .build(allowed_methods: DEFAULT_ALLOWED_METHODS, follow303: false, max_hops: DEFAULT_MAX_HOPS, allow_scheme_downgrade: false, predicate: nil, logger: Logger::NULL), .new private, frozen, no redactor: (one redaction policy per logging path, 5b's P5-95, as 6c's step did — P6-93). Forks a fresh cursor for every hop, the first included, and writes state: { cross_origin: true | false } into its own Stages::REDIRECT slot on every hop — the marker is cursor state, never a header (design §10.15); 6c's Auth::Step reads it. Authorization stripped before every re-issue (REDIR-7); Cookie / Proxy-Authorization stripped cross-origin and kept same-origin, the origin judged against the seed as an explicit [scheme, host, effective port] triple, never URI#== (REDIR-8–10); the default allowed set is exactly {GET, HEAD} and not Method::IDEMPOTENT (REDIR-3/4); a followed 301/302 re-issues the original method and the same body object; the opt-in 303 rebuild drops the body and every Content-* header under the bare downcase fold (REDIR-5, HTTP-13); a present non-replayable body raises NotReplayableError with the current response closed first (REDIR-6, REDIR-22b). Three private helpers — Reissue, Emitter, Chain — replace the plan's seven-positional drive.
  • Location and Origin (private) — one URI::RFC3986_PARSER.join with no re-rendering (%2F, %2B, bracketed IPv6 and every non-default port preserved byte-for-byte, REDIR-13), an http/https scheme and non-empty host screen before the strip (join resolves http:foo and http:///p without raising — P6-95), the credential stripped with userinfo = "" — = nil is a silent no-op on every supported Ruby — and asserted on the rendered URL (REDIR-12); a malformed Location logs the raw string (the one place the redactor is deliberately not called, REDIR-28's own exception) and returns the current response unfollowed and open (REDIR-18). One residue, stated not hidden (P6-96): an explicit default port (https://h:443/y) is elided upstream, at phase 1's model boundary, because URI#to_s drops it and URL.parse! re-parses from text — routed to phase 10.
  • ConditionSnapshot (public, NFR-4-locked per R9) — a frozen Data over the phase-1 pattern; the predicate sees a defensive copy and mutating it changes nothing (REDIR-20); a recognised 3xx always allocates the snapshot and consults the predicate, the cap included — the cap vetoes a predicate's true after consulting it, never before (REDIR-17; P6-91, which corrects the design's own ledger sentence); a raising predicate leaves the current response closed (P6-99).
  • Events (five) / Keys (four) and SchemeDowngradeError — REDIR-28's four emission sites through 5b's Logger inside Instrumentation.contain, the status under 5b's own Keys::HTTP_RESPONSE_STATUS_CODE (P6-98); the downgrade refusal emits before it closes and raises (P6-97).
  • Resend.replayable_body? added beside 6a's .eligible? in the same extend self module — the two are deliberately different predicates (RETRY-7's idempotency clause belongs to retry, not to redirect); Dexpace::NotReplayableError flat under lib/dexpace/error/ on 6a's P6-56 precedent (P6-94).
  • Pipeline.standard / AsyncPipeline.standard (P6-100) — phase 4c's deferral, written over Builder#install_preset and nothing else: Pipeline.standard(over, redirect: nil, settings:, http_tracer_factory:, logger:, level:, preview_bytes:) installs REDIRECT → RETRY → LOGGING (6a's RetryStep, 5b's Step with its required logger:/level:); AsyncPipeline.standard(over, redirect:, …) requires redirect: :unsupported so PIPE-32's asymmetry is visible at the call site and installs RETRY → LOGGING with 6a's AsyncRetryStep and 5b's AsyncStep; over is a transport or a Pipeline::Builder, which is what makes PIPE-24's validate-then-commit reachable through the constructor. The four "postponed to phase 6b" YARD sites in pipeline.rb, builder.rb and async_pipeline.rb are rewritten; 4c's two refute_respond_to(…, :standard) pins flip on this branch.
  • The end-to-end cross-origin credential-leak test is un-guarded — 6c's test/dexpace/auth/cross_origin_convergence_test.rb now runs for real: the defined? guard turns true with this code (the one-line .new → .build repair rides here; the tests PR removes the dead guard), so the suite's only skip is gone. It was run red first against a stub that forked without the marker (Expected ["authorization"] to not include "authorization") and then green against the real step.
  • sig/ mirrors lib/ one file per file — origin.rbs, location.rbs, chain.rbs, reissue.rbs, emitter.rbs with hooks.rbs's comment, because test/gates/gem_layout_test.rb gates the mirror and the plan's "no sig for the private files" would have failed it; the manifest regenerated once from 1,109 to 1,137 rows (+28), every row read against the object model and P6-92.

Decisions taken in the open, against the plan's text

Ledger rows P6-91–P6-100 and the checklist's "Deviations from the plan" (36 items). The plan was written on 2026-09-09 against phase 4/5 designs; the load-bearing corrections: no redactor: keyword, the REDIR-17 order, the flat error, extend self not module_function, the plan's "malformed" fixture https://user:pass@ht!tp://bad is a valid URI (the tests use ht!tp://user:pass@bad), the design's Set-of-URI rationale is false, 6a's ScriptedTransport reused (its callable entry is how REDIR-22a's order — closed before the next hop is dispatched — is observed) and no third scripted double added.

Layering

Each tip of the stack is green under every gate on its own tree. This branch is green on the SimpleCov floor too — 99.25% on 4.0.6 (2,588 runs, 0 failures); the tests PR takes the same tree to 99.98% with 2,705 runs and 0 skips.

Verification

  • Independent review, four rounds by four fresh reviewers with a fix round between each: round 0 0 blocking / 2 should-fix / 2 nits; round 1 0 / 1 / 1; round 2 0 / 2 / 1; round 3 0 / 1 / 1. Every finding was a test gap — the code branch is the implementer's, unchanged through all four rounds; every gap was closed by a mutation-proven test on the tests branch and verified by the next reviewer.
  • All seventeen gates individually at this tip on 4.0.6; the matrix set on 3.2.11; honest RuboCop (--ignore-parent-exclusion) clean; probe clean at the docs tip.
  • Mutations: 44, 60, 70 and 67 applied by the four reviewers on both 4.0.6 and 3.2.11; every one caught at the final tips except two equivalents and R3-1's.
  • By experiment, both interpreters, through a real pipeline with 6c's real Auth::Step downstream: the second call of a cross-origin chain carries no Authorization and the AUTH step reads {cross_origin: true}; a same-origin chain is re-stamped; A → A → B marks hop 2 cross-origin against the seed and A → B → B does not re-mark; host case and the effective port; every Location form on 3.2.11, 3.3.12 (its uri gem's different InvalidURIError message never matched) and 4.0.6; the close order per hop; the 5,000-hop loop; both presets end to end (a 503 → 302 → 200 script retries, follows and returns 200 with the intermediates closed).

Known follow-ups from the final review (not blocking a gate)

  • R3-1 (should-fix, tests) — test/dexpace/pipeline/standard_test.rb:181: AsyncPipeline.standard's settings: threading on the nil-http_tracer_factory: branch has no assertion (dropping settings: from that branch survives on both interpreters); the sync preset's is pinned. A FakeClock-driven case on the async preset closes it. The code is correct as built.
  • R3-2 (nit) — commit d50ea64's message says five 303 cases moved unchanged; four moved and the fifth is new.
  • R0-3 (nit, deferred) — the implementer's tests and docs commit subjects are 80 and 77 characters and 55 body lines exceed 72; the squash titles are the PR titles.
  • Findings routed: the REDIR-13 default-port residue → phase 10's inbound list (P6-96); the userinfo-no-op corpus note's floor caveat closed by a new entry in docs/knowledge/notes/redirect-handling.md; the three replayability spellings and the ScriptedTransport/SequencedTransport pair — already routed by 6c, cited.

Phase 6b, Task 2. Widen phase 6a's Dexpace::Resilience::Resend in place with
the body-only predicate REDIR-6 asks -- no body, or a body answering
#replayable? -- beside .eligible?, which folds in RETRY-7's idempotency
clause and would refuse a body-less POST 307 the allowed-method set admits.
The clear error a re-send site raises over a present, non-replayable body is
Dexpace::NotReplayableError, flat under Dexpace:: on 6a's P6-56 precedent
for the same namespace (RetryPredicateError), not under Resilience::.
Neither file is required by the entry file yet; the step's commit wires the
whole phase-6b block in dependency order.
Phase 6b, Tasks 3-6 and the helpers Task 12 split out. Under
lib/dexpace/redirect/: Origin (private) -- REDIR-8's [scheme, host, port]
triple under the no-argument fold, compared against the SEED; Location
(private) -- one URI::RFC3986_PARSER.join against the current hop, a screen
for an http/https scheme AND a host (join resolves mailto:, ftp:, http:foo
and http:///p without raising), the userinfo strip spelled userinfo = ""
and never = nil, then the freeze, raising URI::InvalidURIError by class for
the step to convert; ConditionSnapshot -- REDIR-20's read-only, defensively
copied Data in the phase-1 shape; Events and Keys -- REDIR-28's five names
and four keys under one http.redirect. prefix; SchemeDowngradeError --
REDIR-15's refusal, naming the two authorities and never a path or query;
Chain (private) -- one call's seed, count, visited set and current request;
Emitter (private) -- the four contained, redacted emissions and the one raw
exception, over the logger's redactor; Reissue (private) -- REDIR-15's
downgrade check, REDIR-7's unconditional Authorization strip, REDIR-9/10's
cross-origin Cookie strip, REDIR-5's 303 rebuild by Content-* prefix and
REDIR-6's replayable-body gate. Every file has a sig/ mirror; the private
ones carry hooks.rbs's comment.
Phase 6b, Tasks 8-13. One iterative follower at Stages::REDIRECT that forks a
fresh cursor for EVERY drive, the first included, with the cross-origin
marker as cursor state -- { cross_origin: bool } on every hop, false on the
seed's own and a same-origin one, judged against the SEED origin -- and never
a request header. Per hop: the fast path on a non-redirect status, the
Location resolved once, the snapshot always allocated and a predicate always
consulted on a recognized 3xx, REDIR-17's cap applied over that answer, the
follow-up built inside a frame that closes the current response before any
raise, the superseded response closed BEFORE the next fork, the hop recorded.
Built through .build with .new private, frozen, with logger: as its own
keyword (the Cursor's bundle carries no logger) and no redactor: (one policy
per logging path, the logger's). Wires the ten-file phase-6b block into the
entry file after 6c's, adds REDIRECT_LAYER to the smoke suite's layer table,
and repairs the one existing test the private constructor invalidates: 6c's
guarded convergence test called Redirect::Step.new, and its defined? guard
stops skipping the moment this class exists, so the test now runs and
passes here.
Phase 6b, Task 13a -- the two constructors phase 4c postponed until the
redirect, retry and instrumentation families all existed (PIPE-39).
Pipeline.standard(over, redirect: nil, settings:, http_tracer_factory:,
logger:, level:, preview_bytes:) installs Redirect::Step, RetryStep and
Instrumentation::Step; AsyncPipeline.standard(over, redirect:, ...) installs
AsyncRetryStep and Instrumentation::AsyncStep and nothing at REDIRECT, with
redirect: a REQUIRED keyword admitting :unsupported alone so PIPE-32's
asymmetry is spelled at the call site. Both are written over
Builder#install_preset and nothing else; the positional is a transport or a
Pipeline::Builder already holding one, which is what makes PIPE-24's
empty-pillars rule reachable through the constructor. A nil
http_tracer_factory: leaves the retry family's own private default in
place. Rewrites 4c's three "postponed to phase 6b" YARD sites and the
PIPE-32 paragraph on AsyncPipeline, adds .standard to both signatures, and
flips 4c's two refute_respond_to pins, which this commit invalidates.
Phase 6b, Task 14. `bundle exec rake surface:regenerate`, once, and the 28
new rows read against the object model: AsyncPipeline.standard,
NotReplayableError, Pipeline.standard, Redirect, ConditionSnapshot with its
three readers and .build, Events (five), Keys (four), SchemeDowngradeError,
Step with #call, #stage, .build, DEFAULT_ALLOWED_METHODS and
DEFAULT_MAX_HOPS, and Resend#replayable_body?. Nothing private appears --
no Origin, Location, Chain, Emitter, Reissue or RECOGNIZED_CODES -- and the
step exposes no configuration reader. 1 109 rows become 1 137; every one
is a widening (NFR-4).
@Wahbeh-Mohammad Wahbeh-Mohammad added type:feature New capability or enhancement area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* area:resilience Retry, recovery, redirects: RETRY-* RECOV-* REDIR-* labels Sep 19, 2026
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 82eb7ce into main Sep 19, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 23-phase-6b-redirect branch September 19, 2026 16:12
@Wahbeh-Mohammad Wahbeh-Mohammad mentioned this pull request Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* area:resilience Retry, recovery, redirects: RETRY-* RECOV-* REDIR-* type:feature New capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant