Skip to content

Latest commit

 

History

History
867 lines (760 loc) · 61.3 KB

File metadata and controls

867 lines (760 loc) · 61.3 KB
phase 26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co
plan 5
type execute
wave 2
depends_on
26-01
26-02
26-03
26-04
files_modified
lib/lockspire/protocol/registration.ex
test/lockspire/protocol/registration_test.exs
autonomous true
requirements
DCR-02
DCR-03
DCR-04
DCR-22
DCR-23
must_haves
truths artifacts key_links
`Lockspire.Protocol.Registration.register/1` accepts `%{metadata, iat, server_policy, source}` and returns `{:ok, %Success{client, client_secret_plaintext, registration_access_token_plaintext}}` on the happy path.
Intake validator rejects `jwks_uri` with `%Error{code: :invalid_client_metadata, field: :jwks_uri, reason: :unsupported_in_slice}`.
Intake validator rejects metadata with both `jwks` and `jwks_uri` (mutual exclusion).
Intake validator rejects RFC 7591 §2 incoherent grant_types/response_types pairs.
Intake validator routes `redirect_uris` through `Lockspire.Clients.validate_redirect_uris/1` for exact-match parity with operator-created clients.
Intake validator rejects explicit `pkce_required: false` with reason `:pkce_floor_required_for_dcr` (D-15).
Persisted `Domain.Client` row has `pkce_required: true` regardless of inbound metadata's `pkce_required` value.
Persisted `Domain.Client` row has `client_secret_hash` (salted via `Policy.hash_client_secret/1`) and `registration_access_token_hash` (deterministic via `Policy.hash_token/1`).
`Lockspire.Security.Policy.verify_client_secret(persisted.client_secret_hash, success.client_secret_plaintext)` returns `true` — round-trip proof that the persisted hash and the returned plaintext form a matched pair (the caller-supplied hash is not silently regenerated mid-flight).
Plaintext `client_secret` and `registration_access_token` are returned ONCE on the `Success` substruct and are NEVER persisted.
Pipeline order is: precondition gate (iat=nil + registration_policy=:initial_access_token) → IAT redemption (if non-nil) → DcrPolicy.resolve/3 → intake validator → credential generation → persist via `Admin.Clients.create_dcr_client/1` (single transaction) → post-commit telemetry.
When `server_policy.registration_policy == :initial_access_token` AND `iat == nil`, `register/1` returns `{:error, %Error{code: :invalid_token, field: :iat, reason: :missing}}` BEFORE the IAT redemption step — anonymous registration is gated upstream of `maybe_redeem_iat/1` (RESEARCH Q5 RESOLVED).
Persistence routes through `Lockspire.Admin.Clients.create_dcr_client/1` (the new Wave-0 helper from plan 26-01 task 4) — NOT through `Lockspire.Clients.register_client/1` which strips DCR fields. Every DCR field on the persisted row matches the RFC 7591 → `%Domain.Client{}` mapping table in Task 2b's action.
Unknown RFC 7591 metadata fields (including `software_statement` per RFC 7591 §2.3) are silently ignored — the persisted row carries no record of them; the registration succeeds normally (RESEARCH Q6 RESOLVED).
Audit attribution: every DCR write carries `actor: %{type: :dcr, id: iat_id_or_"anonymous", display: source.ip}`.
Telemetry emits `:dcr_registration_succeeded` (success) or `:dcr_registration_rejected` (failure) via `Lockspire.Observability.emit/3` — never carrying plaintext.
path provides min_lines contains exports
lib/lockspire/protocol/registration.ex
RFC 7591 intake orchestrator with private validator + credential generation + persistence + post-commit telemetry
200
defmodule Lockspire.Protocol.Registration
defmodule Success
defmodule Error
def register(request)
register/1
path provides contains
test/lockspire/protocol/registration_test.exs
Happy-path + every D-14/D-15 sad-path + audit-actor + telemetry-event assertion
describe "register/1"
from to via pattern
lib/lockspire/protocol/registration.ex
Lockspire.Protocol.InitialAccessToken.redeem/1
pipeline step 2 — IAT redemption (after precondition gate)
InitialAccessToken\.redeem
from to via pattern
lib/lockspire/protocol/registration.ex
Lockspire.Protocol.DcrPolicy.resolve/3
pipeline step 3 — policy intersection
DcrPolicy\.resolve
from to via pattern
lib/lockspire/protocol/registration.ex
Lockspire.Clients.validate_redirect_uris/1
pipeline step 4 — D-14 redirect URI validation
Clients\.validate_redirect_uris
from to via pattern
lib/lockspire/protocol/registration.ex
Lockspire.Clients.rotate_secret_hash/0
pipeline step 5 — client_secret generation (D-04, D-16)
Clients\.rotate_secret_hash
from to via pattern
lib/lockspire/protocol/registration.ex
Lockspire.Clients.generate_client_id/0
pipeline step 5 — client_id generation (D-16)
Clients\.generate_client_id
from to via pattern
lib/lockspire/protocol/registration.ex
Lockspire.Protocol.RegistrationAccessToken.generate/0
pipeline step 5 — RAT generation (D-06, D-16)
RegistrationAccessToken\.generate
from to via pattern
lib/lockspire/protocol/registration.ex
Lockspire.Admin.Clients.create_dcr_client/1
pipeline step 6 — DCR-aware persistence helper (preserves provenance/RAT-hash/IAT-FK/issued_at/expires_at verbatim; from plan 26-01 task 4)
Admin\.Clients\.create_dcr_client
from to via pattern
lib/lockspire/protocol/registration.ex
Lockspire.Admin.Clients.actor_from_attrs (via persistence path)
audit attribution chokepoint — actor.type explicitly set to :dcr
type: :dcr
Author `Lockspire.Protocol.Registration` — the RFC 7591 intake orchestrator. Implements the full D-13 pipeline (refined per revision): precondition gate (iat=nil + registration_policy=:initial_access_token rejection per RESEARCH Q5 RESOLVED) → IAT redemption (optional) → DcrPolicy.resolve/3 → intake validator (private; covers D-14 and D-15) → credential generation → single-transaction persistence via the new `Admin.Clients.create_dcr_client/1` helper from plan 26-01 task 4 → post-commit telemetry. The orchestrator is `Plug.Conn`-free (D-03), exposes `Success` and `Error` substructs (D-12), and uses the `with` pipeline pattern from `pushed_authorization_request.ex:43-64`.

Purpose: This is the heart of Phase 26 — DCR-02 (intake validation), DCR-03 (PKCE floor), DCR-04 (credential issuance + hash-at-rest) all close here. The orchestrator wires together every Wave 0 and Wave 1 primitive: the public generate_client_id/0, the tightened actor_from_attrs/1, the new DCR-aware create_dcr_client/1 (which preserves provenance/RAT-hash/IAT-FK/issued_at/expires_at end-to-end, unlike Lockspire.Clients.register_client/1 which strips DCR fields), the RAT primitives, the IAT atomic redeemer, and the DCR fixtures (in tests). Every D-14 / D-15 axis must produce the exact %Error{field, reason} shape so the Phase 27 HTTP adapter can map to RFC 7591 §3.2.2 error responses.

This plan splits the implementation across two TDD tasks: Task 2a builds the module shell + substructs + register/1 skeleton + the precondition gate + validate_intake_metadata/2. Task 2b adds credential generation + persistence (with the full RFC 7591 → %Domain.Client{} field mapping table) + telemetry. RED→GREEN per task.

