docs: port architecture specs and ADRs from alknet-http; write new alkhttp ADRs 067-070
Phase 1 (SDD) — architecture documentation: Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint gateway, channels-over-WS, Sub/Pub operation types): - overview.md, http-server.md, http-adapters.md, http-mcp.md - README.md index (rewritten for alkhttp) New ADRs: - 067: WebSocket carries the channels protocol (8-byte chunk demux, channel 0 = alk/call, upgrade path /alk/channels) - 068: gateway /publish endpoint for Pub operations (NDJSON body) - 069: WebTransport out of scope in alkhttp (alknet concern) - 070: from_wss consumer adapter (wss feature, tokio-tungstenite) Ported ADRs (25, same numbers, port notes + amendments where the extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027, 034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066. websocket.md rewritten for the channels session; open-questions.md seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing, OQ-03 from_wss reconnect, OQ-04 browser client ownership). Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
# ADR-014: Secret Material Flow and Capability Injection
|
||||
|
||||
*Ported from alknet ADR-014 (Secret Material Flow and Capability Injection); re-targeted to alkhttp.*
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
alkvault holds the master seed and can derive keys and encrypt/decrypt
|
||||
arbitrary data. ADR-008 established that the vault is a **capability source**:
|
||||
"derived keys and decrypted credentials are injected into operation contexts
|
||||
at the assembly layer, not passed as vault references to handlers." That
|
||||
prose was correct but the mechanism was never specified.
|
||||
|
||||
The result was a contradiction in the spec documents. ADR-008 said the master
|
||||
seed never crosses the network, but `operation-registry.md` showed
|
||||
`vault/derive`, `vault/unlock`, and `vault/decrypt` registered as call protocol
|
||||
operations — directly on the wire. Those two statements cannot both be true.
|
||||
The contradiction arose because no injection mechanism existed in the
|
||||
architecture, so the only way the docs could show a handler obtaining a key was
|
||||
to expose vault operations over the call protocol.
|
||||
|
||||
This is a one-way door. Once secret material crosses the wire as a call
|
||||
protocol operation, the attack surface is permanent:
|
||||
|
||||
- `vault/unlock` accepts a BIP39 mnemonic — the root of trust — over the wire.
|
||||
A compromised peer, a logging accident, a tracing span, and the seed is gone.
|
||||
- `vault/derive` returns a `DerivedKey`. The type redacts the private key in
|
||||
JSON today, but the operation's existence means a serialization change, a
|
||||
binary codec addition, or a wrapper change would leak it. The surface is
|
||||
the risk, not the current implementation.
|
||||
- `vault/decrypt` accepts an encrypted blob and returns plaintext. Any
|
||||
authorized caller can decrypt any blob they possess.
|
||||
|
||||
The broader problem this decision addresses is structural: the industry
|
||||
default for storing LLM provider keys, API tokens, and other credentials is
|
||||
plaintext config files and environment variables (e.g., the aisdk Rust port
|
||||
reads `std::env::var("GOOGLE_API_KEY")` and the example backend calls
|
||||
`dotenv::dotenv()`). The alk stack replaces that with a vault (alkvault). But
|
||||
the vault only solves the storage problem; the flow problem — how decrypted
|
||||
material reaches the code that needs it without crossing the network —
|
||||
requires its own decision.
|
||||
|
||||
There is a separate, second axis that the current `OperationContext` conflates
|
||||
with the secret-flow problem. A handler has two orthogonal credential concerns:
|
||||
|
||||
- **Identity (inbound)**: who is calling me? Resolved per-request from
|
||||
`AuthContext` (TLS client cert, auth token). Already in `OperationContext`.
|
||||
- **Capabilities (outbound)**: what secrets can I use for outbound calls? This
|
||||
is the missing axis. A handler calling Google's API needs a decrypted Google
|
||||
API key. That is not the caller's identity — it is the handler's own outbound
|
||||
credential, provisioned by the assembly layer.
|
||||
|
||||
Mixing these two into one channel (e.g., stuffing secrets into
|
||||
`OperationContext.metadata: HashMap<String, Value>`) is a leak risk: metadata
|
||||
propagates through nested calls via `OperationEnv::invoke()`, so a secret
|
||||
placed there by one handler would flow to every downstream operation.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. The vault is assembly-layer only.**
|
||||
|
||||
The CLI binary (or an embedded assembly layer) is the sole component that
|
||||
talks to `VaultServiceHandle` directly. It unlocks the vault at startup,
|
||||
derives and decrypts what each handler needs, and constructs handlers with the
|
||||
results. No vault operation (`derive`, `decrypt`, `unlock`, `lock`) is
|
||||
registered as a call protocol operation. The vault has no ALPN. The master
|
||||
seed and derived private keys never enter the call protocol.
|
||||
|
||||
**2. Capabilities are the injection mechanism.**
|
||||
|
||||
A `Capabilities` type carries outbound secret material from the assembly layer
|
||||
into handlers. Capabilities are distinct from identity (inbound auth) and
|
||||
distinct from per-request metadata. The concrete shape of the `Capabilities`
|
||||
type is a two-way door — to be decided during implementation of the call
|
||||
protocol crate (now the alkcall crate). The one-way constraint is:
|
||||
|
||||
- Capabilities hold non-serializable, zeroized secret material. They cannot
|
||||
cross the call protocol wire even by accident — they are not
|
||||
`serde_json::Value`, they do not implement `Serialize`, and they do not
|
||||
appear in `EventEnvelope` payloads.
|
||||
- Capabilities are injected at handler construction (the common case: a static
|
||||
decrypted API key held for the handler's lifetime) or scoped per-request for
|
||||
internal-only flows. They are never populated from call protocol
|
||||
inputs.
|
||||
|
||||
**3. The call protocol carries no secret material.**
|
||||
|
||||
This is a wire-level constraint on the call protocol, not a handler-level
|
||||
convention. Secret material (private keys, API keys, mnemonics, decrypted
|
||||
credentials, raw tokens) must not appear in:
|
||||
|
||||
- `call.requested` payloads (inputs)
|
||||
- `call.responded` payloads (outputs)
|
||||
- `OperationContext.metadata`
|
||||
|
||||
The wire format does not enforce this — it carries `serde_json::Value` — so the
|
||||
constraint is architectural, enforced by the operation registry and by
|
||||
convention. Operations that need to share public key material (e.g., for
|
||||
identity verification) use a dedicated operation that returns only the public
|
||||
component, never the private key.
|
||||
|
||||
**4. Adapters take credential sources, not static tokens.**
|
||||
|
||||
The `from_openapi` and `from_jsonschema` adapter patterns (the
|
||||
`OperationAdapter` trait lives in the alkcall crate per alkcall ADR-033; the
|
||||
HTTP-backed adapter implementations live in alkhttp — see ADR-066) register
|
||||
HTTP-backed operations. The TypeScript `@alkdev/operations` `from_openapi`
|
||||
takes `config.auth: { token: "..." }` — a static string. The Rust adapters
|
||||
take a credential source wired to the assembly layer (a resolver, a capability
|
||||
handle, or an injected secret), not a literal token. This is the integration
|
||||
point where the vault feeds credentials into HTTP-backed operations: the
|
||||
assembly layer decrypts the token at startup and provides it to the adapter at
|
||||
registration time.
|
||||
|
||||
**5. Handlers that need per-request vault access receive a scoped capability.**
|
||||
|
||||
The common case (a static decrypted API key) is covered by construction-time
|
||||
injection. A narrower case — a handler that derives a child key for a specific
|
||||
operation (e.g., signing for GitHub authentication) — receives a
|
||||
scoped capability that can only derive at a restricted path set. This is still
|
||||
not a vault reference: it is a restricted handle that performs a specific
|
||||
derivation and returns the result to the handler, in-process. The handler
|
||||
never sees the master seed. Whether this scoped capability is a distinct type
|
||||
or modeled as a pre-derived key injected at construction is a two-way door
|
||||
left to the alkcall and agent crate specs.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The master seed and derived private keys never cross the network. The attack
|
||||
surface for the root of trust is local-only.
|
||||
- The `OperationContext` gains a clean second axis (capabilities) instead of
|
||||
overloading `metadata` for secrets, preventing accidental propagation of
|
||||
secret material through nested calls.
|
||||
- Handlers that need outbound credentials (the agent handler calling an LLM
|
||||
provider) receive them directly — no indirection through a `vault/derive`
|
||||
call, no latency, no failure mode where the vault must be reachable at call
|
||||
time.
|
||||
- The adapter contract (alknet OQ-15) gains a concrete shape: adapters take a
|
||||
credential source from the assembly layer, not a static token. This makes
|
||||
the `from_openapi` / `from_jsonschema` / `from_call` patterns safe by
|
||||
construction.
|
||||
- The model is structurally incompatible with the env-var / plaintext-config
|
||||
default. There is no `std::env::var("API_KEY")` path — the only way a handler
|
||||
gets a credential is through a capability, and the only way a capability is
|
||||
populated is through the assembly layer from the vault.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- The assembly layer (CLI binary) has more construction-time responsibility: it
|
||||
must know which handlers need which credentials and wire them. This is
|
||||
expected — the CLI assembles everything (alknet ADR-008).
|
||||
- Adding a new handler that needs a new credential requires updating the
|
||||
assembly layer, not just registering an operation. This is a feature, not a
|
||||
bug: it forces an explicit decision about what secret material a handler
|
||||
needs.
|
||||
- Remote vault administration (unlock a running node's vault over the network)
|
||||
is not supported by this decision. If that capability is needed in the
|
||||
future, it would require a separate, heavily restricted mechanism (admin
|
||||
scope, mTLS-only, never expose the mnemonic over an unauthenticated channel)
|
||||
and its own ADR. This decision does not close that door; it simply does not
|
||||
open it.
|
||||
- The `Capabilities` type shape is not fully specified here. The one-way
|
||||
constraint (non-serializable, zeroized, injection-only) is fixed; the
|
||||
concrete API is a two-way door for the call protocol spec.
|
||||
|
||||
## Assumptions
|
||||
|
||||
These are the load-bearing assumptions. If any of them breaks, the decision
|
||||
should be revisited:
|
||||
|
||||
1. **Handlers need credentials at construction time or at call time, not
|
||||
dynamically discovered at call time.** If a handler needs to derive a key
|
||||
at an unpredictable path determined by call input, the scoped-capability
|
||||
model still covers it (the handler holds a scoped vault access), but the
|
||||
surface area is larger. The assumption is that this case is rare.
|
||||
2. **The call protocol's threat model excludes the assembly layer.** The CLI
|
||||
binary is trusted to hold the vault handle and inject capabilities. If the
|
||||
assembly layer is compromised, all handlers' capabilities are compromised.
|
||||
This is the same trust boundary as alknet ADR-008.
|
||||
3. **No legitimate use case requires returning a private key over the wire.**
|
||||
Public key sharing (identity verification, encryption to a recipient) is
|
||||
the only cross-node key material flow. If a use case for returning a
|
||||
private key emerges (e.g., a key-escrow service), it needs its own ADR and a
|
||||
very different threat model.
|
||||
4. **Adapters are registered at startup, not at call time.** The credential
|
||||
source is wired to the adapter when the operation is registered, not when
|
||||
the operation is invoked. This is consistent with alknet OQ-04 (static
|
||||
registration at startup).
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-003: Crate decomposition (alknet-vault was standalone; now
|
||||
extracted as alkvault) — [ported here](003-crate-decomposition.md)
|
||||
- alkcall ADR-013: irpc as call protocol foundation (alknet ADR-005;
|
||||
superseded — see alkcall ADR-014, irpc was never integrated)
|
||||
- alknet ADR-008: Vault integration point (capability source — this ADR
|
||||
specifies the mechanism that ADR-008 described in prose; alknet mono-repo
|
||||
ADR — the vault is an alkvault concern)
|
||||
- alknet ADR-009: One-way door decision framework (now alkcall ADR-032)
|
||||
- alkcall ADR-033: Rust as canonical implementation language (alknet ADR-013)
|
||||
- alknet OQ-15: Call protocol client and adapter contract (this ADR constrains
|
||||
the adapter contract: adapters take credential sources, not static tokens;
|
||||
resolved by alkcall ADR-022, ported here as
|
||||
[ADR-017](017-call-protocol-client-and-adapter-contract.md))
|
||||
- alknet OQ-16: Safe vault operations for call protocol exposure (resolved by
|
||||
this ADR: none, for now)
|
||||
- alkvault crate (extracted from the alknet mono-repo's `alknet-vault`)
|
||||
|
||||
## Port notes
|
||||
|
||||
- Renames: "alknet-http" → alkhttp; "alknet-core"/"alknet-call" → alkcall
|
||||
(alkcall 0.1.1, which merged the old alknet-core and alknet-call and vendors
|
||||
the core types); "alknet-vault" → alkvault; "the `alknet` crate" (CLI
|
||||
binary) → "the CLI binary (or an embedded assembly layer)" — the CLI crate
|
||||
name is a consumer concern, not an alkhttp one.
|
||||
- "over QUIC" → "over the wire" (§Context, `vault/unlock` bullet): the call
|
||||
protocol is transport-agnostic (alkcall ADR-007), so the constraint is
|
||||
transport-independent.
|
||||
- §4 correction: the original said the adapter patterns are "defined in Rust
|
||||
in alknet-call per ADR-013". The `OperationAdapter` trait lives in the
|
||||
alkcall crate (alkcall ADR-033, alkcall ADR-022 §5); the HTTP-backed adapter
|
||||
implementations live in alkhttp (alkcall ADR-027 / this crate's ADR-066).
|
||||
The original predates ADR-066's location correction.
|
||||
- References to the alknet mono-repo's vault ADRs (ADR-008) and OQs (OQ-04,
|
||||
OQ-15, OQ-16) are annotated as alknet-record citations; the alkcall crate
|
||||
has its own OQ numbering. OQ-15's resolution (the adapter contract) is
|
||||
alkcall ADR-022, ported to this crate as ADR-017.
|
||||
- alknet ADR-003 is ported to this crate under the same number and is linked;
|
||||
alknet ADR-005/008/009/013 are cited textually with their alkcall mappings
|
||||
where one exists (alkcall ADR-032 for the one-way-door framework, alkcall
|
||||
ADR-033 for Rust-canonical).
|
||||
- No decision content changed — the vault-is-assembly-layer-only rule, the
|
||||
Capabilities injection mechanism, the no-secret-material-on-the-wire
|
||||
constraint, and the credential-source adapter contract are verbatim from
|
||||
the alknet ADR.
|
||||
Reference in New Issue
Block a user