Commits the concrete adapter shape deferred by ADR-033: read-sync / write-async split with honker NOTIFY/LISTEN for no-restart cache invalidation, against SQLite, in a separate alknet-store-sqlite crate. Two constraints drive the design: (1) the hot-path read trait (IdentityProvider::resolve_from_fingerprint, CredentialStore::get) is sync — called in the accept loop, no .await — so a SQLite-backed adapter must cache in memory and serve sync reads from the cache; (2) auth changes must take effect without a restart (an early issue the project already fixed for ConfigIdentityProvider via ArcSwap config reload). honker's SQLite NOTIFY/LISTEN (single-digit-ms wake, no polling) is the cache-invalidation mechanism that makes both hold: write commits to SQLite + emits NOTIFY, the running process's LISTEN wakes, the in-memory index reloads and atomically swaps, the next read sees the new state. Same ArcSwap-reload pattern as config, generalized from 'config file is source of truth' to 'SQLite is source of truth, honker signals when it changed.' New async IdentityStore write trait (put_peer / update_peer / remove_peer) extends the sync IdentityProvider read trait for peer mutations. ConfigIdentityProvider does NOT implement it (config reload is its write path — a posture enforced by the absence of a backend, not a type-system constraint); SqliteIdentityProvider implements both. CredentialStore::put/delete refined to async (within ADR-031's one-way door — the contract was get/put/delete keyed by provider persisting EncryptedData never decrypting; sync-vs-async was unspecified). CredentialStoreError renamed to shared StoreError covering both traits. alknet-store-sqlite is one crate implementing both IdentityStore and CredentialStore with shared SQLite connection + honker LISTEN infra (splitting later is a two-way door). Schema shape committed (one row per PeerEntry with JSON columns for fingerprints/scopes/resources; one row per EncryptedData blob keyed by provider); exact DDL is an implementation-detail two-way door in the adapter crate. The keypal adapter-factory pattern is intentionally not ported to Rust (runtime column-mapping is a TS affordance; in Rust each adapter is a concrete type, cross-cutting concerns are a shared helper module). Amends ADR-031 (put/delete async refinement, StoreError rename), ADR-033 (concrete adapter shape now specified, two-crate framing collapsed to one), ADR-034 (OQ-36 now resolved), auth.md (IdentityStore section, cache-invalidation summary, OQ-36 reference), config.md (two write paths note), and the OQ-36/OQ-34 entries in open-questions.md. Review fixed 4 criticals (error-type name divergence, duplicate IdentityProvider sketch, upsert/Duplicate ambiguity, 'shape unchanged' contradiction), 7 warnings, 5 suggestions.
12 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). Refined by ADR-035:
put/delete are async (the sketch below showed them sync; ADR-035
refines this within the one-way door this ADR committed — "there IS a
CredentialStore trait with get/put/delete keyed by provider,
persisting EncryptedData, never decrypting" stands). get stays
sync (cached read). See ADR-035 §3.
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>;
// put/delete refined to async by ADR-035 (within the one-way door
// this ADR committed). The sketch below showed them sync; the
// refinement is that a SQLite-backed adapter cannot do a sync write
// without blocking, and the in-memory default trivially satisfies
// an async trait (no .await points). get stays sync (cached read).
async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError>;
async fn delete(&self, provider: &str) -> Result<(), StoreError>;
}
The error type was sketched here as CredentialStoreError;
ADR-035 §7 renames it
to StoreError — a single shared type for both the
CredentialStore and IdentityStore traits, so both adapters and all
consumers reference one error type. The rename is within this ADR's
one-way door (the contract was "a #[non_exhaustive] error enum for
store failures"; the name was unspecified detail).
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).StoreError— a crate-level error enum for store failures (backend unreachable, serialization, etc.).#[non_exhaustive]so adapter crates can extend without breaking match arms. (Sketched here asCredentialStoreError; renamed toStoreErrorby ADR-035 §7 — a single shared type for bothCredentialStoreandIdentityStore.)
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)
- ADR-035: Concrete Persistence Adapter Shapes (refines this ADR's
put/deleteto async; commits thealknet-store-sqliteadapter design and the honker cache-invalidation mechanism) - 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)