Repository navigation
Phase 6b: redirect — the follower and the standard constructors - #78
Merged
Merged
Conversation
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).
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #23. First PR of phase 6b's three-PR stack — the code — and the last lane of phase 6: cut from
mainate61864f, 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-26andREDIR-28(REDIR-27declined for v1), plusPIPE-39/PIPE-32/PIPE-24closed here.Dexpace::Redirect::StepatStages::REDIRECT—.build(allowed_methods: DEFAULT_ALLOWED_METHODS, follow303: false, max_hops: DEFAULT_MAX_HOPS, allow_scheme_downgrade: false, predicate: nil, logger: Logger::NULL),.newprivate, frozen, noredactor:(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 writesstate: { cross_origin: true | false }into its ownStages::REDIRECTslot on every hop — the marker is cursor state, never a header (design §10.15); 6c'sAuth::Stepreads it.Authorizationstripped before every re-issue (REDIR-7);Cookie/Proxy-Authorizationstripped cross-origin and kept same-origin, the origin judged against the seed as an explicit[scheme, host, effective port]triple, neverURI#==(REDIR-8–10); the default allowed set is exactly{GET, HEAD}and notMethod::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 everyContent-*header under the baredowncasefold (REDIR-5,HTTP-13); a present non-replayable body raisesNotReplayableErrorwith the current response closed first (REDIR-6,REDIR-22b). Three private helpers —Reissue,Emitter,Chain— replace the plan's seven-positionaldrive.LocationandOrigin(private) — oneURI::RFC3986_PARSER.joinwith 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 (joinresolveshttp:fooandhttp:///pwithout raising — P6-95), the credential stripped withuserinfo = ""—= nilis a silent no-op on every supported Ruby — and asserted on the rendered URL (REDIR-12); a malformedLocationlogs 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, becauseURI#to_sdrops it andURL.parse!re-parses from text — routed to phase 10.ConditionSnapshot(public,NFR-4-locked per R9) — a frozenDataover 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'strueafter 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) andSchemeDowngradeError—REDIR-28's four emission sites through 5b'sLoggerinsideInstrumentation.contain, the status under 5b's ownKeys::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 sameextend selfmodule — the two are deliberately different predicates (RETRY-7's idempotency clause belongs to retry, not to redirect);Dexpace::NotReplayableErrorflat underlib/dexpace/error/on 6a's P6-56 precedent (P6-94).Pipeline.standard/AsyncPipeline.standard(P6-100) — phase 4c's deferral, written overBuilder#install_presetand nothing else:Pipeline.standard(over, redirect: nil, settings:, http_tracer_factory:, logger:, level:, preview_bytes:)installsREDIRECT → RETRY → LOGGING(6a'sRetryStep, 5b'sStepwith its requiredlogger:/level:);AsyncPipeline.standard(over, redirect:, …)requiresredirect: :unsupportedsoPIPE-32's asymmetry is visible at the call site and installsRETRY → LOGGINGwith 6a'sAsyncRetryStepand 5b'sAsyncStep;overis a transport or aPipeline::Builder, which is what makesPIPE-24's validate-then-commit reachable through the constructor. The four "postponed to phase 6b" YARD sites inpipeline.rb,builder.rbandasync_pipeline.rbare rewritten; 4c's tworefute_respond_to(…, :standard)pins flip on this branch.test/dexpace/auth/cross_origin_convergence_test.rbnow runs for real: thedefined?guard turns true with this code (the one-line.new → .buildrepair 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/mirrorslib/one file per file —origin.rbs,location.rbs,chain.rbs,reissue.rbs,emitter.rbswithhooks.rbs's comment, becausetest/gates/gem_layout_test.rbgates 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 selfnotmodule_function, the plan's "malformed" fixturehttps://user:pass@ht!tp://badis a valid URI (the tests useht!tp://user:pass@bad), the design's Set-of-URI rationale is false, 6a'sScriptedTransportreused (its callable entry is howREDIR-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
--ignore-parent-exclusion) clean; probe clean at the docs tip.Auth::Stepdownstream: the second call of a cross-origin chain carries noAuthorizationand 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; everyLocationform on 3.2.11, 3.3.12 (itsurigem's differentInvalidURIErrormessage 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)
test/dexpace/pipeline/standard_test.rb:181:AsyncPipeline.standard'ssettings:threading on the nil-http_tracer_factory:branch has no assertion (droppingsettings:from that branch survives on both interpreters); the sync preset's is pinned. AFakeClock-driven case on the async preset closes it. The code is correct as built.d50ea64's message says five 303 cases moved unchanged; four moved and the fifth is new.docs/knowledge/notes/redirect-handling.md; the three replayability spellings and theScriptedTransport/SequencedTransportpair — already routed by 6c, cited.