docs(arch): ADR-030..033 — repo/adapter pattern, PeerEntry, CredentialStore, forwarded-for

Land the storage and auth strategy research (findings.md) as four
accepted ADRs and amend the core and call specs to match:

- ADR-030: PeerEntry and Identity.id decoupling. Replaces
  authorized_fingerprints with peers: Vec<PeerEntry>; Identity.id becomes
  the stable peer_id, decoupled from the rotating fingerprint. Supersedes
  ADR-029 Assumption 1's UUID source (one-way door preserved, source
  changes). Resolves OQ-33 and the storage-boundary half of OQ-34. Records
  the API-key asymmetry as deliberate (OQ-35).

- ADR-031: CredentialStore repo trait + InMemoryCredentialStore default
  adapter in core. Second repo trait alongside IdentityProvider. Vault
  encrypts; the store persists the EncryptedData blob; assembly layer
  loads into Capabilities. EncryptedData core mirror includes salt for
  wire-format compat.

- ADR-032: Forwarded-for identity. forwarded_for field on call.requested
  and OperationContext — metadata only, never read by AccessControl::check
  (enforced structurally via the check signature). The from_call handler
  populates it. Wire-format one-way door, folded into the ADR-029
  migration window.

- ADR-033: Storage boundary and repo/adapter pattern. Core defines repo
  traits + in-memory defaults; persistence adapters are separate crates;
  assembly layer wires. Resolves OQ-34. Concrete adapter shapes deferred
  for exploration (OQ-36).

Amends auth.md, config.md, operation-registry.md, client-and-adapters.md,
open-questions.md, README.md, crates/core/README.md. Marks ADR-029
Accepted (Assumption 1 carries the ADR-030 superseded note). Marks the
research findings doc reviewed.
This commit is contained in:
2026-06-27 12:12:25 +00:00
parent 347bff257c
commit f224ea998c
13 changed files with 1307 additions and 144 deletions

View File