Output: A 200-300-line orchestrator module + a comprehensive test suite covering happy path, every sad path, persistence assertions (the row has pkce_required: true, hashed credentials, correct provenance, RAT hash matches Policy.hash_token(plaintext), Policy.verify_client_secret(stored_hash, returned_plaintext) == true, IAT FK populated, software_statement passthrough silently ignored), audit-row attribution, and telemetry-event assertions.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/STATE.md @.planning/ROADMAP.md @.planning/REQUIREMENTS.md @.planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-CONTEXT.md @.planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-PATTERNS.md @.planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-VALIDATION.md @lib/lockspire/protocol/pushed_authorization_request.ex @lib/lockspire/protocol/dcr_policy.ex @lib/lockspire/clients.ex @lib/lockspire/admin/clients.ex @lib/lockspire/security/policy.ex @lib/lockspire/observability.ex @lib/lockspire/storage/ecto/repository.ex @test/support/fixtures/dcr_fixtures.ex

From lib/lockspire/protocol/pushed_authorization_request.ex (lines 13-90, 177-179):

  • Success and Error substructs (template for Phase 26 substructs).
  • with pipeline shape inside push/1.
  • wrap_jar_error/1 collapse pattern.

From lib/lockspire/protocol/initial_access_token.ex (Wave 1 plan 26-03):

@spec redeem(String.t()) :: {:ok, Lockspire.Domain.InitialAccessToken.t()} | {:error, :invalid_token}

From lib/lockspire/protocol/dcr_policy.ex (Phase 25):

@spec resolve(ServerPolicy.t(), iat_overrides_or_nil :: map() | nil, inbound_metadata :: map()) ::
        {:ok, Resolved.t()} | {:error, :invalid_client_metadata, %{field: atom(), reason: atom(), allowed: list() | nil}}

The Resolved struct (also Phase 25) carries the narrowed allowlists Phase 26 reads back from. Read lib/lockspire/protocol/dcr_policy.ex to confirm the exact field names.

From lib/lockspire/clients.ex:

@spec validate_redirect_uris(list()) :: :ok | {:error, term()}    # line 32
def validate_redirect_uris(redirect_uris)

@spec generate_client_id() :: String.t()                          # line 384 (PUBLIC after Wave 0 plan 26-01)
def generate_client_id

@spec rotate_secret_hash() :: {hash :: String.t(), plaintext :: String.t()}   # lines 53-56
def rotate_secret_hash

From lib/lockspire/protocol/registration_access_token.ex (Wave 1 plan 26-02):

@spec generate() :: {plaintext :: String.t(), hash :: String.t()}
def generate

From lib/lockspire/admin/clients.ex:

# Public; the persistence path goes through this. After Wave 0 plan 26-01 it raises ArgumentError on missing actor.type.
def create_client(attrs)

