> ## Documentation Index
> Fetch the complete documentation index at: https://docs.neobank.proofof.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# One redirect action shape

> Every hosted-URL hand-off publishes one RedirectAction with a kind discriminator, per-field nullability, and enforced per-kind guarantees.

# ADR 0015: Publish one redirect action shape

## Context

Four schemas carried the same concept — open this provider URL, optionally
before a deadline, optionally polling for the outcome. The URL field was
named `url` three times and `connect_url` once; expiry and polling appeared
or vanished per shape; and the approval flavour carried a discriminator
with a single value, which cannot discriminate. The agent-purchase domain
adds a fifth producer, and adding it to four bespoke shapes would deepen
the divergence.

## Decision

One `RedirectAction`: a `kind` discriminator whose closed vocabulary names
every producer — `kyc_session`, `card_reveal`, `grant_approval`, `connect`
— plus `url` (required), `expires_at`, and `poll_after_ms`. Nullability is
decided per field, not inherited from the loosest producer: a null
`expires_at` means the provider communicated no expiry, true today only of
the KYC producer; a null `poll_after_ms` means no polling applies.

Some kinds guarantee more, and both the model and the published schema
description say so, so the contract cannot promise less than the service
delivers: `card_reveal`, `grant_approval`, and `connect` actions always
carry an expiry — the connection service refuses an upstream session
without a usable one — and a `grant_approval` action a positive poll
interval. A guarantee table keyed by kind makes an undecided new kind an
import-time failure. The service refuses to emit an incomplete approval
action as a handled provider error rather than constructing an invalid
one.

`PerfloConnectionView` keeps `status` and `account_hint` as its own fields
and embeds `RedirectAction | None` — null when there is nothing to open:
the connection is settled, or its pending provider session is no longer
readable. That is the nullable `connect_url` it replaces.
`ConfirmationIntentView.step_up_url` stays outside this shape by intent:
it targets the wrapper's own login route, not a provider hosted page, and
its expiry belongs to the intent rather than the URL.
`OperationView.action_required` is the `grant_approval` flavour. The KYC
and reveal session routes return the action directly. One projection
helper validates the hosted URL and builds the shape, with each producer
supplying its own error detail.

A future approval flavour — the agent domain's spending-session activation
— becomes its own `kind`, inheriting the approval guarantees only if live
captures show the provider supplies an expiry and poll interval.

## Consequences

`KycSessionView`, `RevealSessionView`, and `PerfloApprovalAction` leave the
contract; a test pins that exactly one redirect schema exists. Clients can
branch on `kind` rather than on which endpoint produced the object, and the
connection dialog reads the action's `url` where it read `connect_url`.
Adding a producer is one new `kind` member, one guarantee-table entry —
which the import-time check makes mandatory — and one projection site.

## Rejected alternatives

Flattening the connection view into a bare redirect: the connection's
`status` and `account_hint` are facts about the connection, not the
redirect, and a settled connection has no redirect at all.

Inheriting the loosest nullability everywhere: it would erase the expiry
and polling guarantees the reveal and approval flows rely on, and the
service already refuses to emit less.

Keeping the single-value `type` discriminator and widening it later: the
fifth producer forces the widening anyway; widening once, here, is cheaper
than widening a bespoke shape there.
