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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user