Researched: 2026-04-26
Domain: Elixir / Phoenix / Ecto — OAuth 2.0 Dynamic Client Registration (RFC 7591) intake + RFC 7592 management as Plug.Conn-free protocol modules
Confidence: HIGH
Phase 26 implements the four DCR protocol modules (Registration, RegistrationManagement, InitialAccessToken, RegistrationAccessToken) and their persistence/audit/telemetry plumbing on top of the Phase 25 storage skeleton. CONTEXT.md has already locked the major design decisions (D-01..D-28); this research verifies those decisions against the actual code, identifies three concrete code-correctness issues the planner must address, and documents the standard patterns and call sites the implementer will use.
Verification status:
- All hash-at-rest, telemetry, redaction, and resolver primitives the CONTEXT.md decisions reference exist at the cited file paths and line numbers (with one off-by-one —
Clients.rotate_secret_hash/0is at line 53, not 52). - The
mark_authorization_code_redeemed/2pattern atrepository.ex:534-557is the canonical "find by hash, lock FOR UPDATE, freshness check, update in same tx" precedent. Phase 26'sredeem_initial_access_token/1mirrors it exactly. - Three code-correctness issues require planner attention (see "Open Questions"):
Lockspire.Admin.Clients.disable_client_with_audit/4isdefp(private) —RegistrationManagement.delete/2cannot call it directly. Must call the publicAdmin.Clients.disable_client/2or exposedisable_client_with_audit/4.Lockspire.Clients.generate_client_id/0isdefp(private) — must be promoted to public, orRegistrationAccessToken/Registrationreproduce the 24-byte"ls_" <> Base.url_encode64(...)idiom inline.Repository.list_audit_events/1does not exist. D-24 references it for the regression test; the existing project pattern is to queryAuditEventRecorddirectly viaLockspire.TestRepo.all(from(...))(seetest/lockspire/admin/clients_test.exs:232-240).
Primary recommendation: Plan three "carve out a public helper" tasks at the start of Phase 26 (one each for the items above) before authoring the four protocol modules. Then build the protocol modules in dependency order: RegistrationAccessToken → InitialAccessToken → Registration → RegistrationManagement. Audit attribution tightening (D-22) and the telemetry redaction sweep test (D-27) come last because they exercise every other module.
<phase_requirements>
| ID | Description | Research Support |
|---|---|---|
| DCR-02 | Intake validation rejects jwks_uri, jwks ⊕ jwks_uri, enforces RFC 7591 §2 grant/response coherence, routes redirect URIs through Lockspire.Clients.validate_redirect_uris/1 |
RFC 7591 §2.1 (grant/response coherence table, jwks vs jwks_uri mutual exclusion); CONTEXT.md D-14; lib/lockspire/clients.ex:32 (validator already exists) |
| DCR-03 | Self-registered clients are PKCE-required by floor; intake refuses metadata that lowers PKCE; row stored with pkce_required: true |
CONTEXT.md D-15; Lockspire.Clients.normalize/1:108 already forces pkce_required: true; lib/lockspire/clients.ex:271-281 rejects explicit pkce_required: false |
| DCR-04 | Successful registration issues client_id, fresh client_secret, fresh RAT; both secrets hashed at rest; plaintext returned exactly once |
CONTEXT.md D-04, D-06, D-16, D-17; Lockspire.Security.Policy.hash_client_secret/1 at policy.ex:91-96; Lockspire.Security.Policy.hash_token/1 at policy.ex:84-89; Lockspire.Clients.rotate_secret_hash/0 at clients.ex:53-56 |
| DCR-11 | Lockspire.Protocol.InitialAccessToken.redeem/1 is atomic; expired/revoked/used IATs return {:error, :invalid_token}; success marks IAT used in same tx |
CONTEXT.md D-08, D-09, D-10, D-11; canonical pattern at repository.ex:534-557; unique_index([:token_hash]) exists per Phase 25 migration |
| DCR-22 | actor_from_attrs/1 attributes DCR codepaths as :dcr/:self_registered_client, never :operator; regression test fails on :operator-flavored DCR write |
CONTEXT.md D-22, D-23, D-24; current silent fallbacks at admin/clients.ex:407, 414, 419; audit-row test pattern at test/lockspire/admin/clients_test.exs:74-82, 232-240 |
| DCR-23 | RAT/IAT/client_secret plaintext never appear in [:lockspire, :dcr, ...] / [:lockspire, :iat, ...] event payload, audit row, or log line |
CONTEXT.md D-25, D-26, D-27, D-28; Observability.emit/3 at observability.ex:15-29; Redaction.for_telemetry/1 drop list at redaction.ex:8-53; existing telemetry test pattern at test/lockspire/admin/clients_test.exs:207-230 |
| </phase_requirements> |
<user_constraints>
CONTEXT.md was gathered in assumptions mode on 2026-04-26 with all five assumptions confirmed via "Yes, proceed". Decisions D-01..D-28 are locked and constrain this research.
Module Layout & Naming
- D-01: Phase 26 ships four sibling protocol modules, each thin and focused:
Lockspire.Protocol.Registrationatlib/lockspire/protocol/registration.ex;Lockspire.Protocol.RegistrationManagementatlib/lockspire/protocol/registration_management.ex;Lockspire.Protocol.InitialAccessTokenatlib/lockspire/protocol/initial_access_token.ex(distinct namespace fromLockspire.Domain.InitialAccessToken);Lockspire.Protocol.RegistrationAccessTokenatlib/lockspire/protocol/registration_access_token.ex. - D-02: Validator logic for RFC 7591 metadata lives inside
Registrationas private functions, not a separateLockspire.Protocol.RegistrationIntakeValidatormodule. The same private validator pipeline is called fromRegistrationManagement.update/2. Extract to a shared private helper module only if a third caller emerges. - D-03: Each protocol module is
Plug.Conn-free (noimport Plug.Conn, no conn parameters). Inputs are plain maps / Elixir terms; outputs are{:ok, %Success{}}/{:error, %Error{}}tuples or domain structs.
Hash-at-Rest Primitive Reconciliation
- D-04:
client_secretfor self-registered clients usesLockspire.Security.Policy.hash_client_secret/1(salted SHA-256). Reuses the existingLockspire.Clients.rotate_secret_hash/0helper. - D-05: IAT token hash uses
Lockspire.Security.Policy.hash_token/1(plain SHA-256 lowercase hex). Required becauselockspire_initial_access_tokens.token_hashcarriesunique_index([:token_hash]). - D-06: RAT (
registration_access_token) hash useshash_token/1(same primitive as IAT). Same rationale: deterministic hash required for hash-equality lookup at RFC 7592 management calls. - D-07: Two primitives, two purposes — locked.
Atomic IAT Redemption
- D-08:
Lockspire.Protocol.InitialAccessToken.redeem/1accepts a plaintext IAT string (caller does not hash). Function hashes viaSecurity.Policy.hash_token/1internally, then delegates to a newRepository.redeem_initial_access_token/1. - D-09: Repository implementation uses
Repository.transact/1+Ecto.Query.lock("FOR UPDATE")plus freshness checks — the canonical pattern atrepository.ex:534-555(mark_authorization_code_redeemed/2). NoEcto.Multi. - D-10: Freshness checks performed inside the transaction (in order — first failure short-circuits): (1) row exists by
token_hashelse:not_found; (2)revoked_at IS NULLelse:revoked; (3)expires_at > now()else:expired; (4)single_use = false OR used_at IS NULLelse:already_used. Successful redemption setsused_at = now()in the same transaction. - D-11: Public return shape collapses all rejection axes to
{:error, :invalid_token}per Phase 26 SC 3. Discriminating reason emitted to telemetry only (defense against IAT-existence enumeration). On success:{:ok, %Lockspire.Domain.InitialAccessToken{}}withused_atpopulated.
Registration Pipeline (Intake & Issuance)
- D-12:
Registration.register/1accepts a single map argument:%{metadata: <inbound_rfc7591_map>, iat: <plaintext_iat | nil>, server_policy: %ServerPolicy{}, source: %{ip: ..., user_agent: ...}}. Returns{:ok, %Success{client: %Domain.Client{}, client_secret_plaintext: bin, registration_access_token_plaintext: bin}}or{:error, %Error{code: ..., field: atom() | nil, reason: atom() | nil, allowed: list() | nil}}. - D-13: Registration pipeline order: (1) IAT redemption if
iatnon-nil → producesiat_recordwithpolicy_overrides; (2) DcrPolicy resolution; (3) slice-specific intake validation (Phase-26-owned validator, applied AFTER policy resolution narrows the field set); (4) credential generation; (5) persistence in a singleRepository.transact/1; (6) audit + telemetry emission outside the transaction (post-commit). - D-14: Intake validator (private functions inside
Registration) enforces, per DCR-02 verbatim:jwks_urirejection ({:error, %Error{code: :invalid_client_metadata, field: :jwks_uri, reason: :unsupported_in_slice}});jwks ⊕ jwks_urimutual exclusion; RFC 7591 §2grant_types/response_typescoherence (small lookup table);redirect_urisrouted throughLockspire.Clients.validate_redirect_uris/1. - D-15: PKCE floor (DCR-03) — validator refuses any inbound metadata that would produce a
Domain.Clientwithpkce_required: false. Public clients (token_endpoint_auth_method = "none") are accepted only when PKCE is also enforced; row constructed withpkce_required: trueregardless. Explicitpkce_required: falsereturns{:error, %Error{code: :invalid_client_metadata, field: :pkce_required, reason: :pkce_floor_required_for_dcr}}. PKCE-disabling registrations are not silently coerced; they are rejected with a clear reason.
Credential Generation
- D-16: Credential generation lives in
Lockspire.Protocol.RegistrationAccessTokenfor the RAT and inRegistration(private helper) for theclient_secret. Both use:crypto.strong_rand_bytes/1followed byBase.url_encode64/2withpadding: false. Lengths:client_secret32 bytes pre-encode;registration_access_token32 bytes pre-encode.client_idis generated via the existingLockspire.Clients.generate_client_id/0helper. - D-17: Plaintext
client_secretand plaintextregistration_access_tokenare returned to the caller exactly once on theSuccesssubstruct. NEVER persisted in plaintext. - D-18:
client_secret_expires_atcomputed at issuance fromResolved.dcr_default_client_secret_lifetime_seconds.client_id_issued_atset toDateTime.utc_now/0at insert time. Both persisted on theDomain.Clientrow.
RFC 7592 Management Core
- D-19:
RegistrationManagement.read/2,update/2,delete/2each accept(client_id_from_url, %Domain.Client{})where the client is the row matched byRepository.get_client_by_registration_access_token_hash/1(a new repo function). Theclient_id_from_urlandclient.client_idare compared inside the function — mismatches return{:error, :invalid_token}(not a separate "wrong client" error) to prevent enumeration. - D-20:
update/2is full-replace via the same private validator pipeline asregister/1(D-13 steps 2–4, skipping IAT redemption). On success: rotatesregistration_access_token, persists new hash, returns new plaintext exactly once. Prior RAT hash overwritten in the same transaction — invalidation is implicit. - D-21:
delete/2callsLockspire.Admin.Clients.disable_client_with_audit/4withdisabled_by: "dcr_self_delete"andactor: %{type: :self_registered_client, id: client.client_id}. Returns:okon success.
DCR Audit Actor Shape (DCR-22)
- D-22:
Lockspire.Admin.Clients.actor_from_attrs/1is tightened in place atlib/lockspire/admin/clients.ex:397-419. Three silent:operatorfallback branches (lines 407, 414, 419) are changed: when no actor type can be derived fromattrs, the function raisesArgumentError. Existing operator paths must explicitly setattrs[:actor][:type]— those callers are audited and updated as part of this phase. NOT a separateactor_from_dcr_attrs/1. - D-23: Actor-type assignment for DCR codepaths:
Registration.register/1constructsattrs[:actor] = %{type: :dcr, id: <iat_id_or_"anonymous">, display: <source.ip>}.RegistrationManagement.{read,update,delete}/2constructsattrs[:actor] = %{type: :self_registered_client, id: client.client_id}. - D-24: Regression test (DCR-22 failing condition) lives at
test/lockspire/protocol/dcr_audit_attribution_test.exs. Asserts via direct queries that NO row matchesaction LIKE 'dcr_%' AND actor_type = 'operator'. Audit-row assertion is deterministic; telemetry assertion would be flaky in CI.
Telemetry Event Shape & Redaction (DCR-23)
- D-25: Telemetry emits via the existing
Lockspire.Observability.emit/3atlib/lockspire/observability.ex:15-29— NOT raw:telemetry.execute/3. Reuses the project-wide audit-mirror behavior and theLockspire.Redaction.for_telemetry/1sieve. - D-26: Event names are atom singletons in the
:dcr_*and:iat_*family. No extension ofObservability.emit/3to multi-segment paths. Concrete event names: DCR family —:dcr_registration_succeeded,:dcr_registration_rejected,:dcr_management_read,:dcr_management_updated,:dcr_management_deleted,:dcr_management_unauthorized,:dcr_registration_access_token_rotated. IAT family —:iat_redeemed,:iat_redemption_failed(failure_reasonmeasurement carries the discriminating axis from D-11). - D-27: Single-sweep redaction test at
test/lockspire/protocol/dcr_telemetry_redaction_test.exs. Wires a:telemetryhandler that captures every emitted[:lockspire | _]event during an exercise pass covering happy + every failure path. The assertion is a single sweep:refute Enum.any?(captured_events, fn ev -> String.contains?(inspect(ev), plaintext_secret) or String.contains?(inspect(ev), plaintext_rat) or String.contains?(inspect(ev), plaintext_iat) end). - D-28: Audit row redaction enforced at the
Audit.Event.normalize/1boundary — sameLockspire.Redactionprimitives flow through. The redaction test at D-27 reads back thelockspire_audit_eventsrows written during the sweep and applies the sameString.contains?assertion against the persistedpayloadandmetadataJSONB columns.
- File-internal layout of
registration.ex,registration_management.ex,initial_access_token.ex,registration_access_token.ex(private helper organization, doctest placement, internal struct field order) followspushed_authorization_request.exergonomics. - Test fixture additions (e.g.,
test/support/fixtures/dcr_fixtures.exfor inbound metadata maps, RAT plaintext) follow existing fixture naming. - Exact Postgres advisory-lock behavior of
lock("FOR UPDATE")onlockspire_initial_access_tokensis the database default — no custom lock mode. - Whether
Registration.register/1emits a single:dcr_registration_rejectedevent with areasonmeasurement vs separate event names per failure mode — single event withreasonis the default unless test ergonomics demand otherwise. - Exact
Errorstruct field set beyond{code, field, reason, allowed}— additional fields may be added without further sign-off if downstream Phase 27 controllers need them.
- Multi-segment telemetry paths via
Observability.emit/3extension — out of scope for Phase 26. - Separate
Lockspire.Protocol.RegistrationIntakeValidatormodule — only justified if a third caller emerges. actor_from_dcr_attrs/1separate function — rejected (D-22); tighten in place.Ecto.Multi-based IAT redemption — rejected (D-09).- Differentiated public IAT error returns (
:expired | :revoked | :already_used) — rejected (D-11); collapses to:invalid_token. client_secretrotation onPUT /register/:client_id— DCR-FUT-02; v1.6+.- Per-IAT
policy_overridesadmin UI — DCR-FUT-03; UI in v1.6+. jwks_urioutbound fetch with SSRF protections — DCR-FUT-01; rejected at intake in this phase.- Built-in rate limiting on
POST /register— DCR-FUT-04; host-side Plug seam documented in Phase 29. - Per-event explicit redaction assertions — rejected (D-27); single-sweep
String.contains?test.
</user_constraints>
./CLAUDE.md does not exist in the repo (verified by Read returning ENOENT). No CLAUDE.md directives apply. The project follows the conventions established by mix.exs (Elixir ~> 1.18, ecto_sql ~> 3.13.5, telemetry ~> 1.3) and the qa alias (format --check-formatted, compile --warnings-as-errors, credo --strict, dialyzer).
Phase 26 is intentionally single-tier: every capability lives in the lib/lockspire/protocol/ and lib/lockspire/storage/ecto/ boundaries. There is no HTTP, no LiveView, no client-side code. The "tier" axis collapses; the meaningful axis is module ownership.
| Capability | Primary Module | Secondary Module | Rationale |
|---|---|---|---|
| RFC 7591 intake orchestration | Lockspire.Protocol.Registration |
Lockspire.Protocol.DcrPolicy (resolver), Lockspire.Clients (redirect URI validator) |
D-01, D-02; mirrors PushedAuthorizationRequest.push/1 shape |
| RFC 7592 management | Lockspire.Protocol.RegistrationManagement |
Lockspire.Admin.Clients.disable_client/2 (delete path) |
D-19, D-20, D-21 |
| IAT lifecycle (redeem) | Lockspire.Protocol.InitialAccessToken |
Lockspire.Storage.Ecto.Repository.redeem_initial_access_token/1 (NEW) |
D-08, D-09, D-10, D-11 |
| RAT primitives (generate / hash / verify) | Lockspire.Protocol.RegistrationAccessToken |
Lockspire.Security.Policy.hash_token/1 |
D-06, D-16 |
Hash-at-rest (client_secret) |
Lockspire.Security.Policy.hash_client_secret/1 |
Lockspire.Clients.rotate_secret_hash/0 (already wraps) |
D-04 |
| Hash-at-rest (RAT, IAT) | Lockspire.Security.Policy.hash_token/1 |
— | D-05, D-06 |
| DcrPolicy resolution | Lockspire.Protocol.DcrPolicy.resolve/3 (Phase 25) |
— | already shipped |
| Audit attribution | Lockspire.Admin.Clients.actor_from_attrs/1 |
Lockspire.Audit.Event.normalize/1 |
D-22, D-23, D-28 |
| Telemetry emission | Lockspire.Observability.emit/3 |
Lockspire.Redaction.for_telemetry/1 |
D-25, D-26 |
| Persistence (transactional) | Lockspire.Storage.Ecto.Repository.transact/1 |
Repository.transact_with_audit/2 |
D-09, D-13 step 5 |
| Self-registered client lookup by RAT | Lockspire.Storage.Ecto.Repository.get_client_by_registration_access_token_hash/1 (NEW) |
— | D-19 |
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Elixir | 1.18+ (1.19.5 in tooling) | Language | mix.exs:9 declares elixir: "~> 1.18" [VERIFIED: mix.exs] |
| Erlang/OTP | 28 | Runtime | Verified via elixir -v [VERIFIED: shell output] |
| ecto_sql | ~> 3.13.5 | Postgres + transaction + lock("FOR UPDATE") |
mix.exs:39 [VERIFIED] |
| postgrex | >= 0.0.0 | PG driver | mix.exs:40 [VERIFIED] |
| telemetry | ~> 1.3 | Event emission | mix.exs:45 [VERIFIED]; used by Lockspire.Observability.emit/3 |
| jason | ~> 1.4 | JSON | mix.exs:43 [VERIFIED]; relevant for metadata JSONB and Phase 27 |
| plug_crypto | (transitive via phoenix) | Plug.Crypto.secure_compare/2 |
Used by Security.Policy.verify_client_secret/2 [VERIFIED: policy.ex:124] |
| Module | Purpose | When to Use |
|---|---|---|
Lockspire.Security.Policy |
Hash primitives (hash_token/1, hash_client_secret/1, verify_client_secret/2) |
Every credential generation and verification path |
Lockspire.Clients |
validate_redirect_uris/1, rotate_secret_hash/0, private generate_client_id/0 |
Intake validator (redirect URIs); credential gen |
Lockspire.Admin.Clients |
actor_from_attrs/1 (tightened by D-22), disable_client/2, client_audit_event/5 |
Audit-emission boundary; delete path |
Lockspire.Protocol.DcrPolicy |
resolve/3 intersection-only resolver |
Pipeline step 2 (D-13) |
Lockspire.Storage.Ecto.Repository |
transact/1, transact_with_audit/2, mark_authorization_code_redeemed/2 (precedent), register_client/1, update_client/2 |
Persistence + audit-event linking |
Lockspire.Observability |
emit/3 two-segment telemetry helper |
All Phase 26 telemetry |
Lockspire.Redaction |
for_telemetry/1, for_audit/1 sieves |
Drops :client_secret, :token, :token_hash, :authorization from event payloads |
Lockspire.Audit.Event |
normalize/1 audit-row construction |
Enforces field-required and applies Redaction.for_audit/1 to metadata |
| Instead of | Could Use | Tradeoff |
|---|---|---|
Repo.transact/1 + lock("FOR UPDATE") |
Ecto.Multi |
Locked-out by D-09. Multi adds composition overhead; the IAT redemption is a single read+update with no other writes in the transaction, so the simpler pattern wins and matches the existing mark_authorization_code_redeemed/2 precedent. |
Repo.transact/1 + lock("FOR UPDATE") |
UPDATE ... WHERE used_at IS NULL RETURNING * (compare-and-swap) |
Equally atomic, Postgres-only. The project consistently uses lock("FOR UPDATE") for single-use lifecycle tokens (mark_authorization_code_redeemed/2, consume_pushed_authorization_request_record, revoke_lifecycle_token); deviating here would fragment the pattern. |
actor_from_attrs/1 raise on missing type |
Return {:error, :unknown_actor} and force callers to handle |
Locked-out by D-22. Raise is louder, but it's the only way to guarantee that no DCR codepath can silently emit :operator; a return-tuple still requires every caller's discipline, which is what got us into this situation. |
Atom-singleton :dcr_registration_succeeded |
List [:dcr, :registration_succeeded] |
Locked-out by D-26. Switching Observability.emit/3 to accept a list would fork the audit-mirror code path and ripple through every existing caller. Atom prefix carries the namespace. |
Installation: No new deps. All needed primitives exist.
Version verification:
mix deps | grep -E "ecto_sql|telemetry|postgrex|jason"Verified at mix.exs:37-50 against the in-repo lock — no new dependencies required for Phase 26.
┌──────────────────────────────────────┐
│ Phase 27 HTTP layer (out of scope) │
│ POST /register, GET/PUT/DELETE │
│ /register/:client_id │
└───────────────┬──────────────────────┘
│ inbound: %{metadata, iat,
│ server_policy, source}
│ outbound: {:ok, %Success{}} | {:error, %Error{}}
▼
┌────────────────────────────────────────────┐
│ Lockspire.Protocol.Registration.register/1 │
│ (Phase 26 — orchestrator, Plug.Conn-free) │
└────────────────────────────────────────────┘
│
┌─────────────────────────┼───────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────────────┐ ┌──────────────────────┐ ┌──────────────────────────┐
│ InitialAccessToken. │ │ DcrPolicy.resolve/3 │ │ Private intake validator │
│ redeem/1 (Phase 26) │ │ (Phase 25) │ │ (Phase 26 — D-14, D-15) │
│ ↓ │ │ intersection-only │ │ - jwks_uri reject │
│ Repository.redeem_iat/1 │ │ ServerPolicy ∩ │ │ - jwks ⊕ jwks_uri │
│ ↓ │ │ IAT overrides ∩ │ │ - grant/response │
│ transact + FOR UPDATE │ │ inbound metadata │ │ coherence (RFC 7591) │
│ ↓ │ └──────────────────────┘ │ - redirect_uris via │
│ {:ok, %IAT{used_at:...}} │ │ Clients.validate_* │
│ | {:error, :invalid_token}│ │ - PKCE floor │
└──────────────────────────┘ └──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Credential generation (D-16): │
│ - client_id ← Clients.generate_client_id/0 │
│ - client_secret ← Clients.rotate_secret_hash/0 │
│ - RAT ← RegistrationAccessToken.generate │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Repository.transact/1: persist %Domain.Client{} │
│ - client_secret_hash (salted) │
│ - registration_access_token_hash (unsalted) │
│ - provenance: :self_registered │
│ - initial_access_token_id │
│ - client_id_issued_at, client_secret_expires_at │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Post-commit (D-13 step 6): │
│ - Observability.emit(:dcr_registration_succeeded, │
│ measurements, redacted_metadata) │
│ - actor: {type: :dcr, id: iat_id_or_"anonymous"} │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ RegistrationManagement.read/2, update/2, delete/2 │
│ (Phase 26 — RFC 7592) │
│ arg: (client_id_from_url, %Domain.Client{}) │
│ - read: no DB write, emits :dcr_management_read │
│ - update: same private validator → rotate RAT │
│ - delete: Admin.Clients.disable_client_with_audit │
│ (actor: :self_registered_client) │
└─────────────────────────────────────────────────────┘
lib/lockspire/protocol/
├── registration.ex # NEW — D-01
├── registration_management.ex # NEW — D-01
├── initial_access_token.ex # NEW — D-01 (distinct from Domain.InitialAccessToken)
└── registration_access_token.ex # NEW — D-01
lib/lockspire/storage/ecto/repository.ex # EXTEND — add redeem_initial_access_token/1,
# get_client_by_registration_access_token_hash/1
lib/lockspire/admin/clients.ex # EDIT — D-22: tighten actor_from_attrs/1
# + audit and update existing :operator callers
lib/lockspire/clients.ex # MAYBE EDIT — see Open Question 2 (generate_client_id/0
# is private; needs promotion or duplication)
test/support/fixtures/
└── dcr_fixtures.ex # NEW — inbound RFC 7591 metadata fixtures + RAT plaintext
test/lockspire/protocol/
├── registration_test.exs # NEW — happy path + D-14/D-15 sad paths
├── registration_management_test.exs # NEW — read/update/delete + RAT rotation invalidation
├── initial_access_token_test.exs # NEW — D-10 freshness ladder + collapse to :invalid_token
├── registration_access_token_test.exs # NEW — generate/hash/verify primitives
├── dcr_audit_attribution_test.exs # NEW — D-24 regression test
└── dcr_telemetry_redaction_test.exs # NEW — D-27 single-sweep redaction test
What: A protocol module exposes a single public entry function (push/1, register/1, etc.) that takes plain Elixir terms and returns {:ok, %Success{}} / {:error, %Error{}}. The module owns its Success and Error substructs.
When to use: Every Phase 26 protocol module. Locked by D-03.
Example (verbatim precedent at lib/lockspire/protocol/pushed_authorization_request.ex:13-39, 43-64):
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) do
with {:ok, iat_record} <- maybe_redeem_iat(request),
{:ok, %Resolved{} = resolved} <- DcrPolicy.resolve(request.server_policy, iat_record_overrides(iat_record), request.metadata),
:ok <- validate_intake(request.metadata),
{:ok, credentials} <- generate_credentials(),
{:ok, %Client{} = client} <- persist_client(request, resolved, iat_record, credentials) do
emit_dcr_registration_succeeded(client, iat_record, request.source)
{:ok, %Success{client: client, client_secret_plaintext: credentials.client_secret, registration_access_token_plaintext: credentials.rat}}
else
{:error, %Error{} = error} -> emit_dcr_registration_rejected(error, request.source); {:error, error}
end
end
# ... private validate_intake/1, persist_client/4, emit_*/n, etc.
endWhat: Atomic single-use token redemption. The lock prevents two concurrent processes from observing the same used_at IS NULL row.
When to use: Repository.redeem_initial_access_token/1. Locked by D-09.
Example (verbatim from lib/lockspire/storage/ecto/repository.ex:534-557):
@impl TokenStore
def mark_authorization_code_redeemed(token_hash, redeemed_at)
when is_binary(token_hash) and is_struct(redeemed_at, DateTime) do
transact(fn ->
TokenRecord
|> where([token], token.token_hash == ^token_hash)
|> where([token], token.token_type == :authorization_code)
|> lock("FOR UPDATE")
|> repo_one(sensitive: true)
|> case do
nil ->
repo().rollback(:not_found)
%TokenRecord{redeemed_at: %DateTime{}} ->
repo().rollback(:already_redeemed)
%TokenRecord{} = record ->
record
|> Ecto.Changeset.change(redeemed_at: redeemed_at, updated_at: DateTime.utc_now())
|> repo_update(sensitive: true)
|> map_one(&TokenRecord.to_domain/1)
|> unwrap_or_rollback()
end
end)
endPhase 26 application (D-08, D-10):
def redeem_initial_access_token(token_hash, redeemed_at)
when is_binary(token_hash) and is_struct(redeemed_at, DateTime) do
transact(fn ->
InitialAccessTokenRecord
|> where([iat], iat.token_hash == ^token_hash)
|> lock("FOR UPDATE")
|> repo_one(sensitive: true)
|> case do
nil -> repo().rollback(:not_found)
%InitialAccessTokenRecord{revoked_at: %DateTime{}} -> repo().rollback(:revoked)
%InitialAccessTokenRecord{expires_at: expires_at} when expires_at <= redeemed_at -> repo().rollback(:expired)
%InitialAccessTokenRecord{single_use: true, used_at: %DateTime{}} -> repo().rollback(:already_used)
%InitialAccessTokenRecord{} = record ->
record
|> Ecto.Changeset.change(used_at: redeemed_at, updated_at: DateTime.utc_now())
|> repo_update(sensitive: true)
|> map_one(&InitialAccessTokenRecord.to_domain/1)
|> unwrap_or_rollback()
end
end)
endThe public Lockspire.Protocol.InitialAccessToken.redeem/1 then collapses the discriminator (D-11):
def redeem(plaintext) when is_binary(plaintext) do
hash = Lockspire.Security.Policy.hash_token(plaintext)
case Repository.redeem_initial_access_token(hash, DateTime.utc_now()) do
{:ok, %Lockspire.Domain.InitialAccessToken{} = iat} ->
Observability.emit(:iat_redeemed, %{count: 1}, %{iat_id: iat.id})
{:ok, iat}
{:error, reason} when reason in [:not_found, :revoked, :expired, :already_used] ->
Observability.emit(:iat_redemption_failed, %{count: 1, failure_reason: reason}, %{})
{:error, :invalid_token}
{:error, other} ->
Observability.emit(:iat_redemption_failed, %{count: 1, failure_reason: :unexpected}, %{detail: inspect(other)})
{:error, :invalid_token}
end
endWhat: Telemetry emission that fans out to both [:lockspire, :audit, event] (durable audit handler subscribes) and [:lockspire, event] (live telemetry handler subscribes), with metadata passed through Redaction.for_telemetry/1.
When to use: Every Phase 26 telemetry call. Locked by D-25, D-26.
Example (verbatim from lib/lockspire/observability.ex:15-29):
@spec emit(event_name(), measurements(), metadata()) :: :ok
def emit(event_name, measurements \\ %{}, metadata \\ %{}) when is_atom(event_name) do
redacted_metadata = redact(metadata)
normalized_measurements = Map.put_new(measurements, :count, 1)
:telemetry.execute([:lockspire, :audit] ++ [event_name], normalized_measurements, redacted_metadata)
:telemetry.execute([:lockspire] ++ [event_name], normalized_measurements, redacted_metadata)
:ok
endPhase 26 caller:
Observability.emit(:dcr_registration_succeeded, %{}, %{
actor_type: :dcr,
actor_id: iat_id_or_anonymous,
client_id: client.client_id,
iat_id: iat_record && iat_record.id,
source_ip: source.ip,
reason_code: :dcr_registration_succeeded
})- Returning
:expired | :revoked | :already_usedfromredeem/1public API — leaks IAT existence (Pitfall 12 in.planning/research/PITFALLS.md). Always collapse to:invalid_token(D-11). The discriminator may go to telemetry (failure_reasonmeasurement) but never out the public boundary. - Calling
:telemetry.execute/3directly from Phase 26 modules — bypassesRedaction.for_telemetry/1and the audit-mirror path. Always go throughObservability.emit/3(D-25). The one existing exception (Lockspire.Admin.Tokens.emit/4attokens.ex:276-292) does so to restore unredacted IDs for telemetry; Phase 26 has no such requirement. - Putting plaintext under unredacted keys —
Redaction.for_telemetry/1is a key-allowlist sieve. Keys like:client_secret,:token,:token_hash,:authorizationare dropped (redaction.ex:8-53); other keys pass through. The sieve does NOT include:registration_access_token,:initial_access_token,:rat,:iat— Phase 26 must either (a) only ever emit these as*_id/*_hashfields (not plaintext), which D-17/D-25 already require, or (b) extendredaction.ex:8-53to add these key names. Recommendation: option (a) — never put plaintext into telemetry metadata in the first place; the D-27 redaction test will catch any accidental leak. - Hand-rolling SHA-256 hashing — use
Lockspire.Security.Policy.hash_token/1andhash_client_secret/1. Fixture drift caught Phase 25 (Pitfall: shared pattern §"Hash-at-rest viaLockspire.Security.Policy.hash_token/1" in25-PATTERNS.md). - A separate
Lockspire.Protocol.RegistrationIntakeValidatormodule — locked-out by D-02. Validator is private functions insideRegistration. Same private validator is reused byRegistrationManagement.update/2; if a third caller emerges, then refactor to a shared module. - Persisting plaintext credentials anywhere — D-17. The
Successsubstruct's*_plaintextfields are short-lived and intended only for the Phase 27 controller's JSON view. They never enter the database, the audit row, or the telemetry payload.
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
Generate a client_id |
New random-bytes-+-prefix function | Lockspire.Clients.generate_client_id/0 (clients.ex:384-386) — but see Open Question 2: it is currently defp |
Operator-created clients use this exact format; DCR-created clients must match for audit/admin parity |
Hash client_secret |
New :crypto.hash call |
Lockspire.Security.Policy.hash_client_secret/1 (policy.ex:91-96) — already wrapped by Lockspire.Clients.rotate_secret_hash/0 (clients.ex:53-56) |
Salt format "sha256:salt:hash" must match verify_client_secret/2 (policy.ex:98-114) for subsequent client auth |
| Hash IAT or RAT | New :crypto.hash call |
Lockspire.Security.Policy.hash_token/1 (policy.ex:84-89) |
Deterministic SHA-256 lowercase hex required for hash-equality lookup against unique_index([:token_hash]) and registration_access_token_hash |
| Compare hashes | New == (vulnerable to timing) |
Plug.Crypto.secure_compare/2 (already used by Security.Policy.verify_client_secret/2) |
Timing-safe comparison is non-negotiable for credential verification |
| Validate redirect URIs | New URI parsing + scheme/host/fragment checks | Lockspire.Clients.validate_redirect_uris/1 (clients.ex:32-39) |
DCR-02 requires exact-match parity with operator-created clients — locked by D-14 |
| Atomic single-use redemption | New Ecto.Multi or SELECT ... FOR UPDATE NOWAIT |
Repository.transact/1 + lock("FOR UPDATE") mirroring mark_authorization_code_redeemed/2 (repository.ex:534-557) |
Single-pattern consistency for every single-use lifecycle token in the codebase |
| Soft-disable a client | New SQL UPDATE | Lockspire.Admin.Clients.disable_client/2 (public, admin/clients.ex:127-148) which calls private disable_client_with_audit/4 |
Must emit the :client_disabled audit event with the right actor; reusing the public function preserves all surrounding invariants |
| Append a DCR audit event | New AuditEventRecord insert |
Repository.append_audit_event/1 (repository.ex:280-294) or Repository.transact_with_audit/2 (repository.ex:296-312) |
Audit.Event.normalize/1 enforces required fields and applies Redaction.for_audit/1 to metadata; bypassing it leaks plaintext to durable rows |
| Drop secrets from telemetry | New filter in caller | Lockspire.Observability.emit/3 (which calls Redaction.for_telemetry/1) |
Project-wide single-source-of-truth for redaction. Adding a parallel filter creates drift. |
Key insight: Every primitive Phase 26 needs already exists in the repo. The risk surface is misuse (calling a private function, building a parallel filter, returning a discriminator that should be collapsed) — not building too much. Plan tasks to compose existing primitives, not to author new ones.
Pitfall 1: Calling Lockspire.Admin.Clients.disable_client_with_audit/4 from RegistrationManagement.delete/2
What goes wrong: D-21 says RegistrationManagement.delete/2 calls Lockspire.Admin.Clients.disable_client_with_audit/4. But that function is defp (private) at admin/clients.ex:348. The compile will fail with (UndefinedFunctionError) function Lockspire.Admin.Clients.disable_client_with_audit/4 is undefined or private.
Why it happens: CONTEXT.md was authored from research notes; the assumption that the function was public was not verified against the actual defp at admin/clients.ex:348.
How to avoid: Pick one of:
- Promote
disable_client_with_audit/4to public (def) inadmin/clients.ex:348— minimal change, surfaces a tested transactional helper for protocol callers. - Have
RegistrationManagement.delete/2call the publicAdmin.Clients.disable_client/2(admin/clients.ex:127-148) which already wrapsdisable_client_with_audit/4. The public function takes(client_id, attrs)whereattrscarries:disabled_by,:disabled_at, and:actor. This requires no changes toadmin/clients.exbeyond D-22. Recommendation: Option 2 —disable_client/2already does the right thing, including audit + telemetry; passingactor: %{type: :self_registered_client, id: client.client_id}flows correctly throughactor_from_attrs/1(post-D-22 tightening). Warning signs: Compile error at first build ofregistration_management.ex.
What goes wrong: D-16 says client_id is generated via Lockspire.Clients.generate_client_id/0. But that function is defp at clients.ex:384-386. Compile failure.
Why it happens: Same as Pitfall 1.
How to avoid: Promote generate_client_id/0 to public in clients.ex:384. Currently it's private because Lockspire.Clients.register_client/1 is the only existing caller; Phase 26 makes Registration a second caller, which justifies promotion.
Recommendation: Promote to public with explicit @spec. Alternative — duplicate the 2-line idiom ("ls_" <> Base.url_encode64(:crypto.strong_rand_bytes(24), padding: false)) inline in Registration — but this fragments the format and risks drift if the prefix or length ever changes.
Warning signs: Compile error at first build of registration.ex.
What goes wrong: D-24 says the regression test "queries Repository.list_audit_events/1". That function does not exist — grep -n "list_audit_events" lib/lockspire/storage/ecto/repository.ex returns no matches; the only AuditEventRecord references are at repository.ex:19, 282-285.
Why it happens: The function name was extrapolated from convention rather than verified.
How to avoid: Use the existing project pattern at test/lockspire/admin/clients_test.exs:232-240:
defp dcr_audit_rows do
import Ecto.Query
alias Lockspire.Storage.Ecto.AuditEventRecord
Lockspire.TestRepo.all(
from(audit in AuditEventRecord,
where: like(audit.action, "dcr_%"),
order_by: [desc: audit.id])
)
endThe regression assertion then becomes:
assert Enum.all?(dcr_audit_rows(), fn row -> row.actor_type != "operator" end)Note: actor_type is persisted as a string (Audit.Event.normalize/1 converts atom to string via Atom.to_string/1 at audit/event.ex:94). The assertion compares against "operator" not :operator.
Warning signs: Function-undefined error at test load.
What goes wrong: DCR-23 requires events under [:lockspire, :dcr, ...] and [:lockspire, :iat, ...]. Observability.emit/3 produces 2-segment paths: [:lockspire, :dcr_registration_succeeded]. The atom prefix carries the namespace, but a strict reader of the spec might object that the path is not literally 3 segments.
Why it happens: D-26 acknowledges this and locks the atom-singleton choice; CONTEXT.md ## Specifics §1 documents the user's confirmation.
How to avoid: Document in code (module-level moduledoc on Registration) that the project-convention 2-segment shape is the chosen interpretation of the namespace requirement, with the deferred-ideas escape hatch noted (extend Observability.emit/3 to multi-segment paths if a future audit requires).
Warning signs: Audit feedback or third-party telemetry consumer reports that they cannot subscribe to [:lockspire, :dcr | _] as a wildcard.
Mitigation: :telemetry.attach_many/4 accepts an explicit list of event paths; subscribers attach to [:lockspire, :dcr_registration_succeeded], [:lockspire, :dcr_registration_rejected], etc., explicitly. This is the same pattern used at test/lockspire/admin/clients_test.exs:215-227.
What goes wrong: A future caller writes Observability.emit(:dcr_registration_succeeded, %{}, %{registration_access_token: rat_plaintext}). Redaction.for_telemetry/1 does NOT include :registration_access_token in its drop list (redaction.ex:8-53); the plaintext flows through to the audit row and to telemetry consumers.
Why it happens: Redaction.for_telemetry/1 is a key-allowlist sieve — it drops listed keys, but anything not listed passes through. The drop list includes :client_secret, :token, :token_hash, :authorization, :code, :code_challenge, etc., but NOT :registration_access_token, :initial_access_token, :rat, :iat, :plaintext.
How to avoid: Two layers of defense:
- Discipline: Phase 26 callers MUST emit ID/hash fields only (
iat_id,client_id,rat_hash), never the plaintext. D-17 already requires plaintext live only on theSuccesssubstruct. - Test: D-27's single-sweep test catches any accidental leak by
String.contains?againstinspect(captured_event)— this is the safety net. Make this test a Wave 0 deliverable so it fails loudly the instant plaintext leaks. Optional belt-and-braces: Add:registration_access_token,:initial_access_token,:rat,:iat,:client_secret_plaintext,:registration_access_token_plaintextto the drop list atredaction.ex:8-53(both atom and string forms). This is a 12-line change. Discuss with the planner whether it's worth doing as part of Phase 26 or whether the D-27 test alone is sufficient.
What goes wrong: D-22 changes the three silent :operator fallbacks (lines 407, 414, 419) to raise. Existing tests and runtime callers that depend on the silent default break.
Why it happens: Admin.Clients.create_client/1, update_client/2, rotate_client_secret/2, disable_client/2, enable_client/2 all pass attrs that may or may not contain :actor; the silent default has been masking missing-actor bugs.
How to avoid: Audit pass:
grep -rn "Admin.Clients\.\(create_client\|update_client\|rotate_client_secret\|disable_client\|enable_client\)" lib test— enumerate every callsite.- For each, verify
attrs[:actor][:type]is set explicitly; if not, add it. - Confirm
test/lockspire/admin/clients_test.exsalready passesactor: %{type: :operator, ...}explicitly (it does: see:73-83). - Run
mix testafter the tightening; any failures with(ArgumentError) actor type requiredare missed callsites. Warning signs: Test failures in unrelated specs afteractor_from_attrs/1is tightened. The error message must name the offending field (D-22) so the failing callsite is obvious. Plan ordering: Do the audit BEFORE tightening the function. Audit + tighten + run tests should be a single task to keepmix testalways green between commits.
Pitfall 7: Lockspire.Domain.InitialAccessToken vs Lockspire.Protocol.InitialAccessToken namespace confusion
What goes wrong: Two modules with very similar names — the Phase 25 Lockspire.Domain.InitialAccessToken is the defstruct; the Phase 26 Lockspire.Protocol.InitialAccessToken is the protocol module. A developer might alias Lockspire.Domain.InitialAccessToken at the top of registration.ex and then call InitialAccessToken.redeem/1 — which doesn't exist on the domain struct.
Why it happens: D-01 names are sibling-namespaced (Lockspire.Domain.X vs Lockspire.Protocol.X), reusing an established axis from Phase 25 D-15.
How to avoid: When Lockspire.Protocol.Registration aliases both, alias the protocol module under a distinct name:
alias Lockspire.Domain.InitialAccessToken, as: InitialAccessTokenStruct
alias Lockspire.Protocol.InitialAccessTokenor import without alias, qualifying the module path inline.
Warning signs: (UndefinedFunctionError) function Lockspire.Domain.InitialAccessToken.redeem/1 is undefined.
What goes wrong: D-27 test wires a :telemetry.attach handler that sends to self(). If the test path emits N events, the test must assert_received (or accumulate via receive) all N before the timeout — otherwise the test passes spuriously.
Why it happens: assert_received only checks one message. The single-sweep approach in D-27 needs accumulation, not single-message assertion.
How to avoid: Use the existing pattern at test/lockspire/admin/clients_test.exs:207-227 — the handler sends {:telemetry_event, event, metadata} to the test pid; the test then drains the mailbox via flush/0 or a receive loop and folds into a list, which becomes the input to the single String.contains? sweep:
defp drain_events(acc \\ []) do
receive do
{:telemetry_event, event, metadata} -> drain_events([{event, metadata} | acc])
after
100 -> Enum.reverse(acc)
end
endWarning signs: Test passes locally but flakes on CI when emission order varies. Use Enum.sort/1 on the captured list before assertion if order-independence matters; or just rely on String.contains?(inspect(events), plaintext) — order does not affect substring search.
Phase 26 is greenfield (new code, new tests) — there is no rename, refactor, migration, or string replacement of an existing identifier. CONTEXT.md ## Domain explicitly excludes schema migrations (Phase 25 territory). The runtime state to consider:
| Category | Items Found | Action Required |
|---|---|---|
| Stored data | None — verified by inspection of priv/repo/migrations/. The lockspire_initial_access_tokens table (Phase 25 migration 20260427000010) and the DCR fields on lockspire_clients (Phase 25 migration 20260427000020) are already in place. Phase 26 only writes/reads them; it does not migrate them. |
None |
| Live service config | None — no external services involved. The Lockspire library is embedded in the host Phoenix app. | None |
| OS-registered state | None — no scheduled jobs, system services, or daemons reference Phase 26 modules. | None |
| Secrets / env vars | None — Phase 26 does not introduce or rename any env var. The IAT plaintext and RAT plaintext are runtime-generated, never stored. The salt for hash_client_secret/1 is per-secret-random (generated at hash time at policy.ex:93). |
None |
| Build artifacts | None — Phase 26 does not change mix.exs, package metadata, or build output shape. |
None |
The canonical question ("After every file in the repo is updated, what runtime systems still have the old string cached, stored, or registered?") does not apply: nothing is being renamed.
Source: RFC 7591 §2.1 verbatim correlation table [CITED: https://datatracker.ietf.org/doc/html/rfc7591#section-2.1]:
grant_types value response_types value
-----------------------------------------------------
authorization_code code
implicit token
password (none)
client_credentials (none)
refresh_token (none)
urn:...:jwt-bearer (none)
urn:...:saml2-bearer (none)
The "(none)" entry means the grant type does not require a corresponding response_types entry. Per Phase 26 scope (CONTEXT.md ## Domain client_credentials and bearer assertions are out of scope for v1.5; CONTEXT.md ## Specifics §6 also notes implicit is not supported), the validator must enforce:
grant_types: ["authorization_code"]requiresresponse_types: ["code"](orresponse_typesabsent — RFC 7591 §2 default is["code"]).grant_types: ["refresh_token"]requiresgrant_typesALSO contain"authorization_code"(refresh tokens are issued by the authorization-code flow).- Other grant types are out of scope; reject with
:invalid_client_metadata.
Implementation sketch (private function inside Registration):
@allowed_grant_types MapSet.new(["authorization_code", "refresh_token"])
@allowed_response_types MapSet.new(["code"])
defp validate_grant_response_coherence(metadata) do
grant_types = metadata |> Map.get("grant_types", ["authorization_code"]) |> MapSet.new()
response_types = metadata |> Map.get("response_types", ["code"]) |> MapSet.new()
cond do
not MapSet.subset?(grant_types, @allowed_grant_types) ->
{:error, %Error{code: :invalid_client_metadata, field: :grant_types, reason: :unsupported_grant_type, allowed: MapSet.to_list(@allowed_grant_types)}}
not MapSet.subset?(response_types, @allowed_response_types) ->
{:error, %Error{code: :invalid_client_metadata, field: :response_types, reason: :unsupported_response_type, allowed: MapSet.to_list(@allowed_response_types)}}
MapSet.member?(grant_types, "refresh_token") and not MapSet.member?(grant_types, "authorization_code") ->
{:error, %Error{code: :invalid_client_metadata, field: :grant_types, reason: :refresh_token_requires_authorization_code, allowed: nil}}
MapSet.member?(grant_types, "authorization_code") and not MapSet.member?(response_types, "code") ->
{:error, %Error{code: :invalid_client_metadata, field: :response_types, reason: :authorization_code_requires_response_type_code, allowed: ["code"]}}
true ->
:ok
end
endSource: RFC 7591 §2 verbatim [CITED: https://datatracker.ietf.org/doc/html/rfc7591#section-2]: "The 'jwks_uri' and 'jwks' parameters MUST NOT both be present in the same request or response."
defp validate_jwks(metadata) do
has_jwks_uri = Map.has_key?(metadata, "jwks_uri")
has_jwks = Map.has_key?(metadata, "jwks")
cond do
has_jwks_uri ->
{:error, %Error{code: :invalid_client_metadata, field: :jwks_uri, reason: :unsupported_in_slice, allowed: nil}}
# (D-14 explicitly notes this branch is reached only if jwks_uri were not rejected above; kept for spec compliance.)
has_jwks_uri and has_jwks ->
{:error, %Error{code: :invalid_client_metadata, field: :jwks, reason: :mutually_exclusive_with_jwks_uri, allowed: nil}}
true ->
:ok
end
endBefore (lib/lockspire/admin/clients.ex:407, 414, 419):
defp normalize_actor_type(nil), do: :operator
defp normalize_actor_type(value) when is_atom(value), do: value
defp normalize_actor_type(value) when is_binary(value) do
value
|> String.trim()
|> case do
"" -> :operator
normalized -> normalized
end
end
defp normalize_actor_type(_value), do: :operatorAfter (D-22):
defp normalize_actor_type(nil),
do: raise(ArgumentError, "actor.type is required; pass attrs[:actor][:type] explicitly. " <>
"Allowed: :operator | :system | :host_app | :dcr | :self_registered_client")
defp normalize_actor_type(value) when is_atom(value), do: value
defp normalize_actor_type(value) when is_binary(value) do
value
|> String.trim()
|> case do
"" -> raise(ArgumentError, "actor.type cannot be blank")
normalized -> normalized
end
end
defp normalize_actor_type(other),
do: raise(ArgumentError, "actor.type must be an atom or non-blank string, got: #{inspect(other)}")Source: combination of test/lockspire/admin/clients_test.exs:207-230 (handler shape) + D-27 single-sweep assertion.
defmodule Lockspire.Protocol.DcrTelemetryRedactionTest do
use ExUnit.Case, async: false
alias Lockspire.Protocol.{Registration, RegistrationManagement, InitialAccessToken}
alias Lockspire.Storage.Ecto.AuditEventRecord
alias Lockspire.Storage.Ecto.Repository
import Ecto.Query
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Lockspire.TestRepo)
handler_id = "dcr-redaction-#{System.unique_integer([:positive])}"
:telemetry.attach_many(
handler_id,
[
[:lockspire, :dcr_registration_succeeded],
[:lockspire, :audit, :dcr_registration_succeeded],
[:lockspire, :dcr_registration_rejected],
[:lockspire, :audit, :dcr_registration_rejected],
[:lockspire, :dcr_management_read],
[:lockspire, :audit, :dcr_management_read],
[:lockspire, :dcr_management_updated],
[:lockspire, :audit, :dcr_management_updated],
[:lockspire, :dcr_management_deleted],
[:lockspire, :audit, :dcr_management_deleted],
[:lockspire, :dcr_registration_access_token_rotated],
[:lockspire, :audit, :dcr_registration_access_token_rotated],
[:lockspire, :iat_redeemed],
[:lockspire, :audit, :iat_redeemed],
[:lockspire, :iat_redemption_failed],
[:lockspire, :audit, :iat_redemption_failed]
],
&__MODULE__.handle_event/4,
self()
)
on_exit(fn -> :telemetry.detach(handler_id) end)
:ok
end
def handle_event(event, measurements, metadata, pid),
do: send(pid, {:telemetry_event, event, measurements, metadata})
defp drain_events(acc \\ []) do
receive do
{:telemetry_event, e, m, md} -> drain_events([{e, m, md} | acc])
after
50 -> Enum.reverse(acc)
end
end
test "no plaintext RAT/IAT/client_secret in any DCR/IAT telemetry event or audit row" do
iat_plaintext = "iat_test_#{:crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)}"
{:ok, _iat_record} = Lockspire.Test.Fixtures.InitialAccessTokenFixtures.persist(%{plaintext: iat_plaintext})
server_policy = %Lockspire.Domain.ServerPolicy{registration_policy: :initial_access_token, ...}
# Happy path — exercises register, RAT generation, secret hashing
{:ok, %Registration.Success{client_secret_plaintext: secret_plain, registration_access_token_plaintext: rat_plain} = success} =
Registration.register(%{metadata: valid_metadata(), iat: iat_plaintext, server_policy: server_policy, source: %{ip: "1.2.3.4"}})
# Sad path — exercises rejected paths
{:error, _} = Registration.register(%{metadata: invalid_metadata(), iat: iat_plaintext, server_policy: server_policy, source: %{ip: "1.2.3.4"}})
# Management — exercises read/update/delete + RAT rotation
{:ok, _} = RegistrationManagement.read(success.client.client_id, success.client)
{:ok, _} = RegistrationManagement.update(success.client.client_id, success.client, valid_update_metadata())
{:ok, _} = RegistrationManagement.delete(success.client.client_id, success.client)
# IAT failure axes — minted, used (already_used), revoked, expired
exercise_iat_failure_paths()
captured_events = drain_events()
audit_rows = Lockspire.TestRepo.all(from(a in AuditEventRecord, where: like(a.action, "dcr_%") or like(a.action, "iat_%")))
plaintexts = [secret_plain, rat_plain, iat_plaintext]
blob = inspect({captured_events, audit_rows})
for plaintext <- plaintexts do
refute String.contains?(blob, plaintext),
"plaintext leaked into telemetry or audit; offending plaintext: #{plaintext}"
end
end
end| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
Ecto.Multi for single-step transactions |
Repository.transact/1 + lock("FOR UPDATE") |
v1.0 (mark_authorization_code_redeemed/2) |
Phase 26 inherits — D-09 |
Telemetry via raw :telemetry.execute/3 |
Lockspire.Observability.emit/3 + Lockspire.Redaction.for_telemetry/1 |
v1.0 (Lockspire shipped with this from day one) | Phase 26 inherits — D-25 |
| Hand-rolled hash + compare | Lockspire.Security.Policy.hash_* + Plug.Crypto.secure_compare |
v1.0 | Phase 26 inherits — D-04..D-07 |
| Atom-keyed metadata in audit rows | String-keyed (after Audit.Event.normalize/1) |
v1.0 (audit/event.ex:94) |
Phase 26 audit assertions compare against strings, not atoms — see Pitfall 3 |
Deprecated/outdated:
- The
.planning/research/ARCHITECTURE.md"Module Layout" section (lines 124-129) listsdcr_audit.exand proposes file paths that pre-date Phase 25 D-15'sLockspire.Domain.XvsLockspire.Protocol.Xnamespace axis. CONTEXT.md D-01 supersedes the research doc; trust D-01. .planning/research/PITFALLS.md:243references "lines 450-472" foractor_from_attrs/1. Current code is at lines 397-419. Trust the file, not the research doc.- CONTEXT.md ## Canonical References notes that older research docs cite
lib/lockspire/protocol/jar_policy.ex— that file does not exist; uselib/lockspire/protocol/par_policy.exas the resolver structural precedent (verified:par_policy.exexists, nojar_policy.exfile in the protocol directory).
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | disable_client_with_audit/4 will be promoted to public OR disable_client/2 will be used instead |
Pitfall 1, Open Question 1 | If neither happens, RegistrationManagement.delete/2 won't compile |
| A2 | generate_client_id/0 will be promoted to public OR the idiom is duplicated inline |
Pitfall 2, Open Question 2 | If neither happens, Registration.register/1 won't compile |
| A3 | The D-24 regression test queries AuditEventRecord directly, not a (non-existent) Repository.list_audit_events/1 |
Pitfall 3, Open Question 3 | Test won't compile until corrected |
| A4 | Atom-singleton telemetry path satisfies the project-internal interpretation of DCR-23's [:lockspire, :dcr, ...] namespace |
Pitfall 4 (also CONTEXT.md ## Specifics §1) | If a Phase 29 audit reads DCR-23 strictly, may need to extend Observability.emit/3 (deferred) |
| A5 | RFC 7591 §2.1 grant/response coherence rules above are accurate | Code Examples §1 | Verified against RFC text via WebFetch — but spec interpretation can drift; planner should re-read §2.1 if any test fails on a coherence assertion |
If any A1/A2/A3 turn out to require a different resolution, the planner should fold the resolution into the early "carve out helpers" tasks before authoring protocol modules.
All six questions resolved. Resolutions are folded into Phase 26 plans 26-01 through 26-07.
-
disable_client_with_audit/4is currentlydefp(private) atadmin/clients.ex:348— D-21 calls it fromRegistrationManagement.delete/2.- What we know: D-21 specifies the call. The function is private. The public
Admin.Clients.disable_client/2(line 127) wraps it. - What's unclear: Whether to (a) promote the private function or (b) use
disable_client/2instead. - Recommendation: Use
Admin.Clients.disable_client/2. It already takes(client_id, attrs)whereattrscarries:disabled_by,:disabled_at, and:actor. After D-22 tightensactor_from_attrs/1, passingactor: %{type: :self_registered_client, id: client.client_id}flows correctly. No changes toadmin/clients.exrequired beyond D-22. - RESOLVED: Use
Admin.Clients.disable_client/2(public). Folded into plan 26-06 task 2 (grep -c 'Admin.Clients.disable_client(client.client_id'returns 1;grep -c 'disable_client_with_audit'returns 0). D-21 in CONTEXT.md still namesdisable_client_with_audit/4— the planner reads the recommendation here and supersedes that detail with the public wrapper.
- What we know: D-21 specifies the call. The function is private. The public
-
Lockspire.Clients.generate_client_id/0is currentlydefp(private) atclients.ex:384-386— D-16 calls it fromRegistration.register/1.- What we know: D-16 specifies the call. The function is private.
- What's unclear: Whether to promote it to public (smaller diff, single source of truth) or duplicate the 2-line idiom (
"ls_" <> Base.url_encode64(:crypto.strong_rand_bytes(24), padding: false)) inRegistration. - Recommendation: Promote to public. The format is operator-affecting (every operator-created and DCR-created client uses this prefix); keeping it in one place avoids drift.
- RESOLVED: Promote to
defwith@spec. Folded into plan 26-01 task 1.
-
D-24 regression test queries
Repository.list_audit_events/1, which does not exist.- What we know:
grepreturns no matches forlist_audit_eventsinrepository.ex. Existing pattern attest/lockspire/admin/clients_test.exs:232-240queriesAuditEventRecorddirectly viaLockspire.TestRepo.all(from(...)). - What's unclear: Whether to add
Repository.list_audit_events/1torepository.ex(consistent withregister_client/1,list_clients/1shape) or stay with the in-test direct-query pattern. - Recommendation: Stay with the in-test direct-query pattern. Phase 26's regression test is the only Phase 26 caller; adding a public Repository helper for one test is over-investment.
- RESOLVED: Use direct
from(audit in AuditEventRecord, ...)query in tests (no new Repository helper). Folded into plans 26-05 task 1, 26-06 task 1, and 26-07 (cross-cutting sweep).
- What we know:
-
Should
:registration_access_token,:initial_access_token,:rat,:iatbe added to theRedaction.for_telemetry/1andRedaction.for_audit/1drop lists atredaction.ex:8-53?- What we know: Currently the drop list covers
:client_secret,:token,:token_hash,:authorization,:code,:code_verifier, etc. — but NOT the named credential keys. - What's unclear: Whether D-27's single-sweep test is enough (defense via discipline + test) or whether the drop list itself should be extended (defense via filter).
- Recommendation: Belt-and-braces — extend the drop list. 12-line addition. Reduces the blast radius if a future caller accidentally puts plaintext under one of these keys.
- RESOLVED: Yes — extend
@telemetry_drop_keysand@audit_drop_keysMapSets with:registration_access_token,:initial_access_token,:rat,:iat(atom + string variants). Folded into plan 26-01 task 4 (Wave 0). Belt-and-braces: defense via filter complements D-27's defense-via-test.
- What we know: Currently the drop list covers
-
What does
Registration.register/1do when noiatis provided ANDserver_policy.registration_policy = :initial_access_token?- What we know: D-12 says
iat: <plaintext_iat | nil>. D-13 step 1 says "IAT redemption (ifiatnon-nil)". - What's unclear: When
iatis nil but server policy requires one, where is that gating enforced? - Recommendation: Phase 26 enforces it before pipeline step 1: when
server_policy.registration_policy == :initial_access_tokenandiat == nil, return{:error, %Error{code: :invalid_token, field: :iat, reason: :missing}}immediately. This keeps the protocol module self-sufficient and lets Phase 27's controller stay thin (the controller maps:invalid_tokento401, which is the RFC 7592 default). - RESOLVED: Reject with
%Error{code: :invalid_token, field: :iat, reason: :missing}immediately, BEFOREmaybe_redeem_iat/1runs. Folded into plan 26-05 task 2a (precondition gaterequire_iat_when_policy_demands/2). Test pin in plan 26-05 task 1: "rejects with :invalid_token/:iat/:missing when server_policy.registration_policy == :initial_access_token and iat == nil".
- What we know: D-12 says
-
What happens to old
Domain.Clientrows that were created before Phase 25's provenance backfill? (Also covers RFC 7591 §2.3software_statementfield handling.)- What we know: Phase 25 migration
20260427000020_extend_lockspire_clients_dcr.exsbackfills existing rows to:operator(per ROADMAP.md SC 1). RFC 7591 §2 says "Extensions and profiles of this specification MAY define new metadata members for use in client registration" — i.e., the validator should ignore unknown fields gracefully. - What's unclear: None — verified for backfill; the unknown-field handling is documented in the RESEARCH §"Additional Pitfall — Software Statement" (lines 925-931).
- RESOLVED: (a) Backfill: existing rows are
:operatorper Phase 25 migration — verified. (b) Unknown fields includingsoftware_statement: silently ignore — the intake validator's allowlist is "the fields we explicitly handle"; everything else is dropped before persistence. Test pin in plan 26-05 task 1: "register/1 silently ignores software_statement".
- What we know: Phase 25 migration
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Elixir | language | yes | 1.19.5 | none — required |
| Erlang/OTP | runtime | yes | 28 | none — required |
| ecto_sql | persistence | yes | ~> 3.13.5 | none — required |
| postgrex | DB driver | yes | latest | none — required |
| Postgres | DB | assumed yes (project uses Lockspire.TestRepo with Ecto.Adapters.SQL.Sandbox) |
latest with FOR UPDATE (any modern PG) |
none — required |
| telemetry | event emission | yes | ~> 1.3 | none — required |
| jason | JSON encoding | yes | ~> 1.4 | none — required |
| plug_crypto | secure_compare/2 |
yes (transitive via phoenix) | latest | none — required |
Missing dependencies with no fallback: None. Missing dependencies with fallback: None.
All Phase 26 work can proceed with the existing toolchain.
workflow.nyquist_validation = true per .planning/config.json. This section is required.
| Property | Value |
|---|---|
| Framework | ExUnit (built into Elixir 1.19.5) |
| Config file | test/test_helper.exs (excludes :integration tag by default; includes when --include integration passed) |
| Quick run command | mix test test/lockspire/protocol/<file>_test.exs --max-failures 1 |
| Full suite command | MIX_ENV=test mix test.fast (alias: mix lockspire.test.setup && mix test) |
| Sandbox mode | Ecto.Adapters.SQL.Sandbox.mode(Lockspire.TestRepo, :manual) per setup_all (pushed_authorization_request_test.exs:13-18) |
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| DCR-02 | jwks_uri rejected with invalid_client_metadata "not supported in this slice" |
unit | mix test test/lockspire/protocol/registration_test.exs:test_jwks_uri_rejected -x |
❌ Wave 0 |
| DCR-02 | jwks and jwks_uri cannot both be present |
unit | mix test test/lockspire/protocol/registration_test.exs:test_jwks_mutual_exclusion -x |
❌ Wave 0 |
| DCR-02 | RFC 7591 §2 grant_types/response_types coherence — parametric (one test per pairing) |
unit (parametric) | mix test test/lockspire/protocol/registration_test.exs:test_grant_response_coherence -x |
❌ Wave 0 |
| DCR-02 | redirect_uris routed through Lockspire.Clients.validate_redirect_uris/1 (parity test) |
unit | mix test test/lockspire/protocol/registration_test.exs:test_redirect_uris_parity -x |
❌ Wave 0 |
| DCR-03 | PKCE floor — explicit pkce_required: false rejected with clear reason |
unit | mix test test/lockspire/protocol/registration_test.exs:test_pkce_floor_explicit_false -x |
❌ Wave 0 |
| DCR-03 | PKCE floor — Domain.Client row stored with pkce_required: true |
unit | mix test test/lockspire/protocol/registration_test.exs:test_persisted_pkce_required -x |
❌ Wave 0 |
| DCR-04 | client_secret SHA-256-with-salt hashed at rest |
unit | mix test test/lockspire/protocol/registration_test.exs:test_client_secret_hashed_at_rest -x |
❌ Wave 0 |
| DCR-04 | registration_access_token SHA-256 hashed at rest |
unit | mix test test/lockspire/protocol/registration_test.exs:test_rat_hashed_at_rest -x |
❌ Wave 0 |
| DCR-04 | Plaintext returned exactly once on Success substruct |
unit | mix test test/lockspire/protocol/registration_test.exs:test_plaintext_returned_once -x |
❌ Wave 0 |
| DCR-11 | redeem/1 rejects expired IAT with :invalid_token |
unit | mix test test/lockspire/protocol/initial_access_token_test.exs:test_expired_returns_invalid_token -x |
❌ Wave 0 |
| DCR-11 | redeem/1 rejects revoked IAT with :invalid_token |
unit | mix test test/lockspire/protocol/initial_access_token_test.exs:test_revoked_returns_invalid_token -x |
❌ Wave 0 |
| DCR-11 | redeem/1 rejects already-used IAT with :invalid_token |
unit | mix test test/lockspire/protocol/initial_access_token_test.exs:test_already_used_returns_invalid_token -x |
❌ Wave 0 |
| DCR-11 | Successful redemption marks used_at in same transaction |
unit | mix test test/lockspire/protocol/initial_access_token_test.exs:test_used_at_set_in_same_tx -x |
❌ Wave 0 |
| DCR-11 | Atomicity — concurrent redemption attempts produce exactly one success | concurrent (Task.async-many) | mix test test/lockspire/protocol/initial_access_token_test.exs:test_concurrent_redemption_atomicity -x |
❌ Wave 0 |
| DCR-22 | actor_from_attrs/1 raises ArgumentError on missing actor type |
unit | mix test test/lockspire/admin/clients_test.exs:test_actor_from_attrs_raises_on_missing -x |
❌ Wave 0 (extension to existing test) |
| DCR-22 | DCR write paths attribute :dcr for intake; :self_registered_client for management |
unit | mix test test/lockspire/protocol/dcr_audit_attribution_test.exs:test_dcr_actor_types -x |
❌ Wave 0 |
| DCR-22 | Regression — no dcr_* audit row has actor_type = "operator" |
regression | mix test test/lockspire/protocol/dcr_audit_attribution_test.exs:test_no_operator_dcr_attribution -x |
❌ Wave 0 |
| DCR-23 | Single-sweep — RAT/IAT/client_secret plaintext absent from telemetry events |
unit (sweep) | mix test test/lockspire/protocol/dcr_telemetry_redaction_test.exs:test_no_plaintext_in_telemetry -x |
❌ Wave 0 |
| DCR-23 | Single-sweep — RAT/IAT/client_secret plaintext absent from audit rows |
unit (sweep) | mix test test/lockspire/protocol/dcr_telemetry_redaction_test.exs:test_no_plaintext_in_audit_rows -x |
❌ Wave 0 |
- Per task commit:
mix test test/lockspire/protocol/<the_file_being_changed>_test.exs --max-failures 1(typically <5s). - Per wave merge:
mix test test/lockspire/protocol/ test/lockspire/admin/clients_test.exs --max-failures 3(Phase 26 file scope). - Phase gate:
mix test.fast(full unit suite) green;mix qa(format,compile --warnings-as-errors,credo --strict,dialyzer) green before/gsd-verify-work.
-
test/support/fixtures/dcr_fixtures.ex— inbound RFC 7591 metadata fixtures (valid intake map, invalidjwks_urimap, invalid grant/response coherence map, invalid redirect URI map, etc.); RAT plaintext helpers;Registrationrequest-tuple builder. -
test/support/fixtures/initial_access_token_fixtures.ex— extend withpersist/1helper that inserts the IAT row and returns the plaintext (current fixture builds the struct only; redemption tests need a row present). -
test/lockspire/protocol/registration_test.exs— covers DCR-02, DCR-03, DCR-04 happy + sad paths. -
test/lockspire/protocol/registration_management_test.exs— covers RFC 7592read/2,update/2(full-replace + RAT rotation invalidation),delete/2(soft-disable + reuse prevention). -
test/lockspire/protocol/initial_access_token_test.exs— covers DCR-11 freshness ladder + atomicity (Task.async concurrent test). -
test/lockspire/protocol/registration_access_token_test.exs— covers RAT generate/hash primitives. -
test/lockspire/protocol/dcr_audit_attribution_test.exs— covers DCR-22 regression assertion. -
test/lockspire/protocol/dcr_telemetry_redaction_test.exs— covers DCR-23 single-sweep redaction. -
test/lockspire/admin/clients_test.exs— extend with theactor_from_attrs/1-raises tests; audit existing tests pass:actorexplicitly (verified at line 57-61 they do, but a sweep throughupdate_client,rotate_client_secret,disable_client,enable_clientcallers in tests is needed).
test "concurrent redemption — exactly one task wins, the rest get :invalid_token" do
iat_plaintext = "iat_concurrent_test"
{:ok, _row} = Lockspire.Test.Fixtures.InitialAccessTokenFixtures.persist(%{plaintext: iat_plaintext})
# Each task needs its own DB connection in sandbox mode — share via parent allowance
parent = self()
tasks =
for _ <- 1..10 do
Task.async(fn ->
Ecto.Adapters.SQL.Sandbox.allow(Lockspire.TestRepo, parent, self())
Lockspire.Protocol.InitialAccessToken.redeem(iat_plaintext)
end)
end
results = Task.await_many(tasks, 5_000)
successes = Enum.count(results, &match?({:ok, _}, &1))
failures = Enum.count(results, &match?({:error, :invalid_token}, &1))
assert successes == 1, "expected exactly one redemption success, got #{successes}"
assert failures == 9, "expected nine :invalid_token failures, got #{failures}"
endsecurity_enforcement is not explicitly disabled in .planning/config.json; treat as enabled.
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | yes | RAT-bearing auth for RFC 7592; comparison via Plug.Crypto.secure_compare (timing-safe) |
| V3 Session Management | no | DCR is stateless (request → response); no session |
| V4 Access Control | yes | URL client_id MUST match RAT-bound client.client_id (D-19); mismatch → :invalid_token (no enumeration leak) |
| V5 Input Validation | yes | RFC 7591 §2 metadata validation — Lockspire.Clients.validate_redirect_uris/1, private intake validator (D-14), DcrPolicy.resolve/3 (Phase 25) |
| V6 Cryptography | yes | Lockspire.Security.Policy.hash_token/1 (SHA-256), hash_client_secret/1 (salted SHA-256). Never hand-roll. |
| V7 Error Handling | yes | Error-axis collapsing (D-11) — never return discriminating reasons that enable enumeration |
| V9 Communications | n/a | Phase 27 territory (TLS, host responsibility) |
| V11 Business Logic | yes | Atomic single-use redemption (D-09, D-10) — race conditions would defeat the single-use guarantee |
| V14 Configuration | yes | Empty allowlist semantics (dcr_policy.ex:32-46 documents the operator UX hazard) |
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| IAT enumeration via timing or error-discriminator | Information Disclosure | Collapse all redemption rejections to :invalid_token (D-11); use Plug.Crypto.secure_compare for hash equality |
| RAT enumeration via differentiated error returns | Information Disclosure | RegistrationManagement returns :invalid_token for both "no row found by RAT hash" and "URL client_id doesn't match RAT-bound client" (D-19) |
| Race condition — two concurrent IAT redemptions both succeed | Tampering | Repo.transact/1 + lock("FOR UPDATE") (D-09) |
| Plaintext RAT/IAT/secret in telemetry, audit row, or log | Information Disclosure | Lockspire.Redaction.for_telemetry/1 + for_audit/1 drop lists; D-27 single-sweep test catches accidental leaks |
| Operator-flavored audit attribution for DCR writes | Repudiation / forensic confusion | D-22: actor_from_attrs/1 raises on missing type; D-24 regression test enforces |
jwks_uri SSRF |
Tampering / SSRF | Reject jwks_uri at intake (D-14); SSRF-guarded fetch deferred to DCR-FUT-01 |
| Open registration abuse (no rate limit) | DoS | v1.5 documents host-Plug seam responsibility (Phase 29 SECURITY.md); built-in rate limit deferred to DCR-FUT-04 |
| Public client default with PKCE-disabled | Tampering / token theft | D-15 PKCE floor: explicit pkce_required: false rejected; Domain.Client row always pkce_required: true for self-registered clients |
| Software statement (RFC 7591 §2.3) trust-root confusion | Tampering | Out of scope for v1.5 (REQUIREMENTS.md ## Out of Scope); not parsed, not stored, not validated. Inbound software_statement field is ignored — see Pitfall 9 below |
Additional Pitfall — Software Statement (out of scope, but inbound field must be ignored gracefully)
What goes wrong: A registrant POSTs metadata containing software_statement: <jwt> (RFC 7591 §2.3). v1.5 does not support software statements. If Registration.register/1 rejects the entire request because of an unknown field, registrants with software-statement-aware clients see a hard failure and cannot register at all.
Why it happens: REQUIREMENTS.md ## Out of Scope explicitly excludes software statements. RFC 7591 §2 is permissive about unknown fields ("Extensions and profiles of this specification MAY define new metadata members for use in client registration").
How to avoid: The intake validator MUST silently ignore unknown fields, including software_statement. The validator's allowlist is "the fields we explicitly handle"; everything else is dropped before persistence. Test: register with software_statement: "eyJ..." and assert the persisted Domain.Client row does not contain a software-statement field, AND the registration succeeds normally.
Warning signs: Registration test fails when registrant includes any RFC 7591 extension field.
/Users/jon/projects/lockspire/lib/lockspire/protocol/pushed_authorization_request.ex— full read; the structural precedent for D-01 modules/Users/jon/projects/lockspire/lib/lockspire/security/policy.ex— full read; verifieshash_token/1:84-89,hash_client_secret/1:91-96,verify_client_secret/2:99-114/Users/jon/projects/lockspire/lib/lockspire/clients.ex— full read; verifiesvalidate_redirect_uris/1:32,rotate_secret_hash/0:53,generate_client_id/0:384 (PRIVATE)/Users/jon/projects/lockspire/lib/lockspire/admin/clients.ex— full read; verifiesactor_from_attrs/1:397-419 with three silent fallbacks at 407, 414, 419,disable_client_with_audit/4:348 (PRIVATE),disable_client/2:127 (public),client_audit_event/5:386-395/Users/jon/projects/lockspire/lib/lockspire/storage/ecto/repository.ex— selective read of lines 1-120, 220-340, 500-620, 700-920; verifiestransact/1:223,transact_with_audit/2:296-312,mark_authorization_code_redeemed/2:534-557 pattern,register_client/1:44,update_client/2:74, lock pattern usage at 80, 79, 267, 527, 540, 708, 748, 910, 917/Users/jon/projects/lockspire/lib/lockspire/observability.ex— full read; verifiesemit/3:15-29 produces 2-segment paths/Users/jon/projects/lockspire/lib/lockspire/redaction.ex— full read; verifiesfor_telemetry/1drop list at lines 8-53; confirms:registration_access_token/:initial_access_tokenare NOT in drop list/Users/jon/projects/lockspire/lib/lockspire/audit/event.ex— full read; verifiesnormalize/1converts atom→string at line 94, appliesRedaction.for_audit/1to metadata/Users/jon/projects/lockspire/lib/lockspire/protocol/dcr_policy.ex— full read; verifiesResolvedsubstruct fields (D-13 step 2 input)/Users/jon/projects/lockspire/lib/lockspire/storage/ecto/initial_access_token_record.ex— full read; verifies schema,to_domain/1shape/Users/jon/projects/lockspire/lib/lockspire/storage/ecto/audit_event_record.ex— full read; verifiesactor_typefield at line 16 (string)/Users/jon/projects/lockspire/lib/lockspire/domain/initial_access_token.ex— full read/Users/jon/projects/lockspire/lib/lockspire/domain/client.ex— full read; verifies DCR field set (provenance,registration_access_token_hash,initial_access_token_id,client_id_issued_at,client_secret_expires_at)/Users/jon/projects/lockspire/test/lockspire/admin/clients_test.exs— selective read; verifies test pattern for telemetry capture (lines 207-230) and audit-row query (lines 232-240)/Users/jon/projects/lockspire/test/lockspire/protocol/pushed_authorization_request_test.exs— selective read; verifies test setup pattern (Lockspire.TestRepo,Ecto.Adapters.SQL.Sandbox,mode :manual)/Users/jon/projects/lockspire/test/support/fixtures/initial_access_token_fixtures.ex— full read; existing fixture, can be extended forpersist/1/Users/jon/projects/lockspire/priv/repo/migrations/20260427000010_create_lockspire_initial_access_tokens.exs— full read; verifiesunique_index([:token_hash])shipped/Users/jon/projects/lockspire/mix.exs— full read; verifies dependency versions/Users/jon/projects/lockspire/.planning/REQUIREMENTS.md,/Users/jon/projects/lockspire/.planning/STATE.md,/Users/jon/projects/lockspire/.planning/ROADMAP.md,/Users/jon/projects/lockspire/.planning/phases/26-*/26-CONTEXT.md,/Users/jon/projects/lockspire/.planning/phases/26-*/26-DISCUSSION-LOG.md— full reads
- RFC 7591 — Dynamic Client Registration Protocol — verified via WebFetch [CITED: https://datatracker.ietf.org/doc/html/rfc7591]: §2 (client metadata), §2.1 (grant_types/response_types correlation table + jwks_uri/jwks mutual exclusion), §3.2.1 (client information response), §3.2.2 (client registration error response —
invalid_client_metadataerror code) - RFC 7592 — Dynamic Client Registration Management Protocol [CITED: https://datatracker.ietf.org/doc/html/rfc7592]: referenced for §2 (read), §2.1 (update — full-replace), §2.2 (delete — soft-disable), §3 (error codes)
- RFC 6749 §5.2 —
invalid_tokenerror semantics [CITED: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2]: basis for D-11 collapsing
.planning/research/ARCHITECTURE.md— directional but contains stale references (jar_policy.exand module path conventions superseded by Phase 25 D-15). Trust the file evidence above where it conflicts..planning/research/PITFALLS.md— Pitfall 10 (audit attribution) and Pitfall 12 (revoked vs used) referenced by CONTEXT.md. Line numbers cited (450-472) are stale.
- None — every claim in this research is backed by a concrete file:line or an authoritative spec section.
Confidence breakdown:
- Standard stack: HIGH — every dependency verified in
mix.exs, every helper verified at file:line. - Architecture: HIGH — patterns are direct from existing code;
mark_authorization_code_redeemed/2andPushedAuthorizationRequest.push/1are verbatim precedents. - Pitfalls: HIGH — three of the eight pitfalls (Pitfalls 1, 2, 3) are concrete code-correctness issues found by direct codebase inspection; the others are documented design considerations from CONTEXT.md.
- RFC interpretation: HIGH for §2 and §2.1 (verified via WebFetch); MEDIUM for §3 corner cases — planner should re-verify §3 boundary conditions when authoring
RegistrationManagementtests. - Open Questions: HIGH that the questions exist; recommendation rationale is opinion-with-evidence.
Research date: 2026-04-26
Valid until: 2026-05-26 (30 days; codebase is internally stable, but RFC interpretation is locked, so this research stays valid as long as mix.exs deps and the cited line numbers don't move significantly)
Phase: 26-protocol-pipeline-rfc-7591-intake-and-rfc-7592-management-co Research conducted: 2026-04-26