Read lib/lockspire/admin/clients.ex lines 50-130 to confirm the attrs shape create_client/1 accepts. The orchestrator builds attrs = %{client: %Domain.Client{...}, actor: %{type: :dcr, id: ..., display: ...}} (or whatever the existing shape demands — read first, don't guess).

From lib/lockspire/observability.ex:

def emit(event_name, measurements \\ %{}, metadata \\ %{}) when is_atom(event_name)

Phase 26 event names (D-26): :dcr_registration_succeeded, :dcr_registration_rejected.

From test/support/fixtures/dcr_fixtures.ex (Wave 1 plan 26-04):

  • valid_metadata/0, invalid_jwks_uri_metadata/0, mutual_jwks_metadata/0, incoherent_grant_response_metadata/0, invalid_redirect_uri_metadata/0, pkce_required_false_metadata/0, server_policy/1, register_request/1.

Public surface this plan delivers:

defmodule Lockspire.Protocol.Registration do
  defmodule Success do
    @type t :: %__MODULE__{
            client: Lockspire.Domain.Client.t(),
            client_secret_plaintext: String.t() | nil,
            registration_access_token_plaintext: String.t()
          }
    defstruct [:client, :client_secret_plaintext, :registration_access_token_plaintext]
  end

  defmodule Error do
    @type t :: %__MODULE__{
            code: atom(),
            field: atom() | nil,
            reason: atom() | nil,
            allowed: list() | nil
          }
    defstruct [:code, :field, :reason, :allowed]
  end

  @type result :: {:ok, Success.t()} | {:error, Error.t()}

  @spec register(map()) :: result()
  def register(request) when is_map(request)

  # @doc false but exported (def, not defp) so Lockspire.Protocol.RegistrationManagement
  # (Wave 2 plan 26-06) can call the same validator pipeline per D-02.
  @doc false
  @spec validate_intake_metadata(map(), Resolved.t()) :: :ok | {:error, Error.t()}
  def validate_intake_metadata(metadata, resolved)
end
Task 1: Replace Wave-0 stub with full Registration test contract (RED) test/lockspire/protocol/registration_test.exs - test/lockspire/protocol/registration_test.exs (the Wave-0 stub from plan 26-01 task 3) - test/lockspire/protocol/pushed_authorization_request_test.exs (lines 1-100 — DB-backed setup template) - test/support/fixtures/dcr_fixtures.ex (Wave 1 plan 26-04 — confirm helper signatures) - test/support/fixtures/initial_access_token_fixtures.ex (post-Wave-1 — confirm `persist_with_plaintext/1` exists) - .planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-PATTERNS.md (§"`test/lockspire/protocol/registration_test.exs`") - .planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-CONTEXT.md (D-12, D-13, D-14, D-15, D-22, D-23) - lib/lockspire/audit/event.ex (lines 50-100 — confirm `actor_type` stringification at ~line 94 — same as plan 26-01 task 2) Replace the Wave-0 stub with the full test module covering:
`describe "register/1 happy path"`:
- test "returns `{:ok, %Success{client: %Client{}, client_secret_plaintext: bin, registration_access_token_plaintext: bin}}` for valid metadata + redeemable IAT"
- test "persisted `Domain.Client` has `pkce_required: true` regardless of inbound `pkce_required`" (omit inbound `pkce_required` and verify the persisted row's value)
- test "persisted `Domain.Client` has `client_secret_hash` matching the format `\"sha256:<salt>:<hash>\"`"
- test "**round-trip proof: `Lockspire.Security.Policy.verify_client_secret(persisted.client_secret_hash, success.client_secret_plaintext)` returns `true`** — the persisted hash and the returned plaintext form a matched pair. If this fails, the persistence path silently regenerated the secret server-side and the plaintext returned to the caller is unusable (warning 8)."
- test "persisted `Domain.Client` has `registration_access_token_hash` equal to `Policy.hash_token(success.registration_access_token_plaintext)`"
- test "persisted `Domain.Client` has `provenance: :self_registered`"
- test "persisted `Domain.Client` has `initial_access_token_id` matching the redeemed IAT's id when iat is non-nil"
- test "persisted `Domain.Client` has `client_id_issued_at` set to a recent UTC datetime"
- test "persisted `Domain.Client` has `client_secret_expires_at` set from `Resolved.dcr_default_client_secret_lifetime_seconds`"
- test "**unknown-field passthrough: `register/1` silently ignores `software_statement`** — given `metadata = Map.put(valid_metadata, \"software_statement\", \"eyJ...\")`, registration succeeds normally, the persisted row has no `software_statement` field set, the persisted `metadata` JSONB column does NOT contain the key `\"software_statement\"` (warning 7; RESEARCH Q6 RESOLVED)."
- test "happy-path emits `:dcr_registration_succeeded` event with `actor_type: :dcr` in metadata and NO plaintext fields"
- test "audit row written by happy path has `actor_type == \"dcr\"` (NOT `\"operator\"`)"

`describe "register/1 — IAT precondition gate (D-13 step 1 — RESEARCH Q5 RESOLVED)"`:
- test "rejects with `{:error, %Error{code: :invalid_token, field: :iat, reason: :missing}}` when `server_policy.registration_policy == :initial_access_token` AND `iat == nil` — fired BEFORE `maybe_redeem_iat/1` runs"
- test "the precondition emits `:dcr_registration_rejected` telemetry with `code: :invalid_token, field: :iat, reason: :missing` and NO call to `InitialAccessToken.redeem/1` (verify by attaching a `:telemetry` handler to `[:lockspire, :iat_redemption_failed]` and asserting `refute_received {:telemetry_event, [:lockspire, :iat_redemption_failed], _, _}, 200`)"
- test "succeeds when `server_policy.registration_policy == :open` AND `iat == nil` (the gate does NOT trigger)"
- test "succeeds when `server_policy.registration_policy == :initial_access_token` AND `iat` is a valid plaintext (the gate does NOT trigger; `maybe_redeem_iat/1` runs and IAT is consumed)"

`describe "register/1 — IAT redemption (D-13 step 2)"`:
- test "rejects with `{:error, :invalid_token}` when iat is non-nil but already used"
- test "rejects with `{:error, :invalid_token}` when iat is non-nil but revoked"
- test "rejects with `{:error, :invalid_token}` when iat is non-nil but expired"
- test "registers anonymously when iat is nil and server_policy.registration_policy == :open" (audit actor.id == \"anonymous\")

`describe "register/1 — D-14 validator"`:
- test "rejects metadata with `jwks_uri` returning `%Error{code: :invalid_client_metadata, field: :jwks_uri, reason: :unsupported_in_slice}`"
- test "rejects metadata with both `jwks` and `jwks_uri` returning `%Error{field: :jwks, reason: :mutually_exclusive_with_jwks_uri}`" (note: `jwks_uri` is rejected first, but the test uses fixture `mutual_jwks_metadata` that may trigger either; pin to whichever fixture-rule the validator hits first)
- test "rejects RFC 7591 §2 incoherent grant/response pair (`refresh_token` without `authorization_code`) returning `%Error{field: :grant_types, reason: :incoherent_pair}` (or `field: :response_types`)"
- test "rejects redirect URIs that fail `Lockspire.Clients.validate_redirect_uris/1` returning `%Error{field: :redirect_uris}`"

`describe "register/1 — D-15 PKCE floor"`:
- test "rejects metadata with explicit `pkce_required: false` returning `%Error{field: :pkce_required, reason: :pkce_floor_required_for_dcr}`"
- test "accepts metadata that omits `pkce_required` (the persisted row gets `pkce_required: true`)"

`describe "register/1 — failure-path telemetry"`:
- test "every sad path emits `:dcr_registration_rejected` (or per-axis variant) with `reason` measurement and NO plaintext"
- test "no audit row from sad path is attributed to `:operator` (regression sentinel — full sweep lives in plan 26-07's `dcr_audit_attribution_test.exs`)"

Use `:telemetry.attach_many/4` to capture both `[:lockspire, :dcr_registration_succeeded]` and `[:lockspire, :audit, :dcr_registration_succeeded]` (and the `_rejected` variants). Detach in `on_exit`.

Run `mix test test/lockspire/protocol/registration_test.exs` — every test must FAIL with `(UndefinedFunctionError) function Lockspire.Protocol.Registration.register/1 is undefined` (RED — module doesn't exist yet).
Open the existing Wave-0 stub. Replace its single `@tag :pending` test with the full test module described above.
Module preamble:
```elixir
defmodule Lockspire.Protocol.RegistrationTest do
  use ExUnit.Case, async: false
  import Ecto.Query

  alias Lockspire.Domain.Client
  alias Lockspire.Domain.InitialAccessToken, as: IatDomain
  alias Lockspire.Protocol.Registration
  alias Lockspire.Protocol.Registration.Error
  alias Lockspire.Protocol.Registration.Success
  alias Lockspire.Security.Policy
  alias Lockspire.Storage.Ecto.AuditEventRecord
  alias Lockspire.Storage.Ecto.Repository
  alias Lockspire.Test.Fixtures.DcrFixtures
  alias Lockspire.Test.Fixtures.InitialAccessTokenFixtures

  setup_all do
    Application.put_env(:lockspire, :repo, Lockspire.TestRepo)
    start_supervised!(Lockspire.TestRepo)
    Ecto.Adapters.SQL.Sandbox.mode(Lockspire.TestRepo, :manual)
    :ok
  end

  setup do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(Lockspire.TestRepo)
    handler_id = "registration-test-#{System.unique_integer([:positive])}"

    :ok =
      :telemetry.attach_many(
        handler_id,
        [
          [:lockspire, :dcr_registration_succeeded],
          [:lockspire, :dcr_registration_rejected],
          [:lockspire, :audit, :dcr_registration_succeeded],
          [:lockspire, :audit, :dcr_registration_rejected]
        ],
        fn event, measurements, metadata, pid ->
          send(pid, {:telemetry_event, event, measurements, metadata})
        end,
        self()
      )

    on_exit(fn -> :telemetry.detach(handler_id) end)
    %{}
  end
```

Implement every `describe` block above with concrete `assert ... = Registration.register(DcrFixtures.register_request(...))` patterns. Use `assert_receive {:telemetry_event, ...}, 500` for telemetry assertions. For the audit-row check, query directly:
```elixir
rows = Lockspire.TestRepo.all(
  from(audit in AuditEventRecord,
    where: like(audit.action, "dcr_%"),
    order_by: [desc: audit.id])
)
refute Enum.any?(rows, &(&1.actor_type == "operator"))
```

Run `mix test test/lockspire/protocol/registration_test.exs` and confirm all tests fail (RED).
mix test test/lockspire/protocol/registration_test.exs --max-failures=20 ; test $? -ne 0 # expect non-zero (RED) - `grep -c '@tag :pending' test/lockspire/protocol/registration_test.exs` returns `0`. - `grep -c 'describe "register/1 happy path"' test/lockspire/protocol/registration_test.exs` returns `1`. - `grep -c 'describe "register/1 — D-14 validator"' test/lockspire/protocol/registration_test.exs` returns `1`. - `grep -c 'describe "register/1 — D-15 PKCE floor"' test/lockspire/protocol/registration_test.exs` returns `1`. - `grep -c 'describe "register/1 — IAT precondition gate (D-13 step 1 — RESEARCH Q5 RESOLVED)"' test/lockspire/protocol/registration_test.exs` returns `1`. - `grep -c 'describe "register/1 — IAT redemption (D-13 step 2)"' test/lockspire/protocol/registration_test.exs` returns `1`. - `grep -c 'software_statement' test/lockspire/protocol/registration_test.exs` returns at least `2` (warning 7 — passthrough test). - `grep -c 'Policy.verify_client_secret' test/lockspire/protocol/registration_test.exs` returns at least `1` (warning 8 — round-trip proof). - `grep -c 'DcrFixtures\\.' test/lockspire/protocol/registration_test.exs` returns at least `8` (every fixture helper used in tests). - `grep -c ':telemetry.attach_many' test/lockspire/protocol/registration_test.exs` returns at least `1`. - `mix test test/lockspire/protocol/registration_test.exs` exits non-zero (RED — module doesn't exist). RED phase complete — full intake contract is described in tests. Task 2 makes them pass. Task 2a: Author module shell + substructs + register/1 skeleton + iat=nil precondition gate + validate_intake_metadata/2 (GREEN) lib/lockspire/protocol/registration.ex - lib/lockspire/protocol/pushed_authorization_request.ex (full file — verbatim structural template per D-01) - lib/lockspire/protocol/dcr_policy.ex (full file — confirm `resolve/3` signature + `Resolved` struct fields) - lib/lockspire/clients.ex (full file — confirm `validate_redirect_uris/1` rejection contract, `generate_client_id/0` is now public) - lib/lockspire/domain/server_policy.ex (confirm `:registration_policy` field is one of `:disabled | :initial_access_token | :open`) - lib/lockspire/redaction.ex (post Wave-0 plan 26-01 task 5 — `:rat`, `:registration_access_token`, `:iat`, `:initial_access_token` are now in the drop list, but the orchestrator must STILL never put plaintext into metadata in the first place — defense in depth) - .planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-PATTERNS.md (§"`lib/lockspire/protocol/registration.ex`") - .planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-CONTEXT.md (D-12, D-13, D-14, D-15) - .planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-RESEARCH.md (Open Question 5 RESOLVED — iat=nil + registration_policy=:initial_access_token gate) This task brings every test in Task 1's `describe "register/1 — D-14 validator"`, `describe "register/1 — D-15 PKCE floor"`, `describe "register/1 — IAT precondition gate (D-13 step 1 — RESEARCH Q5 RESOLVED)"`, and `describe "register/1 — IAT redemption (D-13 step 2)"` to GREEN. The happy-path tests, persistence-shape tests, audit-attribution tests, and telemetry tests will still be RED at the end of this task — Task 2b makes those pass. After this task: `register/1` returns `{:error, ...}` on every sad path correctly; on the happy path it returns `{:error, %Error{code: :persistence_error, ...}}` because `persist_client/5` is a stub.
Step 1 — Module shell + substructs (D-12):
```elixir
defmodule Lockspire.Protocol.Registration do
  @moduledoc """
  RFC 7591 dynamic client registration intake — `Plug.Conn`-free orchestrator.

  Pipeline (per Phase 26 D-13, refined per RESEARCH Q5 RESOLVED):
    1. Precondition gate — when `server_policy.registration_policy == :initial_access_token`
       and `iat == nil`, reject with `%Error{code: :invalid_token, field: :iat, reason: :missing}`
       BEFORE any other step.
    2. IAT redemption via `Lockspire.Protocol.InitialAccessToken.redeem/1` (skipped if `iat` is nil).
    3. DcrPolicy resolution via `Lockspire.Protocol.DcrPolicy.resolve/3` (Phase 25).
    4. Slice-specific intake validation (D-14 jwks/coherence/redirect + D-15 PKCE floor).
    5. Credential generation (`client_id`, `client_secret`, `registration_access_token`).
    6. Persistence via `Lockspire.Admin.Clients.create_dcr_client/1` (DCR-aware persistence
       helper from plan 26-01 task 4 — preserves provenance/RAT-hash/IAT-FK/issued_at/expires_at
       verbatim, unlike the legacy `Lockspire.Clients.register_client/1` which strips them).
    7. Post-commit audit + telemetry emission (`:dcr_registration_succeeded` /
       `:dcr_registration_rejected`).

  Per D-11 IAT-style enumeration defense, IAT redemption failures collapse to
  `%Error{code: :invalid_token}` (the discriminator stays in telemetry).
  """

  alias Lockspire.Admin
  alias Lockspire.Clients
  alias Lockspire.Domain.Client
  alias Lockspire.Domain.InitialAccessToken, as: IatDomain
  alias Lockspire.Domain.ServerPolicy
  alias Lockspire.Observability
  alias Lockspire.Protocol.DcrPolicy
  alias Lockspire.Protocol.DcrPolicy.Resolved
  alias Lockspire.Protocol.InitialAccessToken
  alias Lockspire.Protocol.RegistrationAccessToken
  alias Lockspire.Security.Policy

  defmodule Success do
    @type t :: %__MODULE__{
            client: Client.t(),
            client_secret_plaintext: String.t() | nil,
            registration_access_token_plaintext: String.t()
          }
    defstruct [:client, :client_secret_plaintext, :registration_access_token_plaintext]
  end

  defmodule Error do
    @type t :: %__MODULE__{
            code: atom(),
            field: atom() | nil,
            reason: atom() | nil,
            allowed: list() | nil
          }
    defstruct [:code, :field, :reason, :allowed]
  end

  @type result :: {:ok, Success.t()} | {:error, Error.t()}
```

Step 2 — Public `register/1` with precondition gate + `with` pipeline:
```elixir
@spec register(map()) :: result()
def register(%{metadata: metadata, server_policy: %ServerPolicy{} = server_policy} = request)
    when is_map(metadata) do
  iat = Map.get(request, :iat)
  source = Map.get(request, :source, %{ip: nil, user_agent: nil})

  with :ok <- require_iat_when_policy_demands(server_policy, iat),
       {:ok, iat_record} <- maybe_redeem_iat(iat),
       {:ok, %Resolved{} = resolved} <- resolve_policy(server_policy, iat_record, metadata),
       :ok <- validate_intake_metadata(metadata, resolved),
       credentials <- generate_credentials(),
       {:ok, %Client{} = client} <- persist_client(metadata, resolved, iat_record, credentials, source) do
    emit_succeeded(client, iat_record, source)

    {:ok,
     %Success{
       client: client,
       client_secret_plaintext: credentials.client_secret,
       registration_access_token_plaintext: credentials.rat
     }}
  else
    {:error, %Error{} = error} ->
      emit_rejected(error, source)
      {:error, error}
  end
end
```

Step 3 — Precondition gate (RESEARCH Q5 RESOLVED — the new step):
```elixir
# Reject anonymous registration when server policy demands an IAT.
# Fired BEFORE maybe_redeem_iat/1 so no IAT-redemption-failure telemetry is emitted on this axis.
defp require_iat_when_policy_demands(%ServerPolicy{registration_policy: :initial_access_token}, nil) do
  {:error, %Error{code: :invalid_token, field: :iat, reason: :missing}}
end

defp require_iat_when_policy_demands(%ServerPolicy{}, _iat), do: :ok
```

Step 4 — `maybe_redeem_iat/1` (private; collapses IAT failures to `%Error{code: :invalid_token}` per D-11):
```elixir
defp maybe_redeem_iat(nil), do: {:ok, nil}

defp maybe_redeem_iat(plaintext) when is_binary(plaintext) do
  case InitialAccessToken.redeem(plaintext) do
    {:ok, %IatDomain{} = iat} -> {:ok, iat}
    {:error, :invalid_token} -> {:error, %Error{code: :invalid_token}}
  end
end
```

Step 5 — `resolve_policy/3` (private wrapper around DcrPolicy.resolve/3):
```elixir
defp resolve_policy(server_policy, iat_record, metadata) do
  iat_overrides = iat_record && Map.get(iat_record, :policy_overrides)

  case DcrPolicy.resolve(server_policy, iat_overrides, metadata) do
    {:ok, %Resolved{} = resolved} ->
      {:ok, resolved}

    {:error, :invalid_client_metadata, %{field: field, reason: reason} = info} ->
      {:error,
       %Error{
         code: :invalid_client_metadata,
         field: field,
         reason: reason,
         allowed: Map.get(info, :allowed)
       }}
  end
end
```

Step 6 — `validate_intake_metadata/2` (PUBLIC `@doc false`, so `RegistrationManagement.update/2` in plan 26-06 can reuse it per D-02; covers D-14 + D-15):
```elixir
@doc false
@spec validate_intake_metadata(map(), Resolved.t()) :: :ok | {:error, Error.t()}
def validate_intake_metadata(metadata, %Resolved{} = _resolved) when is_map(metadata) do
  with :ok <- validate_jwks(metadata),
       :ok <- validate_grant_response_coherence(metadata),
       :ok <- validate_redirect_uris(metadata),
       :ok <- validate_pkce_floor(metadata) do
    :ok
  end
end

# D-14: jwks_uri rejected first (mutual-exclusion check is shadowed when both present
# because jwks_uri rule fires first; we still keep the explicit rule for spec clarity).
defp validate_jwks(metadata) do
  cond do
    Map.has_key?(metadata, "jwks_uri") ->
      {:error, %Error{code: :invalid_client_metadata, field: :jwks_uri, reason: :unsupported_in_slice}}

    Map.has_key?(metadata, "jwks") and Map.has_key?(metadata, "jwks_uri") ->
      {:error, %Error{code: :invalid_client_metadata, field: :jwks, reason: :mutually_exclusive_with_jwks_uri}}

    true ->
      :ok
  end
end

# D-14: RFC 7591 §2 grant_types/response_types coherence.
defp validate_grant_response_coherence(metadata) do
  grant_types = Map.get(metadata, "grant_types", []) |> List.wrap()
  response_types = Map.get(metadata, "response_types", []) |> List.wrap()

  cond do
    "refresh_token" in grant_types and "authorization_code" not in grant_types ->
      {:error, %Error{code: :invalid_client_metadata, field: :grant_types, reason: :incoherent_pair}}

    "code" in response_types and "authorization_code" not in grant_types ->
      {:error, %Error{code: :invalid_client_metadata, field: :response_types, reason: :incoherent_pair}}

    true ->
      :ok
  end
end

defp validate_redirect_uris(metadata) do
  redirect_uris = Map.get(metadata, "redirect_uris", [])
  case Clients.validate_redirect_uris(redirect_uris) do
    :ok ->
      :ok

    {:error, _reason} ->
      {:error, %Error{code: :invalid_client_metadata, field: :redirect_uris, reason: :invalid_uri}}
  end
end

# D-15: explicit `pkce_required: false` is rejected (not silently coerced).
defp validate_pkce_floor(metadata) do
  case Map.get(metadata, "pkce_required") do
    false -> {:error, %Error{code: :invalid_client_metadata, field: :pkce_required, reason: :pkce_floor_required_for_dcr}}
    _ -> :ok
  end
end
```

Step 7 — Stub `generate_credentials/0` and `persist_client/5` (Task 2b implements these):
```elixir
defp generate_credentials do
  # Task 2b: full implementation
  raise "generate_credentials/0 stub — Task 2b implements"
end

defp persist_client(_metadata, _resolved, _iat_record, _credentials, _source) do
  # Task 2b: full implementation
  {:error, %Error{code: :persistence_error, reason: :not_implemented}}
end
```

Step 8 — Stub `emit_succeeded/3` and `emit_rejected/2`:
```elixir
defp emit_succeeded(_client, _iat_record, _source), do: :ok
defp emit_rejected(_error, _source), do: :ok
```

Step 9 — Run tests:
`mix test test/lockspire/protocol/registration_test.exs --include "describe 'register/1 — D-14 validator'" --max-failures=1` — passes.
`mix test test/lockspire/protocol/registration_test.exs --include "describe 'register/1 — D-15 PKCE floor'" --max-failures=1` — passes.
`mix test test/lockspire/protocol/registration_test.exs --include "describe 'register/1 — IAT precondition gate (D-13 step 1 — RESEARCH Q5 RESOLVED)'" --max-failures=1` — passes (the gate fires before maybe_redeem_iat/1).
`mix test test/lockspire/protocol/registration_test.exs --include "describe 'register/1 — IAT redemption (D-13 step 2)'" --max-failures=1` — passes for the rejection axes; the "registers anonymously" test fails (waiting on Task 2b).

Run `mix qa` — exit 0 (no warnings).
mix compile --warnings-as-errors && mix qa - `ls lib/lockspire/protocol/registration.ex` exists. - `grep -c 'defmodule Lockspire.Protocol.Registration' lib/lockspire/protocol/registration.ex` returns `1`. - `grep -c 'defmodule Success' lib/lockspire/protocol/registration.ex` returns `1`. - `grep -c 'defmodule Error' lib/lockspire/protocol/registration.ex` returns `1`. - `grep -c 'def register(%{metadata: metadata, server_policy:' lib/lockspire/protocol/registration.ex` returns `1`. - `grep -c 'def validate_intake_metadata' lib/lockspire/protocol/registration.ex` returns `1` (PUBLIC for plan 26-06 reuse). - `grep -c '@doc false' lib/lockspire/protocol/registration.ex` returns at least `1`. - `grep -c 'require_iat_when_policy_demands' lib/lockspire/protocol/registration.ex` returns at least `2` (definition + use site in `with`). - `grep -c 'registration_policy: :initial_access_token' lib/lockspire/protocol/registration.ex` returns `1` (the precondition gate's pattern match). - `grep -c 'reason: :missing' lib/lockspire/protocol/registration.ex` returns `1` (the precondition's Error reason). - `grep -c ':unsupported_in_slice' lib/lockspire/protocol/registration.ex` returns `1` (D-14 jwks_uri reason). - `grep -c ':pkce_floor_required_for_dcr' lib/lockspire/protocol/registration.ex` returns `1` (D-15 reason). - `grep -c ':incoherent_pair' lib/lockspire/protocol/registration.ex` returns at least `1`. - `mix compile --warnings-as-errors` exits 0. - The validator + precondition test groups in Task 1 all pass; the persistence + happy-path test groups still fail (RED — waiting on Task 2b). - `mix qa` exits 0. Task 2a complete. Module shell, substructs, the precondition gate (RESEARCH Q5), and the validator pipeline are in place. The orchestrator returns the right `%Error{}` on every sad path. Persistence stub returns `:not_implemented` — Task 2b makes the happy path land. Task 2b: Implement generate_credentials/0 + fully-specified persist_client/5 (RFC 7591 → %Domain.Client{} mapping table) + telemetry (GREEN) lib/lockspire/protocol/registration.ex - lib/lockspire/protocol/registration.ex (Task 2a — extend, do not rewrite) - lib/lockspire/admin/clients.ex (post Wave-0 plan 26-01 task 4 — confirm `create_dcr_client/1` signature + behavior; the helper accepts `%{client: %Domain.Client{}, actor: %{type, id, display}}` and persists the row VERBATIM through `Repository.register_client/1` + `transact_with_audit/2`) - lib/lockspire/clients.ex (confirm `rotate_secret_hash/0` returns `{hash, plaintext}`; `generate_client_id/0` is `def`) - lib/lockspire/protocol/registration_access_token.ex (Wave 1 plan 26-02 — confirm `generate/0` returns `{plaintext, hash}`) - lib/lockspire/security/policy.ex (lines 84-114 — `hash_token/1`, `hash_client_secret/1`, `verify_client_secret/2`) - lib/lockspire/domain/client.ex (full file — confirm DCR field set on the defstruct; the persistence path writes directly into `%Domain.Client{}` here) - lib/lockspire/protocol/dcr_policy.ex (confirm `Resolved` substruct fields including `dcr_default_client_secret_lifetime_seconds`, `dcr_default_registration_access_token_lifetime_seconds`, `dcr_allowed_scopes`, `dcr_allowed_grant_types`, `dcr_allowed_response_types`) - lib/lockspire/observability.ex (lines 15-29 — `emit/3` signature) - lib/lockspire/redaction.ex (post Wave-0 plan 26-01 task 5 — `:rat`, `:registration_access_token`, `:iat`, `:initial_access_token` are now in the drop list; orchestrator must STILL avoid emitting them in the first place) - .planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-CONTEXT.md (D-04, D-06, D-13 step 5/6, D-16, D-17, D-18, D-22, D-23, D-25, D-26) This task brings every test in Task 1's `describe "register/1 happy path"` and `describe "register/1 — failure-path telemetry"` to GREEN, plus the "registers anonymously when iat is nil and server_policy.registration_policy == :open" test from the IAT redemption block, plus the round-trip `Policy.verify_client_secret` test (warning 8) and the `software_statement` passthrough test (warning 7).
Step 1 — Replace the Task-2a stubs with the real `generate_credentials/0`:
```elixir
defp generate_credentials do
  {client_secret_hash, client_secret} = Clients.rotate_secret_hash()
  {rat_plaintext, rat_hash} = RegistrationAccessToken.generate()
  client_id = Clients.generate_client_id()

  %{
    client_id: client_id,
    client_secret: client_secret,
    client_secret_hash: client_secret_hash,
    rat: rat_plaintext,
    rat_hash: rat_hash
  }
end
```

Step 2 — Replace the Task-2a stub with the FULLY-SPECIFIED `persist_client/5`. The key insight: build a complete `%Domain.Client{}` struct (every field populated per the mapping table below), then hand it to `Admin.Clients.create_dcr_client/1` (the new helper from plan 26-01 task 4) which persists it verbatim through `Repository.register_client/1` + `transact_with_audit/2`. NO call to `Lockspire.Clients.register_client/1` (that path strips DCR fields).

**RFC 7591 wire field → `%Domain.Client{}` field mapping table (PIN — this is the contract):**

| RFC 7591 wire | Domain.Client field | Source | Normalization |
|---------------|---------------------|--------|---------------|
| `redirect_uris` | `:redirect_uris` | `metadata["redirect_uris"]` | exact-match list, validated upstream by `Lockspire.Clients.validate_redirect_uris/1` |
| `grant_types` | `:allowed_grant_types` | `metadata["grant_types"]` (default `["authorization_code"]`) | normalized list of strings, intersected with `resolved.dcr_allowed_grant_types` |
| `response_types` | `:allowed_response_types` | `metadata["response_types"]` (default `["code"]`) | normalized list of strings, intersected with `resolved.dcr_allowed_response_types` |
| `token_endpoint_auth_method` | `:token_endpoint_auth_method` | `metadata["token_endpoint_auth_method"]` (default `"client_secret_basic"`) | atomized; one of `:client_secret_basic | :client_secret_post | :private_key_jwt | :none` |
| `client_name` | `:name` | `metadata["client_name"]` | as-is string (or nil) |
| `scope` | `:allowed_scopes` | `metadata["scope"]` | space-separated string → list of scope strings, validated against `resolved.dcr_allowed_scopes` |
| `client_uri` | `:metadata["client_uri"]` | `metadata["client_uri"]` | passed through under `:metadata` JSONB column (not a top-level Domain.Client field in v1.5) |
| `logo_uri` | `:logo_uri` | `metadata["logo_uri"]` | as-is string (or nil) |
| `tos_uri` | `:tos_uri` | `metadata["tos_uri"]` | as-is string (or nil) |
| `policy_uri` | `:policy_uri` | `metadata["policy_uri"]` | as-is string (or nil) |
| `contacts` | `:contacts` | `metadata["contacts"]` | normalized list of strings (or `[]`) |
| (n/a wire) | `:client_id` | `credentials.client_id` (`Clients.generate_client_id/0`) | always generated server-side; metadata's `client_id` rejected by Phase 25 `DcrPolicy.resolve/3` |
| (n/a wire) | `:client_secret_hash` | `credentials.client_secret_hash` (`Policy.hash_client_secret(plaintext)` via `Clients.rotate_secret_hash/0`) | hash-at-rest format `"sha256:<salt>:<hash>"`; plaintext returned ONCE on `Success` |
| (n/a wire) | `:registration_access_token_hash` | `credentials.rat_hash` (`Policy.hash_token(rat_plaintext)` via `RegistrationAccessToken.generate/0`) | hash-at-rest plain SHA-256 lowercase hex; plaintext returned ONCE on `Success` |
| (n/a wire) | `:client_type` | derived from `:token_endpoint_auth_method` (`"none"` → `:public`; everything else → `:confidential`) | enforces RFC 7591 §2 client-type/auth-method coherence on the persisted row |
| (n/a wire) | `:pkce_required` | `true` (HARDCODED per D-15) | NEVER lowered; inbound `pkce_required: false` is rejected upstream by `validate_pkce_floor/1`; this literal floors the persisted row regardless |
| (n/a wire) | `:subject_type` | `:public` (default) | v1.5 doesn't expose pairwise; future phase |
| (n/a wire) | `:provenance` | `:self_registered` (HARDCODED) | NEVER `:operator` for this codepath; D-23 |
| (n/a wire) | `:initial_access_token_id` | `iat_record && iat_record.id` (or `nil` for anonymous mode) | references the IAT row marked used in the same transaction; FK to `lockspire_initial_access_tokens.id` |
| (n/a wire) | `:client_id_issued_at` | `now = DateTime.utc_now() |> DateTime.truncate(:microsecond)` | RFC 7591 §3.2.1; persisted on the row, returned in the JSON response by Phase 27 |
| (n/a wire) | `:client_secret_expires_at` | `DateTime.add(now, resolved.dcr_default_client_secret_lifetime_seconds || 0, :second)` | from server-policy/iat-overrides intersection (Phase 25 `Resolved`) |
| (n/a wire) | `:active` | `true` (default — newly created clients are active) | `disable_client/2` flips this on RFC 7592 delete |
| (n/a wire) | `:metadata` | `Map.take(metadata, ["client_uri"])` (and any other RFC 7591 wire-format extension fields we explicitly support; for v1.5 only `:client_uri` lands here) | JSONB column; do NOT include `software_statement` or any other unknown RFC 7591 extension field (RESEARCH Q6 RESOLVED — silently ignored) |
| `software_statement` | (DROPPED) | — | RFC 7591 §2.3 silently ignored (RESEARCH Q6 RESOLVED); the validator does NOT reject; persistence does NOT include the field. The persisted row's `:metadata` JSONB column does NOT contain the key `"software_statement"` (warning 7). |
| `jwks_uri` | (REJECTED at validator) | — | D-14: `validate_jwks/1` returns `{:error, %Error{field: :jwks_uri}}` before reaching `persist_client/5` |
| `jwks` (alone) | `:jwks` | `metadata["jwks"]` | as-is map (rare in v1.5; Phase 25 schema accepts the field) |

Concrete code:
```elixir
defp persist_client(metadata, %Resolved{} = resolved, iat_record, credentials, source) do
  now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
  iat_id = iat_record && Map.get(iat_record, :id)

  auth_method = atomize_auth_method(Map.get(metadata, "token_endpoint_auth_method", "client_secret_basic"))
  client_type = client_type_from_auth_method(auth_method)

  client = %Client{
    client_id: credentials.client_id,
    client_secret_hash: credentials.client_secret_hash,
    client_type: client_type,
    name: Map.get(metadata, "client_name"),
    redirect_uris: Map.get(metadata, "redirect_uris", []),
    allowed_scopes: parse_scope(Map.get(metadata, "scope", "")),
    allowed_grant_types: Map.get(metadata, "grant_types", ["authorization_code"]),
    allowed_response_types: Map.get(metadata, "response_types", ["code"]),
    token_endpoint_auth_method: auth_method,
    pkce_required: true,
    subject_type: :public,
    logo_uri: Map.get(metadata, "logo_uri"),
    tos_uri: Map.get(metadata, "tos_uri"),
    policy_uri: Map.get(metadata, "policy_uri"),
    contacts: Map.get(metadata, "contacts", []),
    jwks: Map.get(metadata, "jwks"),
    active: true,
    provenance: :self_registered,
    registration_access_token_hash: credentials.rat_hash,
    initial_access_token_id: iat_id,
    client_id_issued_at: now,
    client_secret_expires_at:
      DateTime.add(now, resolved.dcr_default_client_secret_lifetime_seconds || 0, :second),
    metadata: build_extension_metadata(metadata)
  }

  attrs = %{
    client: client,
    actor: %{
      type: :dcr,
      id: iat_id_or_anonymous(iat_id),
      display: source[:ip] || source["ip"]
    }
  }

  case Admin.Clients.create_dcr_client(attrs) do
    {:ok, %Client{} = persisted} -> {:ok, persisted}
    {:error, %Ecto.Changeset{} = changeset} -> {:error, %Error{code: :persistence_error, reason: changeset}}
    {:error, reason} -> {:error, %Error{code: :persistence_error, reason: reason}}
  end
end

defp iat_id_or_anonymous(nil), do: "anonymous"
defp iat_id_or_anonymous(id), do: to_string(id)

defp atomize_auth_method("client_secret_basic"), do: :client_secret_basic
defp atomize_auth_method("client_secret_post"), do: :client_secret_post
defp atomize_auth_method("private_key_jwt"), do: :private_key_jwt
defp atomize_auth_method("none"), do: :none
defp atomize_auth_method(_), do: :client_secret_basic

defp client_type_from_auth_method(:none), do: :public
defp client_type_from_auth_method(_), do: :confidential

defp parse_scope(scope) when is_binary(scope) do
  scope |> String.split(" ", trim: true) |> Enum.uniq()
end

defp parse_scope(_), do: []

# RFC 7591 §2.3 software_statement is silently ignored (RESEARCH Q6 RESOLVED).
# Only RFC 7591 extension fields we explicitly support land in :metadata JSONB.
defp build_extension_metadata(metadata) when is_map(metadata) do
  metadata
  |> Map.take(["client_uri"])
  |> Map.reject(fn {_k, v} -> is_nil(v) end)
end
```

Step 3 — Replace the Task-2a stubs with the real telemetry helpers (D-25, D-26 — NEVER carry plaintext):
```elixir
defp emit_succeeded(%Client{} = client, iat_record, source) do
  iat_id = iat_record && Map.get(iat_record, :id)

  Observability.emit(:dcr_registration_succeeded, %{count: 1}, %{
    actor_type: :dcr,
    actor_id: iat_id_or_anonymous(iat_id),
    client_id: client.client_id,
    iat_id: iat_id,
    source_ip: source[:ip] || source["ip"]
  })
end

defp emit_rejected(%Error{} = error, source) do
  Observability.emit(:dcr_registration_rejected, %{count: 1}, %{
    actor_type: :dcr,
    code: error.code,
    field: error.field,
    reason: error.reason,
    source_ip: source[:ip] || source["ip"]
  })
end
```

Step 4 — Run the full test suite:
`mix test test/lockspire/protocol/registration_test.exs --max-failures=1` — all tests pass.
`mix qa` — exit 0.

DEFENSE-IN-DEPTH check on telemetry redaction: `grep -E '%\{[^}]*(:rat|:registration_access_token|:client_secret|:plaintext|:initial_access_token)[^}]*\}' lib/lockspire/protocol/registration.ex | grep -E 'Observability\.emit|metadata' | wc -l` returns `0` (zero plaintext keys in any telemetry-metadata literal). Note: `:iat_id` (the integer FK) is OK; `:initial_access_token` (plaintext) is NOT.

AUDIT-ATTRIBUTION check (Task 1's regression-sentinel test): the persistence path goes through `Admin.Clients.create_dcr_client/1` → `actor_from_attrs/1` (tightened in plan 26-01 task 2) → `Audit.Event.normalize/1` → `actor_type` stringified to `"dcr"`. The test asserts `actor_type == "dcr"` on the audit row. If `"operator"` instead, plan 26-01's tightening did not propagate or `attrs.actor.type` was dropped — debug.

ROUND-TRIP `verify_client_secret` check (warning 8 in Task 1): the persisted `client_secret_hash` and the returned `client_secret_plaintext` form a pair. `Policy.verify_client_secret(persisted.client_secret_hash, success.client_secret_plaintext)` returns `true`. If `false`, the persistence path silently regenerated the secret server-side (the original `Lockspire.Clients.register_client/1` bug we routed around with `create_dcr_client/1`).

SOFTWARE_STATEMENT PASSTHROUGH check (warning 7 in Task 1): given metadata containing `"software_statement" => "eyJ..."`, the persisted row's `:metadata` JSONB does NOT contain the key `"software_statement"` (we only `Map.take(metadata, ["client_uri"])`); registration succeeds normally. RESEARCH Q6 RESOLVED.
mix test test/lockspire/protocol/registration_test.exs --max-failures=1 && mix qa - `grep -c 'Admin.Clients.create_dcr_client' lib/lockspire/protocol/registration.ex` returns `1` (DCR-aware persistence helper). - `grep -c 'Lockspire.Clients.register_client' lib/lockspire/protocol/registration.ex` returns `0` (must NOT route through the DCR-stripping legacy path). - `grep -c 'Clients.rotate_secret_hash' lib/lockspire/protocol/registration.ex` returns `1`. - `grep -c 'Clients.generate_client_id' lib/lockspire/protocol/registration.ex` returns `1`. - `grep -c 'RegistrationAccessToken.generate' lib/lockspire/protocol/registration.ex` returns `1`. - `grep -c 'pkce_required: true' lib/lockspire/protocol/registration.ex` returns at least `1` (the literal floor in `persist_client/5`). - `grep -c 'provenance: :self_registered' lib/lockspire/protocol/registration.ex` returns `1` (D-23 hardcoded). - `grep -c 'type: :dcr' lib/lockspire/protocol/registration.ex` returns at least `1` (audit attribution). - `grep -c 'Map.take(metadata, \["client_uri"\])' lib/lockspire/protocol/registration.ex | tr -d '[:space:]' | grep -E '^[1-9]' || true` — confirms the unknown-field passthrough convention (only `client_uri` lands in `:metadata` JSONB). - `grep -c 'Observability.emit(:dcr_registration_succeeded' lib/lockspire/protocol/registration.ex` returns `1`. - `grep -c 'Observability.emit(:dcr_registration_rejected' lib/lockspire/protocol/registration.ex` returns `1`. - Manual-grep regression sentinel: `grep -vE '^[[:space:]]*#' lib/lockspire/protocol/registration.ex | grep -E '%\{[^}]*(:rat[^_]|:registration_access_token|:plaintext|:initial_access_token[^_])[^}]*\}' | grep -E 'Observability\.emit|metadata' | wc -l` returns `0` (zero plaintext keys in any telemetry-metadata literal — note `:rat_id` and `:iat_id` are integer FKs and are OK; the regex excludes those). - `mix test test/lockspire/protocol/registration_test.exs --max-failures=1` exits 0. - `mix test test/lockspire/protocol/registration_test.exs: --max-failures=1` exits 0 (warning 8 closed). - `mix test test/lockspire/protocol/registration_test.exs: --max-failures=1` exits 0 (warning 7 closed). - `mix qa` exits 0. RFC 7591 intake is fully implemented end-to-end. DCR-02 (intake validation), DCR-03 (PKCE floor), DCR-04 (credential issuance + hash-at-rest, round-trip-proven), DCR-22 (audit attribution `:dcr`), DCR-23 (no plaintext in telemetry) all close at the protocol-module layer. The persistence path preserves every DCR field verbatim via `Admin.Clients.create_dcr_client/1`. Unknown RFC 7591 fields (including `software_statement`) are silently ignored. The round-trip `Policy.verify_client_secret` proof confirms the persisted hash and returned plaintext form a matched pair. Cross-cutting sweep tests at plan 26-07 are the final closing assertions.

<threat_model>

Trust Boundaries

Boundary Description
Inbound RFC 7591 metadata → Registration.register/1 Untrusted client-controlled JSON crosses into the validator. The validator is the only thing standing between attacker-shaped metadata and a persisted Domain.Client row.
Plaintext IAT (caller-supplied) → maybe_redeem_iat/1 Crosses into the module exactly once; immediately delegated to InitialAccessToken.redeem/1 (Wave 1) which hashes and collapses errors.
Generated credentials (client_secret, RAT) → Success substruct → Phase 27 controller Plaintext is returned to the immediate caller exactly once; the persisted row holds only hashes. Audit + telemetry emission paths must NOT see the plaintext.
Audit-event metadata → lockspire_audit_events JSONB columns Must be redacted via Audit.Event.normalize/1Lockspire.Redaction.for_audit/1.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-26-INTAKE-INJECTION Tampering Intake validator (private functions in Registration) mitigate D-14 enforcement: jwks_uri rejected outright; jwks ⊕ jwks_uri mutual exclusion; RFC 7591 §2 grant/response coherence via lookup table; redirect_uris routed through the operator-shared Lockspire.Clients.validate_redirect_uris/1. D-15 enforcement: explicit pkce_required: false rejected with :pkce_floor_required_for_dcr. The persisted row's pkce_required is hardcoded to true regardless of inbound value. Tests in Task 1's describe "register/1 — D-14 validator" and describe "register/1 — D-15 PKCE floor" are the closing automated proofs.
T-26-RAT-LEAK Information Disclosure Registration.register/1 post-commit telemetry mitigate The emit_succeeded and emit_rejected helpers pass only :client_id, :iat_id, :source_ip, :actor_type, :actor_id, :code, :field, :reason into Observability.emit/3 — never credentials.rat, credentials.client_secret, or metadata containing inbound jwks keys. The acceptance-criteria grep enforces this. The Lockspire.Redaction.for_telemetry/1 sieve (:client_secret, :token, :token_hash, :authorization) is the second line of defense. Wave 3 plan 26-07's cross-cutting sweep test is the closing proof.
T-26-IAT-LEAK Information Disclosure maybe_redeem_iat/1 plaintext path mitigate Plaintext IAT enters register/1 via request.iat and is passed once to InitialAccessToken.redeem/1 (which hashes immediately). The plaintext is NEVER stored in any local that ends up in metadata. After redeem/1 returns, only the %IatDomain{id, ...} struct (no plaintext field) is in scope.
T-26-SECRET-LEAK Information Disclosure client_secret plaintext path mitigate Clients.rotate_secret_hash/0 returns {hash, plaintext} — the orchestrator places the hash on the persisted row's client_secret_hash and returns the plaintext on the Success substruct (D-17). No telemetry/log/audit metadata literal carries :client_secret plaintext.
T-26-ATTRIBUTION-DRIFT Repudiation Audit row attribution on DCR write mitigate attrs.actor = %{type: :dcr, id: iat_id_or_"anonymous", display: source.ip} is set explicitly at persist_client/5 (D-23). The tightened actor_from_attrs/1 from plan 26-01 raises if :dcr is missing or blank — fail loud, never silently :operator. The dcr_audit_attribution_test.exs regression test at plan 26-07 is the final sweep.
T-26-IAT-ENUM Information Disclosure (enumeration) maybe_redeem_iat/1 collapse mitigate InitialAccessToken.redeem/1 already collapses 4 axes to :invalid_token (Wave 1 plan 26-03). maybe_redeem_iat/1 re-wraps as %Error{code: :invalid_token} — no axis discriminator leaks to the caller. The discriminator is preserved in :iat_redemption_failed telemetry only.
T-26-PKCE-DOWNGRADE Tampering pkce_required floor enforcement mitigate Two-line defense: (1) validate_pkce_floor/1 rejects explicit pkce_required: false with :pkce_floor_required_for_dcr (D-15); (2) persist_client/5 hardcodes pkce_required: true on the persisted row regardless of inbound. Test in Task 1's describe "register/1 — D-15 PKCE floor" covers both legs.
T-26-POLICY-BYPASS Elevation of Privilege Anonymous registration when policy demands IAT mitigate RESEARCH Q5 RESOLVED: require_iat_when_policy_demands/2 fires BEFORE maybe_redeem_iat/1. When server_policy.registration_policy == :initial_access_token AND iat == nil, returns %Error{code: :invalid_token, field: :iat, reason: :missing}. No call to InitialAccessToken.redeem/1 happens (no IAT-redemption-failure telemetry leaks the policy state). Test in Task 1's describe "register/1 — IAT precondition gate (D-13 step 1 — RESEARCH Q5 RESOLVED)".
T-26-DCR-PERSIST-LOSS Tampering / Repudiation Persistence path correctness mitigate The naive route — Lockspire.Clients.register_client/1normalize/1 — silently strips provenance, registration_access_token_hash, initial_access_token_id, client_id_issued_at, client_secret_expires_at, AND regenerates client_secret_hash server-side (so the plaintext returned to the caller would not match the persisted hash). Plan 26-05 persist_client/5 routes through Admin.Clients.create_dcr_client/1 (plan 26-01 task 4) which writes the fully-formed %Domain.Client{} verbatim through Repository.register_client/1. Acceptance grep grep -c 'Lockspire.Clients.register_client' lib/lockspire/protocol/registration.ex returns 0. The round-trip Policy.verify_client_secret(persisted.client_secret_hash, success.client_secret_plaintext) == true test in Task 1 is the closing automated proof.
T-26-EXTENSION-FIELD-LEAK Information Disclosure (storage of unvetted material) RFC 7591 extension fields including software_statement mitigate RESEARCH Q6 RESOLVED: silently ignore unknown fields. build_extension_metadata/1 calls Map.take(metadata, ["client_uri"]) — only the v1.5-supported extension lands in the :metadata JSONB column. software_statement (RFC 7591 §2.3 — a JWT possibly containing third-party PII) is dropped. Test in Task 1: "register/1 silently ignores software_statement".
</threat_model>
- `Lockspire.Protocol.Registration.register/1` returns `{:ok, %Success{}}` on the happy path with valid metadata + redeemable IAT. - Precondition gate fires: `server_policy.registration_policy == :initial_access_token` + `iat == nil` returns `%Error{code: :invalid_token, field: :iat, reason: :missing}` (RESEARCH Q5 RESOLVED). - Every D-14 / D-15 axis returns `{:error, %Error{}}` with the documented `field` and `reason` atoms. - Persisted `Domain.Client` always has `pkce_required: true` and `provenance: :self_registered`. - Persistence routes through `Admin.Clients.create_dcr_client/1` — DCR fields preserved verbatim. Round-trip `Policy.verify_client_secret(persisted.client_secret_hash, success.client_secret_plaintext)` returns `true` (warning 8). - Unknown RFC 7591 fields including `software_statement` are silently ignored — registration succeeds; persisted row carries no record (warning 7; RESEARCH Q6 RESOLVED). - Audit row's `actor_type` is `"dcr"` (never `"operator"`) for every DCR write. - Telemetry emits `:dcr_registration_succeeded` and `:dcr_registration_rejected` with no plaintext credentials in metadata. - `mix qa` is green.

<success_criteria>

  • All tests in test/lockspire/protocol/registration_test.exs pass.
  • DCR-02 (intake validation), DCR-03 (PKCE floor), DCR-04 (credential issuance + hash-at-rest, round-trip-proven) close at the protocol-module layer.
  • The orchestrator follows the pushed_authorization_request.ex structural template (D-01).
  • The validator pipeline is @doc false def (publicly callable but undocumented) so plan 26-06's RegistrationManagement.update/2 can reuse it (D-02).
  • The persistence path routes through Admin.Clients.create_dcr_client/1 — DCR fields preserved verbatim, never silently regenerated.
  • The precondition gate (RESEARCH Q5 RESOLVED) closes the policy-bypass axis before any other pipeline step.
  • Unknown RFC 7591 fields including software_statement are silently ignored. </success_criteria>
After completion, create `.planning/phases/26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co/26-05-SUMMARY.md`.