Skip to content

Phase 6c: authentication — documentation and phase record - #77

Merged
Wahbeh-Mohammad merged 18 commits into
mainfrom
24-phase-6c-authentication-docs
Sep 19, 2026
Merged

Wahbeh-Mohammad merged 18 commits into
mainfrom
24-phase-6c-authentication-docs

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Closes #24. Third and last PR of phase 6c's stack — the documentation and the phase record for the two PRs below it. Targets the tests PR's branch. Its CLAUDE.md, READMEs, architecture.md and roadmap text are written for 6c on top of main; phase 6a's stack (#72–#74) rewrites the same sentences for 6a and merges first, so this stack's rebase-and-reprove pass re-derives the counts.

What lands

12 files, +1,624 / −39, all Markdown.

  • The checklist, docs/work/mvp/phase6/phase6c/2026-09-09-phase6c-authentication-checklist.md, written from the build: 38 own rows, all ✅ — AUTH-29's three clauses stated in its row (the stripping clause satisfied by construction and no code; the suppression and HTTPS-guard-skip clauses implemented) — plus the Task 15 row "written, guarded; owned by 6b" and 12 cross-reference rows; the matrix facts on every interpreter; five guard tables (95 guards run red across the rounds); 35 deviations from the plan; the findings routed; postponed work: none.
  • The phase 6c design's ledger gains an "As built" addendum, P6-71–P6-87: the step's constructor, #pretty_print, the username redaction, the BINARY scan, Basic's colon refusal and transcoding, username*, AUTH_REFRESH, the hook future, the observed inner futures, the private replayability gate, the BearerProvider functions, the error placement, the keyword builds, the encoding name and the causeless raise, the per-waiter settlement, the fourth bearer rejection. The design's P6-1–P6-7 stand; they collide by number with 6a's (recorded by the roadmap on 2026-09-10) and phase 10 consolidates.
  • docs/knowledge/notes/authentication.md (new — pp gives a Data its own #pretty_print and never calls #inspect, correcting authentication/f8a5bc6a) and a new entry in notes/error-handling.md (cause: nil for a secret-carrying cause, narrowing error-handling/866b8ebe).
  • docs/sdk-documentation/auth.md — new as-built page (the one architecture.md listed as unwritten), 77 fence checks on 4.0.6 and 3.2.11 with no secret printed; architecture.md, the core README, README.md and docs/README.md point at it.
  • docs/first-release.md — one phrase corrected in the AuthDescriptor blocker line (the step takes one stamper:, no Scheme => credential table); the two entries the design named (that line, the query/cookie apiKey line) verified present, not re-filed.
  • CLAUDE.md — "Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c and 6c are built", the opening paragraph gains the authentication layer, 140 → 165 lib files beside version.rb, eleven test-mirror exceptions (auth/validation.rb joins, bounded_map.rb leaves — it now has a true mirror), twelve checklists, and the "Constraints that will bite" lines (BoundedMap reachable only by a bare name from a full-nesting body; the bearer hot path lock-free by publication and XCUT-12's one sanctioned lock across a fetch; Basic is pack("m0") and Digest is ::Digest).
  • The roadmap — the 2026-09-18 "Phase 6c implemented" status note with its review-round paragraphs, and one phase-10 inbound bullet (three spellings of the replayability predicate), cited by date. The Cursor widening was consumed not at all (it did not exist on the base); the end-to-end cross-origin test is named as 6b's.

Verification

  • bundle exec rake on Ruby 4.0.6 at this tip: exit 0, all seventeen gates green (2,353 / 60,235 / 1 skip, 99.98%, YARD 0 undocumented).
  • ruby .claude/skills/housekeeping/probe.rb: no drift, all eight checks; verify_knowledge_structure.rb OK.
  • Empty diff under docs/product-spec/, docs/sdk-design-ruby/, docs/knowledge/harvested/, docs/deviations.md, every earlier phase's documents, the phase-6 charter, and 6a's and 6b's documents.
  • The final reviewer re-derived every count CLAUDE.md and the status note state against the tree and ran every auth.md fence on both interpreters.

Known follow-ups (not blocking)

  • The counts above are 6c-on-f1fe848; the rebase onto main after 6a merges re-derives them (6a: 149 lib files, thirteen exceptions) and reconciles the two phase-10 inbound bullets (6a's is numbered by ordinal) and the two status notes.
  • R3-2 (nit) — two 73-character body lines in the round-2 docs commit (a12c758).

@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:auth Authentication and credentials: AUTH-* labels Sep 18, 2026
@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor Author

Review record for the phase 6c stack (#75 → #76 → #77)

Five independent reviews, each by a fresh agent with no memory of the previous one, each re-running every gate itself on every tip (4.0.6 all seventeen individually at the code tip and the full rake at the tests and docs tips; the matrix set on 3.2.11 and 3.4.10) and applying its own mutations on both interpreters, with a fix round by a fresh agent between each. The stack was cut from main at f1fe848 and built in parallel with phase 6a's. The run stopped at the four-review cap with one should-fix open (R3-1, a poisoned bearer cache); by the maintainer's decision one targeted fix round and a fifth review followed rather than carrying a cache-poisoning defect into the PR body.

Round Verdict Blocking Should-fix Nits Mutations (caught) Disposition
0 changes required 0 1 4 58 (55) all 5 fixed
1 changes required 0 3 2 58 (56) all 5 fixed
2 changes required 0 3 2 46 (44) all 5 fixed
3 changes required 0 1 1 43 (43) R3-1 fixed in the extra round; R3-2 (a commit-body wrap) deferred to the PR body
4 (final) changes required 0 1 0 37 (36) R4-1 carried into the PR bodies

Nothing was skipped. The pattern across the rounds is the point of the record: no round found a red gate or a layering fault; each found the next layer of the credential surface by its own hostile inputs — a #stamp_fresh/#stamp mutation surviving, a 401 left open on a junk hook future, a secret's character leaking through #cause/full_message, one waiter's cancel cancelling every coalesced bearer waiter, a probabilistic nonce-counter test, a loose async eviction compare, and finally a provider token the header grammar refuses being cached until expiry.

Round 0 → fixed in round 1

  • R0-1 should-fix — gems/dexpace-core/test/dexpace/auth/async_step_test.rb:283: AUTH-37's post-eviction #stamp_fresh branch is not distinguished from #stamp by any test (mutation survives). Fixed on tests (fdb3420): New top-level double gems/dexpace-core/test/support/spy_bearer_stamper.rb (SpyBearerStamper: #stamp answers 'Bearer cached', #stamp_fresh 'Bearer fresh', #evict_if_matches the Boolean it was built with, every call recorded) and two BearerTest cases in async_st…
  • R0-2 nit — gems/dexpace-core/test/dexpace/auth/challenges_test.rb:124: Nothing pins the per-pattern timeout on the parser's eight Regexps. Fixed on code (39653a5): Regexp.new returns an unfrozen object (verified on all four interpreters), so the eight scanner patterns in lib/dexpace/auth/challenges.rb now .freeze as 5a's HTTPDate::GRAMMAR does (code branch, private constants, no surface change); on the tests branch (fdb3…
  • R0-3 nit — gems/dexpace-core/lib/dexpace/auth/digest_handler.rb:207: UnencodableCredentialError always names ISO-8859-1 even when the UTF-8 branch raised. Fixed on code (661ae8a): DigestHandler#materialize takes the branch's target Encoding and the error names it (encoding: target.name); UnencodableCredentialError's reason is keyed by the target through a private REASONS table ('advertised charset=UTF-8 and the value cannot be transcode…
  • R0-4 nit — gems/dexpace-core/lib/dexpace/auth/digest_handler.rb:160: A raising Digest attempt consumes a nonce count. Fixed on code (661ae8a): DigestHandler#compute now materialises the credential (the one raising step) before next_count and passes the parts into response_for/ha1_for, which is the order the design's own authorization_for fence has; after a refused attempt the nonce's slot is unset an…
  • R0-5 nit — gems/dexpace-core/lib/dexpace/instrumentation/keys.rb:88: A second earlier-phase lib file is widened beside bounded_map.rb (Events::AUTH_REFRESH). Fixed on docs (a6c5bad): No code change, as the finding says. The checklist's 'What was built' now records instrumentation/keys.rb (Events::AUTH_REFRESH) as a second earlier-phase lib file widened beside bounded_map.rb, a pure widening of a module 6a may widen too, for the manager's 6…

Round 1 → fixed in round 2

  • R1-1 should-fix — gems/dexpace-core/lib/dexpace/auth/async_step.rb:180: A hook FUTURE that fulfils with a non-request fails the async step's future but leaves the 401 OPEN. Fixed on code (977a1eb): Step#consult's rescue is now one private Step#closing_on_error(response) frame both runtimes use; AsyncStep overrides #consult (a Future passes through, a direct answer meets #replacement!) instead of overriding #replacement!, which is strict everywhere again,…
  • R1-2 should-fix — gems/dexpace-core/test/dexpace/auth/step_test.rb:257: AUTH-30's close-BEFORE-replay order is asserted by count only; a close-after-drive mutation survives. Fixed on tests (e0d9690): The replay's scripted second reply is now a callable that reads closes_of(first) AS the second drive reaches the transport (SequencedTransport/SequencedAsyncTransport already call a callable item) and the test asserts it is 1: step_test.rb DriveTest ('the orig…
  • R1-3 should-fix — gems/dexpace-core/lib/dexpace/auth/digest_handler.rb:232: The typed encoding failure's #cause names a character of the secret, and full_message renders it; BasicHandler raises the bare Ruby error naming a byte. Fixed on code (977a1eb): Both raises in DigestHandler#materialize spell raise unencodable(text, field, target), cause: nil (the rescue variable dropped), and UnencodableCredentialError takes a third required keyword source_encoding: (the value's own encoding name, an attr_reader and…
  • R1-4 nit — docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md:3467: The roadmap's 2026-09-18 note still states the pre-repair run and coverage figures. Fixed on docs (2a97140): The roadmap's 2026-09-18 note now dates its 2,333-run / 6,578-line sentence to the implementer's tips (528626a), appends round 0's post-repair figures (a6c5bad: 2,339 runs, 6,583 / 6,584) to the round-0 paragraph, and carries a round-1 paragraph with this roun…
  • R1-5 nit — gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb:178: AUTH-35 async: 'caching nothing' after an already-expired fetch result is not pinned. Fixed on tests (e0d9690): async_bearer_stamper_test.rb's 'AUTH-35 on the async path' asserts refute(subject.evict_if_matches("Bearer expired")) and assert_nil(@token) after the expired result, assert_nil(@token) after the non-token result and after the nil-token provider. Round 1's M51…

Round 2 → fixed in round 3

  • R2-1 should-fix — gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb:128: Cancelling ONE request's future cancels every request coalesced on the single-flight bearer fetch, and every new arrival until the provider settles. Fixed on code (f7b0cdb): AsyncBearerStamper#awaiting now builds each waiter's future as R12 prescribes: a Completer of the request's own, settled from the single-flight slot's #on_settle through a private #deliver, and never registered on the slot's completer — so cancelling one waite…
  • R2-2 should-fix — gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb:269: AUTH-24 at the handler level is probabilistic: a read-then-set counter that bypasses BoundedMap#update survives on both interpreters. Fixed on tests (947097e): digest_handler_test.rb CounterTest gains 'AUTH-24: the increment is one BoundedMap#update, never a read through #[] then #set': the handler is frozen so its store cannot be replaced; the store instance is narrowed in place with define_singleton_method — #[] /…
  • R2-3 should-fix — gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb:234: AUTH-36's exact-match eviction is pinned on the sync stamper only; a loose comparison on the async half survives. Fixed on tests (947097e): The async EvictionTest's AUTH-36 case now refutes evict_if_matches on "Bearer cur" (doubled space), "Bearer curator" (superstring) and "cur" (bare token) before the exact assert; the reviewer's M58 (include? comparison) fails 'Expected true to not be truthy.'…
  • R2-4 nit — gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb:40: YARD reads the comment line that starts with @lock as an unknown tag. Fixed on code (f7b0cdb): The class comment's line beginning @lock now reads 'the settle block takes the lock to publish the token'; the YARD gate at every tip prints no 'Unknown tag @lock' (39 warnings remain, all pre-existing), 731 methods 100% documented.
  • R2-5 nit — docs/work/mvp/phase6/phase6c/2026-09-09-phase6c-authentication-checklist.md:316: The 'Audit groups run' paragraph still says cause: nil applies nowhere in 6c and names Step#consult as the one re-raise site. Fixed on docs (de79833): The checklist's 'Audit groups run' sentence is qualified: at implementation no carried re-raise existed; after round 1's repair the two encoding failures are raised cause: nil on four sites (digest_handler.rb's two, basic_handler.rb's two, deviation 31) and th…

Round 3 → fixed in round 4

  • R3-1 should-fix — gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb:216: A provider token the outbound header grammar refuses is cached by both bearer stampers and poisons every later request until expiry; the async #stamp then raises synchronously instead of returning a future. Fixed on code (430c527): Code (430c527): a fetched token whose Bearer <token> wire form HeaderSyntax.valid_outbound_value? refuses is now AUTH-35's fourth rejection on BOTH stampers — BearerStamper#validate raises, and AsyncBearerStamper#invalid returns, a ProviderError ('the provid…
  • R3-2 nit — docs/work/mvp/phase6/phase6c/2026-09-09-phase6c-authentication-checklist.md:1: Two body lines of the round-2 docs commit ba5d5ea are 73 characters, over the 72-column wrap the repository style asks for. Deferred to pr body on docs (``): The two 73-character body lines live in the round-2 fixer's docs commit (ba5d5ea, now a12c758 after this round's rebase). Rewrapping them requires rewording that commit, and the brief says never amend an earlier fixer's commit — the rebase this round performed…

Round 4 (final) — open, carried into the PR bodies

  • R4-1 should-fix — gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb:95: AUTH-35's rejections on the sync BearerStamper are pinned only from a cold cache; a rejection on the REFRESH path (a warm cache inside the margin) is unobserved by every suite, so three single-edit mutations survive on both interpreters. Mutations applied one at a time to bearer_stamper.rb and run against ALL 26 auth suites (288 runs) under ruby -w, restored after each: R4-M9b fetched = @token.nil? ? validate(@provider.fetch) : @provider.fetch (validate skipped entirely on a refresh), R4-M14 if @token.nil? && fetched.expired?(now: @clock.now, margin: 0) (only the expiry check skipped on a refresh) and R4-M15 (only the grammar check skipped on a refresh) each SURVIVE with 288 runs / 0 failures on 4.0.6 and 3.2.11 (mutate_extra_406.log, mutate_3211.log). Under R4-M14 a token already expired at fetch time would be cached and…
  • R3-2 nit — two 73-character body lines in the round-2 docs commit (now a12c758); rewrapping would amend an earlier fixer's commit, which the brief forbids.

What the final reviewer verified by experiment, both interpreters

  • H: R3-1's reproduction re-run (exp_poisoned_cache.rb) and my own probes of the repair (exp_r4.rb, 25 checks) — identical on both: sync call 1 ProviderError then calls 2–5 stamped with fetches 2 and the cache not the newline token; async first stamp a failed future and the second a settled Future (fetches 2); through an AsyncStep pipeline request 1 P
  • The sync stamper's refresh path by hand (exp_refresh_path.rb) — with t1 (expiry 100) cached and the clock at 80, a refresh answering nil / an expired token / an Object / 'abc\n' / a raising provider → ProviderError (RuntimeError for the last), the still-valid t1 kept, the next call 'Bearer clean' with f
  • Basic by hand (F.2) — alice:s3cr3t → 'Basic YWxpY2U6czNjcjN0'; ü:pä → 'Basic w7w6cMOk', ascii_only; a colon in the username refused; an empty password refused, whitespace-only accepted ('Basic YWxpY2U6ICAg'); BearerToken ' ' → 'token must not be blank', nil →
  • Digest by hand against my own ::Digest derivation (F.3) — the four derived values equal the committed ones and the handler's output; RFC 2617 §3.5's header reproduced (nc=00000001, cnonce 0a4f113b, qop=auth, opaque echoed) and the legacy no-qop form without nc/cnonce/qop; SHA-256 and SHA-256-sess
  • The parser by hand (F.4) — 'Basic realm=x, Digest realm=y' → two in order; 'Bearer dGhlIHNlY3JldCB0b2tlbg==' → token68 whole; 'Digest realm="unterminated' → realm 'unterminated'; 'Digest realm=r,,nonce=n' → {realm, nonce}; a 1 000 000-byte quoted realm in 0.275 s; 'D
  • The nonce store (F.5) — digest_handler.rb's only reference is the bare BoundedMap.new(cap: cap) in the full-nesting body and no lib code names Dexpace::BoundedMap outside bounded_map.rb's own definition and comments; two handlers hold different stores; default @
  • The step against 4c's doubles (F.6) — #stage is Stages::AUTH; a ForkingProbe at REDIRECT forking {cross_origin: true} drives an http:// request with one call and no Authorization and no guard; {cross_origin: false} and no redirect step both stamp 'secret'; the HTTPS guard raise
  • The bearer stampers (F.7) — sixteen threads on a cold cache with the fetch parked until all arrived → fetches 1 for BearerStamper AND AsyncBearerStamper, every thread 'Bearer t1', the cached token frozen; a refusing mutex installed after warm-up never fires on either
  • The async step (F.8) — AsyncStep#call returns a Dexpace::Async::Future; a raising provider is a FAILED future on a direct root-cursor call and through the driver ('provider boom'); the 401 replay forks exactly once through the fresh downstream (forks 2, calls 0,
  • Redaction (F.9) — #to_s, #inspect, interpolation and #pretty_inspect of BearerToken, KeyCredential, NamedKeyCredential and PasswordCredential contain none of the placeholder secrets (the NamedKeyCredential name visible, PasswordCredential's username redacted
  • Task 15 with a scratch Dexpace::Redirect::Step stub (F.10) — as committed 1 run / 1 assertion / 1 skip with 'phase 6b's Dexpace::Redirect::Step is not on this base; 6b un-guards this test'; with stub_redirect.rb preloaded and forking {cross_origin: true}: 1 run, 6 assertions, 0 failures; forking with
  • Entry-file dependency order (D) — swapping step/async_step in dexpace.rb still loads under ruby -w because async_step.rb require_relatives step.rb — no NameError is reachable; the committed block sits after 5b's, twenty-five lines in dependency order; restored from a saved
  • Mechanical global constraints (C, mech_checks.rb over the docs tip through git show) — 63 new .rb files all open with the two header lines; plain requires added under lib are exactly strscan (challenges.rb), digest and securerandom (digest_handler.rb); Base64 (the value is ['u:p'].pack('m0')), OpenSSL, Random/UUID, Time.parse
  • Surface, mirrors and counts (D, G, counts.rb from git ls-tree at the docs tip) — 166 lib .rb under lib/dexpace/ (165 beside version.rb) ↔ 166 .rbs one-for-one; the only lib files without a test mirror are the eleven private_constants CLAUDE.md names (auth/validation in, bounded_map out) plus version.rb; 25 6c lib files
  • Docs tip prose checks (G) — the roadmap's 2026-09-18 note carries 2,353 / 60,235 / 6,621 / 6,622 / 92.43% / 1 060 matching my runs, says 6c postpones nothing, names Task 15 as 6b's to un-guard, says the Cursor widening was 'consumed not at all' and claims neither Curs

Tips reviewed at the final round: code 430c527, tests 13fe732, docs 2039060 on main f1fe848.

Phase 6c, cut from main at f1fe848 with nothing of phase 6a present,
so the steps take their own logger: keyword and AUTH-31 calls phase
3b's Body#replayable? directly.

Twenty-five new lib files: auth.rb, the flat AuthResolutionError under
error/, and twenty-three under auth/ -- the closed five-member Scheme
set with ALL and .of, Requirement, Descriptor and the pure three-tier
Resolver (AUTH-1..7); BearerToken, KeyCredential, NamedKeyCredential
and PasswordCredential, each redacting in #to_s, #inspect and, for the
two Data types, #pretty_print, because pp walks a Data's members and
never calls an #inspect override (AUTH-8..10); the never-raising RFC
7235 list parser Challenges.parse over Challenge, the one fold point
(AUTH-12, AUTH-13); BasicHandler over pack("m0") and never Base64
(AUTH-14); the challenge-only DigestHandler with MD5, MD5-sess,
SHA-256 and SHA-256-sess, qop=auth or legacy, a per-nonce counter in
its own BoundedMap and a typed failure for a credential ISO-8859-1
cannot carry (AUTH-15..22, AUTH-24); ChallengeHandlerChain with its
proxy-aware header name and the hook adapter (AUTH-23, AUTH-25); the
stateless KeyStamper (AUTH-26); BearerStamper with a lock-free hot
path and XCUT-12's sanctioned lock-across-fetch (AUTH-34..36);
AsyncBearerStamper with AUTH-37's three zones and one single-flight
slot; BearerProvider.fetch_async as AUTH-11's never-raising default;
and the AUTH pillar Step and AsyncStep at Stages::AUTH, built through
.build(stamper:, challenge_hook:, logger:), forking for every drive,
reading the cross-origin marker off the cursor and never a header,
guarding HTTPS before any fetch or write, replaying a 401 at most once
behind the replayability gate and closing the superseded response
(AUTH-27..33, AUTH-38).

Three earlier files widen in place: BoundedMap gains #update(key), the
read-yield-write the nonce counter needs; Instrumentation::Events
gains AUTH_REFRESH, the ninth event; lib/dexpace.rb gains the
twenty-five-line Phase 6c block. Every file has a sig/ mirror; the
strict Steep target is green with no relaxation and no Digest,
SecureRandom or Random in any signature.

Three existing tests change as pins the code invalidated: the smoke
suite's layer table and its preloaded stdlib features, the seam
surface's require pin (digest joins the four), and 5b's keys_test.rb
(eight events become nine). The surface manifest is regenerated once,
956 to 1059 lines, all 103 rows read against the object model.
Review round 0's R0-3 and R0-4, both in DigestHandler.

UnencodableCredentialError always named ISO-8859-1, and its message
blamed the challenge for not advertising charset=UTF-8, even when the
UTF-8 branch was the one that raised -- a BINARY-tagged credential
against a charset=UTF-8 challenge fails "\xE4 from ASCII-8BIT to UTF-8"
and was reported as a Latin-1 failure. #materialize now takes the
branch's target Encoding and the error names it; the message's reason
is keyed by the target (a private REASONS table) so each branch says
why its encoding applied. The same branch also refuses a UTF-8-tagged
credential carrying an invalid sequence: `encode` to the same encoding
passes bytes through unvalidated, so such a value would have been
hashed as it was, which is the silently wrong response R10 rejects.

#compute took the nonce count before hashing, so a refused attempt
consumed an nc and the next response on that nonce went out one higher
than the server had seen. The credential is now materialised first --
the one step that can raise -- and the count taken after it, which is
the order the design's own authorization_for fence has.

The RBS mirrors follow: REASONS declared, the three private signatures
that carry the materialised parts and the target updated.
Review round 0's R0-2. Regexp.new, unlike a Regexp literal, returns an
unfrozen object, so the parser's eight timeout-compiled patterns were
the one set of core patterns not frozen at load: phase 5a's
HTTPDate::GRAMMAR freezes and its suite pins the property. The eight
now freeze the same way, so the tests branch can pin "a private,
frozen Regexp with a per-pattern timeout" on each of them and a
removed `timeout:` runs red instead of surviving. Private constants;
no surface change.
Review round 1's R1-1 and R1-3.

AsyncStep validated a challenge hook's future-settled value outside the
frame that closes the 401: a hook future fulfilling with a non-request
failed the step's future with the 401 body left open, contradicting the
class comment and AUTH-32. Step#consult's rescue becomes one
closing_on_error(response) frame both runtimes use; AsyncStep overrides
consult to pass a future through and checks the settled value inside the
same frame, so a String, or a future of a future, closes the 401 before
the frame fails the future. replacement! is strict everywhere.

UnencodableCredentialError carried the rescued conversion error as its
cause, whose message names the offending character (U+65E5) or byte
("\xE4") of the secret, and full_message renders a cause on every
supported Ruby (AUTH-8). Both raises in DigestHandler#materialize now
spell cause: nil, and the error carries the value's own encoding as
#source_encoding and in its message instead, so the diagnostic loses
only the character. BasicHandler raised the bare Encoding error for a
BINARY-tagged or invalid UTF-8-tagged field; each field is now
transcoded under its own name and refused as an InvalidArgumentError
naming the field and the two encodings, cause nil.

RBS mirrors follow; the surface manifest gains the one new reader.
Review round 2's R2-1 and R2-4, both in AsyncBearerStamper.

The expired-or-missing zone derived the caller's future from the
single-flight slot through Future#then, and #then wires the derived
future's cancellation back to its source. The source here is the ONE
slot every coalesced caller shares, and every arrival until the
provider settles, so cancelling one request's future -- which
AsyncStep#observe forwards from the step's future -- cancelled every
other waiter, and every new request coalesced onto an already-cancelled
future until the provider settled (AUTH-37, SEAM-18). R12 prescribes a
second #on_settle and a second Completer; the stamper now builds each
waiter's future that way, settled FROM the slot's settlement and never
wired back to it, so cancelling a waiter detaches that waiter alone and
the fetch, the cache and the other waiters are untouched. One private
#settle classifies both completers by Future#then's three rules: a
cancellation stays a cancellation (a provider that cancels its own fetch
cancels the slot and every waiter with its reason), a failure is the
same object, a success settles with the block's value; a stamp the
outbound header grammar refuses fails that waiter only.

The class comment's line beginning `@lock` read to YARD as an unknown
tag; reworded. RBS mirror follows; no public surface changes.
Review round 3's R3-1, in both bearer stampers.

AUTH-35's validation checked a fetched token for nil, class and expiry
and nothing else, so a token whose `Bearer <token>` wire form the
outbound header grammar refuses (HTTP-18: a trailing newline read off a
file, a CR) was written into the cache. It can never be sent, so no 401
can ever arrive to evict it (AUTH-36), and it stays until it expires --
never, for a token with no expiry: the sync stamper raised HTTP-18's
InvalidArgumentError on every later call with the provider never asked
again, and the async stamper's fresh zone raised it synchronously out of
a method that returns a Future, failing every later request through an
AsyncStep with the transport never reached. AUTH-35 wants a misbehaving
provider result uncached so a later request retries.

The grammar check is now the fourth rejection in BearerStamper#validate
and AsyncBearerStamper#invalid, a ProviderError whose message never
names the token: nothing is cached, the sync call raises from inside the
lock with @token untouched, the async waiters fail with it, the slot
clears, and the next call fetches again. Checked where the token
arrives, as KeyStamper checks its key where IT arrives (construction),
not in BearerToken.build, whose contract is AUTH-9's non-blank rule. A
cached token therefore always stamps, so #stamp's fresh and expiring
zones cannot raise; #deliver's rescue stays for a request whose own
derivation refuses. Comments on both stampers and ProviderError follow;
no public surface changes, the RBS mirrors are unchanged.
The reconciled Layers class carried both phase 6a's retry pin and
phase 6c's authentication pin beside the phase-3b through phase-5
cases and reached 104 lines, over Metrics/ClassLength's 100, which the
honest RuboCop run sees and the nested-worktree rake gate does not.
The two phase-6 cases move to a sibling PhaseSixLayers class, the shape
phase 4a's reconciliation used for the same collision.
Twenty-nine suites, one per public auth file plus bounded_map_test.rb,
the first true mirror of a private constant, plus five that carry no
lib mirror and say so in their headers: step_bearer_challenge_test.rb
(a second suite over step.rb), pillar_integration_test.rb (one example
set over both runtimes), cross_origin_convergence_test.rb (Task 15,
guarded on defined?(Dexpace::Redirect::Step) and skipping on this
base with a reason naming 6b), matrix_facts_test.rb (Task 1's facts as
a standing test, deriving the four Digest expectations rather than
transcribing them) and error/auth_resolution_error_test.rb.

Eight top-level test-support doubles, one class per file:
ChallengeFixtures, FixedCnonce, SequencedTransport (named so as not to
collide with the ScriptedTransport phase 6a is writing at the same
time), SequencedAsyncTransport, ScriptedBearerProvider,
ScriptedAsyncBearerProvider, SpyCursor and AuthFixtures.

The AUTH-24 proof is deterministic: bounded_map_test.rb forces the
interleaving between the read and the write through the block, and
bearer_stamper_test.rb parks sixteen threads on a barrier so the
single-flight fetch is counted, never timed. Every "never raises"
claim is asserted on the value, identity claims use assert_same, the
test helper is named dispatch because run is Minitest::Test#run, and
every thread a test starts is joined.
Review round 0's findings, each with the line that now runs red on its
revert.

R0-1: AsyncStep's post-eviction routing was indistinguishable under the
suite, because the real AsyncBearerStamper fetches through #stamp and
#stamp_fresh alike once its cache is empty (mutation M45 survived). A
new top-level double, SpyBearerStamper, answers "Bearer cached" from
#stamp and "Bearer fresh" from #stamp_fresh and records every call, and
two BearerTest cases drive a 401-then-200 through it: a successful
eviction retries through #stamp_fresh and a failed one through #stamp.
M45 now fails on ["Bearer cached", "Bearer fresh"] versus twice cached.

R0-2: the parser suite pins each of the eight scanner patterns as a
private, frozen Regexp with a non-nil #timeout, as http_date_test.rb
pins CFG-31's grammar; a removed `timeout:` or `.freeze` is a red test.
The suite crossed Metrics/ClassLength and is split into GrammarTest
and LeniencyTest over one shared helper.

R0-3: a BINARY-tagged credential under charset=UTF-8 raises naming
UTF-8 with the conversion error as cause; a UTF-8-tagged credential
with an invalid sequence is refused on its own bytes, cause nil; a
Latin-1-tagged one is transcoded and accepted. The error suite asserts
the UTF-8 message says the challenge advertised the charset and never
that it did not.

R0-4: a refused Digest attempt leaves the nonce's counter unset, and
the next response on that nonce sends nc=00000001.
Review round 1's R1-1, R1-2, R1-3 and R1-5.

R1-2: the AUTH-30 order was asserted by count alone, so a close-after-
drive mutation survived on both interpreters. The replay's scripted
reply is now a callable that reads the 401's close count AS the second
drive reaches the transport, in step_test.rb, step_bearer_challenge_
test.rb and both async_step_test.rb branches; the sync and async
close-after-drive mutations run red.

R1-1: async_step_test.rb gains the future-fulfilled non-request shape,
a String and a future of a future, each closing the 401 and failing
the future with the InvalidArgumentError naming the class.

R1-3: unencodable_credential_error_test.rb takes the source_encoding
keyword and proves the error is raised with no cause inside an in-flight
rescue; digest_handler_test.rb asserts nil cause, the source encoding,
and that message, detailed_message, inspect, full_message and every
each_cause message carry neither U+65E5, the character nor the
password; basic_handler_test.rb asserts the typed, causeless refusal of
a BINARY-tagged and an invalid UTF-8-tagged field naming no byte.

R1-5: the async AUTH-35 test asserts the rejected results cache nothing
through #evict_if_matches and the cache slot, so round 1's M51 runs red.
Review round 2's R2-1, R2-2 and R2-3.

async_bearer_stamper_test.rb gains a CancellationTest: cancelling one
of three coalesced waiters (a #stamp_fresh one among them) leaves the
others pending, the provider's fetch unsettled and a new arrival
coalescing onto the live slot with one fetch in all, and once the
provider settles the survivors stamp the token, the cache holds it and
the cancelled one carries its own reason; a provider cancelling its own
fetch cancels every waiter AS a cancellation with the provider's
reason, caches nothing and frees the slot for a retry; a token the
outbound header grammar refuses fails that waiter alone and never
raises into the settling thread. async_step_test.rb gains the same
isolation through a real async pipeline: one cancelled request, the
other two drive with the fresh token. Round 2's tree, the Future#then
derivation, a completer wired back to the slot, a cancellation
forwarded as a failure, the rescue dropped and a slot left set all run
red on 4.0.6 and 3.2.11.

digest_handler_test.rb pins the handler's increment to
BoundedMap#update deterministically: the store's #[], #set and #put are
narrowed in place to raise (the handler is frozen, so the store is not
replaced), #update records its key, and two counts on one nonce read
00000001 and 00000002 through two recorded updates. The reviewer's
read-then-set counter, which the sixteen-thread race never observed
under the GVL, now raises on both interpreters.

async_bearer_stamper_test.rb's AUTH-36 case gains the sync suite's
exactness pins -- a doubled space, a superstring and the bare token do
not evict -- so a substring comparison on the async half runs red as it
already did on the sync one.
Review round 3's R3-1. A provider token the outbound header grammar
refuses -- a trailing newline, a CR, a non-ASCII byte -- is now
AUTH-35's fourth rejection, and the suites pin it where the round found
it unobserved: bearer_stamper_test.rb (split into CacheTest,
RejectionTest and EvictionTest under Metrics/ClassLength) asserts three
such tokens each raise ProviderError with @token nil and no name of the
token in the message, then the next call fetches the clean one;
async_bearer_stamper_test.rb FailureTest asserts two waiters on one
fetch both fail with it, nothing is cached, the slot is free, and the
next #stamp returns a future that stamps the clean token -- never a
synchronous raise -- and that an unusable BACKGROUND refresh is logged
as http.auth.refresh without naming the token and caches nothing, the
still-valid token stamped meanwhile. step_bearer_challenge_test.rb and
async_step_test.rb carry the same through a real pipeline: one dispatch
fails, the transport is never reached, the next two are 200 with one
refetch.

Round 3's CancellationTest case fed exactly such a token to prove
#deliver's rescue; the fourth rejection now pre-empts it before the
stamp, so the case becomes a request whose own derivation refuses,
which is the raise left for the rescue to keep off the settling thread.

Eight mutations run red on 4.0.6 and 3.2.11: the grammar check dropped
from either stamper, the sync token cached before validation, the
rescue dropped, either message naming the token, the async write made
unconditional, and the refusal raised out of the settle block.
The checklist, written at execution time from what was built: 38 own
rows, all implemented, nothing deferred, plus the guarded Task 15 row
and twelve cross-reference rows; what was built; the matrix facts
re-run on 3.2.11, 3.3.12, 3.4.10 and 4.0.6 with the four Digest
expectations derived on each; the sixty-one guards run red on 4.0.6
and 3.2.11; the audit groups; twenty-eight departures from the plan's
text; the findings routed; postponed work none.

The design gains an As-built addendum, rows P6-71 to P6-83, recording
where the source reads differently from the design's text: Step.build
with no redactor: keyword, the third rendering pp needs, no
Replayability module, BearerProvider shipped as a real function, the
three Auth errors filed under auth/.

docs/knowledge/notes/authentication.md is the corpus's first
authentication note: pp gives a Data its own #pretty_print over the
members and never calls an #inspect override, so the harvested
two-rendering rule is one short.

docs/sdk-documentation/auth.md is the as-built page, every fence run
verbatim on 4.0.6 and 3.2.11 as one script and no example printing a
secret; architecture.md, the core README, README.md and docs/README.md
point at it. CLAUDE.md's built-phases paragraph, its counts and its
constraints-that-bite list are rewritten from the tree.
docs/first-release.md corrects one phrase to what was built: the step
takes one stamper and no Scheme => credential table. The roadmap gains
the 2026-09-18 status note and one phase-10 inbound bullet for the
three replayability spellings phase 6 leaves behind.
The checklist's AUTH-13, AUTH-18, AUTH-21, AUTH-36 and AUTH-37 rows
say what the round changed and which guard proves it: the eight
scanner patterns pinned frozen with their timeout (R0-2), a refused
Digest attempt consuming no nonce count (R0-4), the typed failure
naming the branch that raised with a reason worded for it and the
UTF-8 branch refusing a credential that is not text under its own tag
(R0-3, P6-84), and AsyncStep's post-eviction routing told apart through
SpyBearerStamper, the ninth double (R0-1). "What was built" gains the
double and records instrumentation/keys.rb as a shared pair for the
6a/6c merge (R0-5); "Guards run red" gains the round's table, guards
62-68, all caught on 4.0.6 and 3.2.11; "Deviations from the plan"
gains items 29 and 30.

The design's As-built addendum gains P6-84 and a paragraph on the
count order, which returns to the object model's own fence and so
carries no row. The auth page states both branches raise naming their
encoding and adds the BINARY-tagged example, every fence re-run as one
script (66 checks, 0 fails on 4.0.6 and 3.2.11). CLAUDE.md's Digest
bullet carries the encoding and count-order rules; the roadmap's 6c
status note gains the round's record.
The checklist's AUTH-8, AUTH-14, AUTH-21, AUTH-30, AUTH-32 and AUTH-35
rows now say what the repaired tree does: the close-before-replay order
read as the second drive reaches the transport, the future-fulfilled
non-request closing the 401, both encoding failures raised cause: nil
with the value's own encoding carried instead, BasicHandler's typed
refusal, and the async stamper caching nothing for a rejected token. A
third guard table (69-80) records the twelve mutations run red on 4.0.6
and 3.2.11; deviations 31 and 32 state the two departures; the manifest's
second regeneration is noted.

The design's As-built addendum gains its seventh differing statement and
row P6-85, amending R10's "the rescued conversion error as #cause" and
P6-84's parenthetical, plus a round-1 paragraph for the async-step gap
P6-78 had promised closed. docs/knowledge/notes/error-handling.md gains
an entry narrowing the styleguide's "the original exception object as
the cause:" rule for a secret. auth.md's Digest, Basic and async-step
prose and examples show #source_encoding, the nil cause and the typed
Basic refusal (70 fence checks, 0 fails on 4.0.6 and 3.2.11). CLAUDE.md's
Digest bullet carries the rule. The roadmap's 6c note dates its
implementer-tip figures, appends the round-0 and round-1 post-repair
figures (R1-4), and cites P6-85 and the 1 060-line manifest.
The checklist's AUTH-37 row now states the expired zone as R12's second
Completer, settled from the shared fetch and never wired back to it,
with the cancellation-isolation clauses the new suites prove; AUTH-24
names the deterministic pin that ties the handler to BoundedMap#update;
AUTH-36 the async exactness pins. A fourth guard table (81-87) carries
the round's seven mutations run red on 4.0.6 and 3.2.11, deviation 6
and 12 are amended, deviations 33 and 34 record the repair and the two
suite pins, and the "Audit groups run" sentence about cause: nil is
qualified to the tree after round 1 (R2-5).

The design's As-built addendum gains its eighth differing statement,
a round-2 paragraph and row P6-86, which narrows P6-79's "cancels the
inner future" to the waiter; every P6-71-P6-85 range reads P6-86.
auth.md's async-stamper prose says why the waiter is not a Future#then
derivation and gains a fence showing one cancelled waiter leaving the
others driving (73 fence checks, 0 fails on 4.0.6 and 3.2.11).
CLAUDE.md's bearer bullet and the roadmap's 2026-09-18 note carry the
same, the note with this round's figures.
Review round 3's R3-1: a provider token the outbound header grammar
refuses was cached by both bearer stampers and could never be sent or
evicted, and it is now AUTH-35's fourth rejection.

The checklist's AUTH-35 row names the fourth rejection and where it is
pinned on both stampers and through both pipelines; the AUTH-37 row
says an unusable background refresh is logged and cached for nothing,
and that #deliver's rescue is re-pinned through a request whose own
derivation refuses; the HTTP-18 cross-reference row gains the bearer
stampers; a fifth guard table (88-95) records the eight mutations run
red on 4.0.6 and 3.2.11; deviation 35 states the departure and the
bearer_stamper_test.rb split, deviations 12 and 33 point at it, and the
Minitest-conventions row counts nine split suites. The design's As-built
addendum gains a ninth differing statement (AUTH-35's validation has a
fourth check, the port's), the round-3 paragraph and row P6-87, which
amends P6-86's "fails that waiter only". auth.md's bearer section states
the rejection on both runtimes with a runnable fence each (77 fence
checks, 0 fails, on 4.0.6 and 3.2.11, no secret printed); CLAUDE.md's
bearer bullet names it; the roadmap's 6c note gains the round-3
paragraph with the repaired tips' figures.

The round's nit, two 73-character body lines in the round-2 docs
commit, is deferred to the PR body: rewrapping them would rewrite an
earlier fixer's commit.
Phase 6a merged first, so the three 6c branches were rebased from
f1fe848 onto main 905523c with the nine files both lanes changed
reconciled inside the rebased commits. This commit adds the two
records that reconciliation owes: the roadmap's dated paragraph
naming the reconciled files, the regenerated manifest count and the
old and new tips, and a dated note in the 6c checklist stating that
its own count sentences describe its original base while CLAUDE.md
carries the combined tree's figures.
@Wahbeh-Mohammad
Wahbeh-Mohammad force-pushed the 24-phase-6c-authentication-tests branch from 13fe732 to a870f7e Compare September 19, 2026 09:56
@Wahbeh-Mohammad
Wahbeh-Mohammad force-pushed the 24-phase-6c-authentication-docs branch from 2039060 to 029a6e7 Compare September 19, 2026 09:56
@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor Author

Reconciliation onto main after phase 6a merged (2026-09-19)

Phase 6a's stack (#72 → #73 → #74) merged first, so this stack was rebased from f1fe848 onto main 905523c by a rebase-and-reprove pass; the reviewed SHAs cited in the record above and in the three PR bodies are therefore stale. The map, every 6c commit preserved and none reordered:

Branch Reviewed (round 4) Rebased What changed inside
24-phase-6c-authentication (#75) 430c527 5a74e17 the rebased five commits, with lib/dexpace.rb (6a's block then 6c's), test/dexpace_test.rb (both layer pins) and the manifest (regenerated: 1,005 + 104 = 1,109 rows) reconciled inside them — plus one new fix: commit: the honest RuboCop run found the reconciled smoke suite's Layers class at 104 lines (Metrics/ClassLength), so the two phase-6 cases moved to a sibling PhaseSixLayers class, the shape phase 4a's reconciliation used
24-phase-6c-authentication-tests (#76) 13fe732 a870f7e rebased only; no conflict, no 6c suite needed a #bundle repair
24-phase-6c-authentication-docs (#77) 2039060 029a6e7 the rebased five commits with CLAUDE.md, the three READMEs, architecture.md and the roadmap reconciled (both lanes' content, counts re-derived from the combined tree: 174 lib/ files beside version.rb, thirteen test-mirror exceptions, thirteen checklists, fourteen pages; both status notes and both phase-10 inbound bullets in merge order) — plus one new docs: commit: the roadmap's dated reconciliation paragraph and a note in the 6c checklist stating its own count sentences describe its original base

Every file only one lane touched is byte-identical to that lane's reviewed tip (proved over the whole tree: the only paths differing from both main and 2039060 are the nine reconciled files and the checklist note).

Re-proven at the rebased tips: docs tip full rake green on 4.0.6 — 2,588 runs / 67,040 assertions / 1 skip (the guarded end-to-end cross-origin test, 6b's) / 99.98% — with the honest RuboCop clean (484 files), the probe clean and both corpus verifiers OK; tests and code tips full rake on 4.0.6 and the matrix set on 3.2.11 (both), 3.3.12 and 3.4.10 (tests tip) — figures below.

Tip Ruby Run Result
docs 029a6e7 4.0.6 bundle exec rake (all seventeen) green — 2,588 runs / 67,040 assertions / 0 failures / 1 skip; 7,171 / 7,172 lines (99.98%); honest RuboCop 484 files, no offenses; probe "no drift found"; verify_knowledge_structure OK (2,166 harvested, 55 notes)
tests a870f7e 4.0.6 bundle exec rake green — the same figures
tests a870f7e 3.2.11 / 3.3.12 / 3.4.10 the matrix set green — 2,588 runs, 1 skip, 99.98% on each row
code 5a74e17 4.0.6 bundle exec rake green — 2,286 runs / 65,267 assertions / 0 failures; 93.01% (above the floor, no layering exception needed); 6 manifests match
code 5a74e17 3.2.11 the matrix set green — 92.96%

Pushed with --force-with-lease on 2026-09-19; the three PRs now carry these tips.

@Wahbeh-Mohammad
Wahbeh-Mohammad changed the base branch from 24-phase-6c-authentication-tests to main September 19, 2026 10:04
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit e61864f into main Sep 19, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 24-phase-6c-authentication-docs branch September 19, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:auth Authentication and credentials: AUTH-* area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* type:feature New capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 6c: Authentication

1 participant