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.
10 KiB
ADR-031: CredentialStore Repo Trait
Status
Accepted (establishes the second repo-trait in core, alongside
IdentityProvider; resolves the credential-persistence dimension of
OQ-34)
Context
alknet-http's from_openapi / from_mcp handlers need provider
credentials (API keys, OAuth tokens) to call outbound services. ADR-014
established the no-env-vars invariant: credentials come from
Capabilities, populated by the assembly layer from the vault at startup.
The vault (ADR-018/019/020/025/026) handles encryption/decryption; the
master seed and derived private keys never cross the network.
What's missing is the persistence layer for the encrypted credential
blobs. Today the in-memory Capabilities path works for the
vault-at-startup deployment (the assembly layer decrypts everything the
handlers need at boot, injects into Capabilities), but there is no
shared, trait-bound abstraction for where the encrypted blobs live before
the assembly layer decrypts them, and no way for a runtime process to
put/get/delete encrypted credentials without re-implementing the
storage shape in every consumer.
The research at docs/research/alknet-storage-strategy/findings.md §4
identified this as the second application of the repo/adapter pattern (the
first being IdentityProvider for peer identity). The vault encrypts; a
CredentialStore persists the EncryptedData blob; the assembly layer
loads them into Capabilities at registration time. The trait boundary
that matters for cross-crate sharing is the store trait, not the storage
backend — exactly mirroring IdentityProvider.
The kepal reference (/workspace/keypal) demonstrates the same pattern in
TypeScript: a Storage interface with adapters for Redis, Drizzle, Prisma,
Kysely, Convex, and in-memory. The core logic is backend-agnostic; storage
is a trait; the consumer picks the adapter at wiring time. The alknet
equivalent: core defines the repo trait, the default in-memory adapter
lives alongside it, and a future persistence adapter is a separate crate
(ADR-033).
Decision
1. Add CredentialStore trait to alknet-core
pub trait CredentialStore: Send + Sync {
fn get(&self, provider: &str) -> Option<EncryptedData>;
fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), CredentialStoreError>;
fn delete(&self, provider: &str) -> Result<(), CredentialStoreError>;
}
provider: &str— the provider identifier ("openai","anthropic","github", etc.). The key the assembly layer uses to look up a credential when populatingCapabilities.EncryptedData— the vault's encrypted-blob type (ADR-020, defined inalknet-vault). The store persists the blob as-is; it does not decrypt. Decryption is the vault's job (ADR-025, local-only by construction).CredentialStoreError— a crate-level error enum for store failures (backend unreachable, serialization, etc.).#[non_exhaustive]so adapter crates can extend without breaking match arms.
The trait returns Option<EncryptedData> from get (not Result): a
missing credential is the common case (the provider isn't configured),
not an error. put and delete are mutations and return Result since
the backend may be unwritable (a read-only deployment, a corrupted store,
etc.).
2. Add InMemoryCredentialStore default adapter to alknet-core
pub struct InMemoryCredentialStore {
entries: RwLock<HashMap<String, EncryptedData>>,
}
impl InMemoryCredentialStore {
pub fn new() -> Self;
pub fn with_entries(entries: HashMap<String, EncryptedData>) -> Self;
}
impl CredentialStore for InMemoryCredentialStore { ... }
The default adapter covers tests and config-loaded deployments where
credentials are decrypted from the vault at startup and held in memory for
the process lifetime. This is the same posture as
ConfigIdentityProvider — no persistence, no backend dependency, no env
vars. The assembly layer constructs it from vault-decrypted entries at
boot.
3. EncryptedData re-export shape
The store trait references EncryptedData, which is defined in
alknet-vault. To keep alknet-core lean (no vault dependency — ADR-003
keeps the vault standalone with zero alknet-crate dependencies), the
trait's EncryptedData parameter is a core-owned serializable type:
the vault produces it; the store persists it as a serializable blob; the
vault consumes it back. The core trait carries the wire shape without a
vault dependency.
The exact shape of EncryptedData in core is a thin serializable struct
mirroring the vault's type: { key_version, salt, iv, data } (the fields
the vault's EncryptedData carries, per ADR-020 and
crates/alknet-vault/src/encryption.rs). The salt field is kept for
wire-format compatibility with the TS predecessor (OQ-20) — a core mirror
that omitted it could not round-trip the vault's EncryptedData. This is a
one-way door — it pins the credential-blob wire shape — and it's
intentionally minimal (the vault's HD-derivation path is the vault's
concern, ADR-020). ADR-020 already defines this shape; this ADR's
commitment is that the store trait carries it as a serializable value type,
not a vault-bound reference.
4. No list method
The trait is get / put / delete — no list. The research (§11 OQ-3)
flagged list as a two-way-door remainder: a management UI or a startup-
enumeration use case might want to list all stored providers, but no
current consumer needs it. Adding list is non-breaking (a new method
with a default-impl, or a list_providers(&self) -> Vec<String> that
returns vec![] from the in-memory adapter until overridden).
Consequences
Positive:
- A second repo trait in core establishes the pattern concretely:
IdentityProviderfor identity resolution,CredentialStorefor encrypted-credential persistence. Both follow the same shape (core trait- in-memory default; persistence adapters additive in separate crates, ADR-033).
- The vault stays local-only by construction (ADR-025). The store
persists
EncryptedDatablobs; the vault decrypts them. The store never sees plaintext credentials, never sees the master seed, never holds derived keys. The encryption boundary is preserved. - The no-env-vars invariant (ADR-014) gets a persistence-layer
counterpart: encrypted credentials persist in a
CredentialStore, the assembly layer loads them intoCapabilitiesat registration time, the handlers read fromCapabilitiesper-request. Nostd::env::varpath exists at any layer. alknet-http'sfrom_openapi/from_mcphandlers consume the trait viaCapabilities(the assembly layer wires theCredentialStore→Capabilitiesmapping at registration). The handlers don't know whether the credential came from an in-memory map or a SQLite file.
Negative:
- alknet-core gains a second trait and a default adapter. The dependency surface grows by one trait + one struct + one error enum — small, but non-zero. The trade is that downstream crates (alknet-http, future credential-management UIs) get a shared abstraction instead of each rolling their own store shape.
- The
EncryptedDatatype is re-stated in core (a thin serializable shape mirroring the vault's type). If the vault'sEncryptedDatashape changes (a new key version, an additional field), the core shape must be kept in sync. The shape is small and stable (ADR-020 locked it), so the sync cost is low. - A future persistence adapter (
alknet-credential-store-sqliteor similar) is additive and not specified here. The trait shape is the one-way door; the adapter is a two-way door (ADR-033). Concrete adapter shapes are deferred for exploration per the project's note that the repo pattern is a tool to reach for, not a one-size-fits-all mold.
Assumptions
-
The vault remains the sole encryption boundary.
CredentialStorepersistsEncryptedDatablobs and never decrypts. Decryption is the vault's job, local-only (ADR-025). This ADR does not introduce a remote decryption path. -
provider: &stris the key. Credentials are keyed by provider name ("openai","anthropic", etc.). Multi-credential-per-provider (e.g., separate keys for org-A vs org-B under the same provider) is not in the trait shape; if needed, an additiveget_scoped(provider, scope)method is the extension path — not a signature change to the existingget(which is a one-way-door break on the trait). -
No
listmethod. The trait isget/put/delete. Addinglistis non-breaking (a default-impl method). See "Nolistmethod" above. -
Adapter crates that persist credentials are additive and not specified here. ADR-033 establishes the pattern; the concrete adapter shapes are deferred for exploration. This ADR's commitment is to the trait shape + the in-memory default, not to any specific backend.
-
EncryptedDatain core is a thin serializable mirror of the vault's type. The vault owns the encryption logic and the HD-derivation path (ADR-020); core carries only the wire shape. This keeps the vault standalone (ADR-018) while letting the store trait reference a concrete type.
References
- ADR-014: Secret Material Flow and Capability Injection (the no-env-vars invariant this trait supports)
- ADR-018: Vault as Standalone Crate (the vault has zero alknet-crate
dependencies; core's
EncryptedDatais a thin mirror, not a vault reference) - ADR-019: Vault Assembly-Layer-Only Access (the assembly layer bridges
vault →
CredentialStore→Capabilities) - ADR-020: HD Derivation for Encryption Keys (the
EncryptedDatashape) - ADR-025: Vault Local-Only Dispatch (the store never decrypts; the vault is the sole decryption boundary)
- ADR-033: Storage Boundary and Repo/Adapter Pattern (the overarching pattern this ADR follows)
- OQ-34: Persistent Peer Registry (resolved by this ADR + ADR-030 + ADR-033
— the storage boundary is
config + in-memory adapternow, persistence adapters additive) docs/research/alknet-storage-strategy/findings.md§4 (theCredentialStoretrait and adapter pattern)/workspace/keypal— TypeScript repo-pattern reference (Storage interface + adapters; the pattern alknet'sCredentialStorefollows)