@@ -173,7 +173,8 @@ pub struct PeerCompositeEnv {
pub connections: HashMap<PeerId, Arc<dyn OperationEnv + Send + Sync>>, // Layer 2, peer-keyed
connection_order: Vec<PeerId>, // insertion order for PeerRef::Any first-match
}
pub type PeerId = String; // logical id (UUID v1), NOT Identity.id — see OQ-33
pub type PeerId = String; // = Identity.id from IdentityProvider resolution
// = PeerEntry.peer_id (stable, not crypto material — ADR-030)
```
`OperationEnv` gains a peer-routing method with a `PeerRef` selector
@@ -307,6 +308,16 @@ they do. `from_call` means "I trust the remote node as much as my own
handlers." The abort cascade (ADR-016) crosses the node boundary transparently
through the forwarding handler's `parent_request_id`.
**Forwarded-for identity** (ADR-032): the `from_call` forwarding handler
populates `forwarded_for` on the `call.requested` payload it constructs to
send to the spoke. The hub reads its own `OperationContext.identity` (the
end user it authenticated) and sets `forwarded_for` to that identity when
forwarding. The spoke receives it as metadata on its `OperationContext`
available for logging, auditing, per-user rate limiting, but never used by
`AccessControl::check` (the spoke authorizes the hub, its direct caller,
not the end user). The hub may set `forwarded_for: None` if it doesn't
want to disclose the originator. See [ADR-032](../../decisions/032-forwarded-for-identity.md).
### from_jsonschema
Schema-only registration: produces `HandlerRegistration` bundles with no
@@ -587,6 +598,9 @@ Based on the gap analysis and the downstream unblock chain:
|----------|-----|---------|
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
| Peer-graph routing model (DC-1, supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~Default-deny; `remote_safe: bool`; trusted-peer opt-in~~ — superseded by ADR-029 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated existing `AccessControl`) |
| Secret material flow and capability injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
@@ -631,12 +645,23 @@ See [open-questions.md](../../open-questions.md) for full details.
- **OQ-32** (open): Multi-hop federation — v1 is one-hop; the peer-keyed
overlay model extends to multi-hop without redesign; petgraph is the
candidate if path-finding becomes real (ADR-029 §3.7).
- **OQ-33** (resolved): `PeerId` is a logical id (connection-assigned UUID),
not `Identity.id` — decoupling from crypto material keeps the door open for
key-rotation-safe ACLs. See OQ-33 in open-questions.md.
- **OQ-34** (open): Persistent peer registry — the storage dimension OQ-33
surfaced; not a v1 blocker (UUID works), tracked so the no-DB posture's
limit is deliberate. See OQ-34 in open-questions.md.
- **OQ-33** (resolved by ADR-030): `PeerId` is a logical id. Source is
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
stable across key rotation), not a connection-assigned UUID. The UUID
workaround is removed. See OQ-33 in open-questions.md.
- **OQ-34** (resolved by ADR-030 + ADR-033): Persistent peer registry —
the storage boundary is `core trait + in-memory default` (config-backed
`ConfigIdentityProvider` now; persistence adapters additive in separate
crates). See OQ-34 in open-questions.md.
- **OQ-35** (recorded by ADR-030): API key identity vs peer identity — the
asymmetry between the fingerprint path (gets `PeerEntry` id-decoupling)
and the API-key path (doesn't) is deliberate. See OQ-35 in
open-questions.md.
- **OQ-36** (tracked by ADR-033): Concrete adapter shapes — the repo/adapter
pattern is committed (core trait + in-memory default; persistence adapters
are separate crates); the concrete adapter shapes (table schemas, backend
choice, indexing) are deferred for exploration. See OQ-36 in
open-questions.md.
## References

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-23
last_updated: 2026-06-27
---
# Operation Registry
@@ -113,6 +113,7 @@ pub struct OperationContext {
pub parent_request_id: Option<String>,
pub identity: Option<Identity>, // Caller's identity (inbound — who invoked me)
pub handler_identity: Option<CompositionAuthority>, // Handler's composition authority (ADR-022)
pub forwarded_for: Option<Identity>, // Original caller when forwarded (ADR-032, metadata only — NOT used by AccessControl::check)
pub capabilities: Capabilities,
pub metadata: HashMap<String, Value>,
/// Reachability set — the operations this handler may compose.
@@ -176,6 +177,7 @@ impl OperationContext {
- `parent_request_id`: Set when this call was initiated by another operation (via `OperationEnv`). Records the agency chain — the call tree is the principal→agent chain (ADR-015)
- `identity`: The authenticated caller (from `IdentityProvider`) — inbound auth (who is calling me). For external calls, this is who sent the `call.requested`. For internal calls, this is the parent handler's `handler_identity` (propagated through `OperationEnv::invoke()`)
- `handler_identity`: The composition authority of the handler processing this call. `None` for leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) — they don't compose. `Some(...)` for `Local` and `Session` ops that can compose children. For internal calls (`internal: true`), the ACL check runs against this authority (ADR-015, ADR-022). This is NOT a peer `Identity` — it's a declared authority bundle set at registration by the assembly layer
- `forwarded_for`: The original caller when this call was forwarded by a `from_call` handler (ADR-032). **Metadata only**`AccessControl::check` never reads it; the ACL always authorizes the direct caller's `identity`. Handlers may read it for logging, auditing, per-user rate limiting, or application context. Populated from `call.requested.forwarded_for` by the dispatch path; set to `None` for composed children (wire-ingress only). The forwarder's claim, not a verified identity — a malicious hub can lie (same property as HTTP `X-Forwarded-For`). See ADR-032.
- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below
- `metadata`: Request-scoped context (tracing IDs, connection info). **Must not hold secret material** — see ADR-014. **Does not propagate through `OperationEnv::invoke()`** — nested calls get fresh metadata. The tracing link between parent and child is `parent_request_id`, not metadata propagation. Anything a handler needs to pass to a child goes in the call `input`.
- `scoped_env`: The reachability set — the operations this handler may compose. Populated from the registration bundle's `scoped_env` (ADR-022). The reachability check in `OperationEnv::invoke()` consults `scoped_env.allows(&name)`. This is *data* (a `ScopedOperationEnv` struct), not a dispatch trait. `None`/empty for leaves.
@@ -420,6 +422,9 @@ impl OperationEnv for LocalOperationEnv {
// None for leaves — they don't compose, so this is never used
// for ACL on a grandchild.
handler_identity: registration.composition_authority.clone(),
// Composed children do not inherit forwarded_for — it's a
// wire-ingress field, not a composition-ingress field (ADR-032).
forwarded_for: None,
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
metadata: HashMap::new(), // Fresh — does NOT propagate parent metadata (ADR-014)
abort_policy: policy, // Explicit policy (from invoke() default or invoke_with_policy)
@@ -666,6 +671,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`from_jsonschema`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
| Peer-graph routing model (supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~`remote_safe` marking on `HandlerRegistration`~~ — superseded by ADR-029 |
## Open Questions