# Bind sensitive handoffs to local account-link initiation

Status: design approved by claude-fable-5 at c4fe2bc, 2026-09-08; implemented by Codex and awaiting independent code review. Required by the Fable-approved Docs linking design2e69825. Issuer max_age300 is already merged Home3947850 and deployed47301e8. The existing shared consumer verifies identity, nonce and replay, but does not request/enforce fresh authentication or expose a safe application nonce-binding hook.

Add ConsumerConfig.RequireFreshAuth (default false) and BindStart func(*http.Request, string) error (default nil). The callback receives the generated public nonce, never the pending browser secret or assertion token. Call it after Store creates the pending row and before setting the pending cookie or redirecting to Home. Docs will atomically bind that nonce's hash to the previously initiated local person/session/origin record. A rejected binding cancels only the newly created pending row, returns a fixed failure, sets no new pending cookie and never redirects. If cancellation itself fails, return the existing generic storage failure; the orphan row remains subject to existing expiry/cap eviction. Binding must not be an external network operation or create an authorization grant. A successful binding followed by a lost response is an abandoned bounded flow, not a completed link.

Add Store.StartFresh alongside unchanged Store.Start, using one internal implementation. StartFresh marks the authentication requirement inside the already sealed return-path payload, not in a browser query or untrusted caller-selected Finish flag. Use the fixed plaintext prefix oficina-handoff-fresh-v1: before the validated local path; normal Start keeps the exact old plaintext path. The prefix is not a valid SafePath. New Finish recognizes exactly that prefix, strips it and validates the resulting path. Unknown prefixes remain invalid. This needs no schema migration and protects the policy with the existing authenticated encryption. An old reader decrypts the fresh payload but refuses it as an invalid path, so rollback cannot silently downgrade a sensitive exchange. Ordinary old/new rows stay compatible.

RequireFreshAuth selects StartFresh and adds max_age=300 to the registered issuer redirect. The age request is convenience only: Store.Finish enforces the sealed policy even if the query parameter was stripped or an ordinary Consumer instance handles Finish. After normal signature/issuer/audience/nonce/subject checks, replay insertion and deletion of pending/inbox commit as before. For a fresh payload, return identity only if actual claims.AuthTime is not later than the supplied wall-clock now and is within300 seconds of the persisted clock high-water value returned by begin. Otherwise return zero claims, empty path and ErrRefused AFTER consumption. The high-water comparison prevents a tolerated backward clock adjustment from reviving stale authentication. The wall-clock comparison prevents crediting future authentication. No timestamp is clamped or rewritten.

The after-commit refusal is deliberate: an unusable proof cannot later become usable by waiting, changing handler configuration, replaying the old browser cookie or moving the clock. No application Complete callback runs on the refusal. Consumers still decide permissions, and Docs confirmation separately checks both current identity proofs and initiation binding. Fresh identity is not membership or authorization.

The app must initiate linking with its same-origin fresh local-account POST before this shared Start hook can bind anything. Merely receiving a Home assertion or setting RequireFreshAuth cannot initiate a link. Docs' hook validates exact current local session/person/origin and a pending initiation with no previously bound nonce; conflicting/racing starts have one winner. The generic library does not store local person IDs or make that application decision.

Acceptance: ordinary old-auth exchange unchanged; fresh mode sends exact300; stripping it and finishing through ordinary Consumer cannot accept stale proof; fresh proof succeeds with unchanged auth_time; future proof and stale proof fail once then remain consumed, including after restart/config downgrade; tolerated clock rollback does not revive stale authentication; successful hook observes the exact nonce in the outbound registered redirect; rejected hook produces no cookie/redirect and cleans only its new pending row; concurrent app-binding fixture has one winner; duplicate incoming cookies retain existing refusal before any hook. Fresh sealed payload is rejected by old SafePath behavior, ordinary payload remains byte-shape compatible. Privacy/WAL/log gates, race tests and meaningful freshness/hook-bypass mutations must pass, followed by independent Fable review and actual CI. No Docs linking or team-ready claim from this library stage alone.

Implementation evidence: make test passes root unit tests, Home race tests and nested consumer unit/race tests. handoff/fresh_test.go covers exact freshness boundaries, future authentication, persisted high-water rollback, consumption across database reopen and time advance, ordinary payload compatibility and old-reader fresh refusal, pre-consumption subject/path refusal, HTTP configuration/query downgrade, exact nonce binding, cancellation with concurrent eviction, generic cancellation storage failure with eventual sweep, duplicate cookies and one concurrent application binding winner. Four Go overlay mutations each fail the intended test: future credit, wall-time age in place of high-water age, freshness checked before consumption, and skipped binding. Logs: /tmp/oficina-link-consumer-full.log and /tmp/oficina-link-consumer-mutations/results.json.

A deliberate failure case: transit time plus up to30 seconds of previously observed clock lead can shorten the effective five-minute consumer window. A consumer clock behind Home can also see actual fresh authentication as future. Both refuse and consume that proof; the user starts again. No timestamp is clamped. Older readers refuse fresh payloads before consumption, leaving an inert pending row until expiry.

Publication prerequisite remains: integrate the independently reviewed shared sealer before publishing the combined module for Docs. This branch does not yet update Home's module pin or deploy either consumer.
