docs(arch): ADR-035 — concrete persistence adapter shapes, resolve OQ-36

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.
This commit is contained in:
2026-06-28 11:10:31 +00:00
parent 6cc8715ccf
commit 0de2cebb1d
10 changed files with 779 additions and 61 deletions

View File

@@ -14,7 +14,7 @@ The storage and auth strategy research (`docs/research/alknet-storage-strategy/f
- **ADR-030** (PeerEntry and Identity.id decoupling): `authorized_fingerprints: HashSet<String>``peers: Vec<PeerEntry>`; `Identity.id` becomes the stable `peer_id` (not the fingerprint); key rotation changes the fingerprint, not the identity. Supersedes ADR-029's v1 UUID source (the one-way door — `PeerId` is logical, not crypto — is preserved; the source changes from UUID to `Identity.id` from `PeerEntry`). Resolves OQ-33 and the storage-boundary half of OQ-34.
- **ADR-031** (CredentialStore repo trait): the second repo trait in core (alongside `IdentityProvider`), with `InMemoryCredentialStore` default adapter. Establishes the credential-persistence abstraction.
- **ADR-032** (Forwarded-for identity): `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it. Wire-format one-way door, included with 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; the assembly layer wires the adapter. Resolves OQ-34's storage-boundary question. Concrete adapter shapes are deferred for exploration (OQ-36).
- **ADR-033** (Storage boundary and repo/adapter pattern): core defines repo traits + in-memory defaults; persistence adapters are separate crates; the assembly layer wires the adapter. Resolves OQ-34's storage-boundary question. Concrete adapter shapes now committed by ADR-035 (was OQ-36).
The alknet-call crate is **implemented and reviewed** — both the server-side core and the client/adapter surface (207 lib + 2 integration tests passing). The alknet-core and alknet-call crate specs are in draft; the alknet-vault crate specs are stable.
@@ -79,6 +79,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity (Metadata, Not Authority) | Accepted |
| [033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Accepted |
| [034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Outgoing-Only X.509 and the Three Peer Roles | Accepted |
| [035](decisions/035-concrete-persistence-adapter-shapes.md) | Concrete Persistence Adapter Shapes — Read/Write Split, honker+SQLite | Accepted |
## Open Questions
@@ -124,7 +125,7 @@ See [open-questions.md](open-questions.md) for the full tracker.
**Open (feature extensions, not blocking):**
- **OQ-32**: Multi-hop federation — the one-hop model is the architectural commitment; multi-hop is a feature extension that doesn't break downstream
- **OQ-36**: Concrete persistence adapter shapes — the repo/adapter pattern is committed (ADR-033); in-memory adapters ship with core; persistence adapters (SQLite, etc.) are deferred for exploration
- **OQ-36**: ~~Concrete persistence adapter shapes~~**resolved by ADR-035** (read-sync / write-async / honker-NOTIFY cache invalidation; `alknet-store-sqlite` crate; `IdentityStore` write trait; `CredentialStore::put`/`delete` async)
- **OQ-37**: ~~X.509 outgoing-only case~~**resolved by ADR-034** (three remote roles named: public X.509 endpoint, transport relay, hub; `PeerEntry` asymmetry is correct; client-side verifier selection by `PeerEntry` presence)
**Deferred (not active):**

View File

@@ -706,10 +706,13 @@ See [open-questions.md](../../open-questions.md) for full details.
`PeerEntry` supports multiple credential paths (fingerprints +
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
See OQ-35 in open-questions.md.
- **OQ-36** (open, deferred for exploration): Concrete persistence adapter
shapes — the repo/adapter pattern is committed (ADR-033); the in-memory
adapters ship with core; the persistence adapter shapes (SQLite, etc.)
are deferred for exploration. See OQ-36 in open-questions.md.
- **OQ-36** (resolved by ADR-035): Concrete persistence adapter shapes —
read-sync / write-async split (`IdentityStore` async write trait
extends the sync `IdentityProvider` read trait); SQLite adapter caches
in memory and uses honker NOTIFY/LISTEN for no-restart cache
invalidation; `alknet-store-sqlite` crate implements both
`IdentityStore` and `CredentialStore`. See ADR-035 and OQ-36 in
open-questions.md.
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three
remote roles named (public X.509 endpoint, transport relay, hub).
`PeerEntry` asymmetry is correct: a pure-client connection to a public

View File

@@ -44,7 +44,7 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
| OQ-33 | PeerId — logical id vs crypto identity | resolved by ADR-030 | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable across key rotation) |
| OQ-34 | Persistent peer registry (storage boundary) | resolved by ADR-030+031+033 | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| OQ-35 | ~~API key asymmetry~~ | dissolved | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
| OQ-36 | Concrete persistence adapter shapes | open (deferred for exploration) | The repo/adapter pattern is committed (ADR-033); in-memory adapters ship with core; persistence adapters deferred |
| OQ-36 | Concrete persistence adapter shapes | resolved by ADR-035 | Read-sync / write-async split (`IdentityStore`); SQLite adapter caches in memory, honker NOTIFY for no-restart cache invalidation; `alknet-store-sqlite` crate |
| OQ-37 | X.509 outgoing-only case | resolved by ADR-034 | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; client-side verifier by `PeerEntry` presence (CA vs fingerprint pin) |
## Key Design Principles

View File

@@ -237,6 +237,63 @@ How it resolves:
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) for the `PeerEntry` model, the multi-credential resolution, and the fingerprint normalization rationale.
## IdentityStore (write trait, ADR-035)
`IdentityProvider` (defined above) is **read-only** and stays
read-only — it is the hot-path trait called on every incoming
connection (sync, no `.await`). Peer **mutations** (add/update/remove
a `PeerEntry`) go through a separate async write trait that extends
`IdentityProvider`:
```rust
/// Write trait — management path, async (ADR-035). ConfigIdentityProvider
/// does NOT implement this (config reload is its write path — see below).
/// SqliteIdentityProvider does: writes hit SQLite, emit honker NOTIFY,
/// and the local LISTEN refreshes the in-memory read index.
#[async_trait]
pub trait IdentityStore: IdentityProvider {
async fn put_peer(&self, peer: &PeerEntry) -> Result<(), StoreError>;
async fn update_peer(&self, peer_id: &str, peer: &PeerEntry) -> Result<(), StoreError>;
async fn remove_peer(&self, peer_id: &str) -> Result<(), StoreError>;
}
```
`IdentityStore: IdentityProvider` is a supertrait — any type that
implements `IdentityProvider` *could* implement `IdentityStore`, but
not implementing it is a design posture, not a type-system constraint.
`ConfigIdentityProvider` deliberately does **not** implement
`IdentityStore`: it holds no SQLite handle and no backend, and its
write path is config reload (`ConfigReloadHandle::reload`), not a
method call. This preserves the config-is-source-of-truth model.
Implementing `IdentityStore` for `ConfigIdentityProvider` "for
symmetry" would violate that model — the constraint is the absence of
a backend, not a trait bound. A deployment that wants method-call
peer management (CLI `alknet peer add`, an admin call-protocol
operation) wires the SQLite adapter (`SqliteIdentityProvider`), which
implements both `IdentityProvider` (sync reads from cache) and
`IdentityStore` (async writes to SQLite + honker NOTIFY).
### Cache invalidation without restart (ADR-035)
The no-restart-on-auth-change property — already established for
`ConfigIdentityProvider` via `ArcSwap` config reload — is preserved by
the SQLite adapter via honker's SQLite `NOTIFY`/`LISTEN`:
1. A write (`put_peer` / `update_peer` / `remove_peer`) commits to
SQLite and emits `NOTIFY 'peers_changed'`.
2. The running alknet process's `LISTEN` wakes in single-digit ms
(honker watches `PRAGMA data_version` — no polling, no daemon).
3. The process reloads its in-memory index from `SELECT * FROM peers`
and atomically swaps it (`ArcSwap`, same pattern as config reload).
4. The next `resolve_from_fingerprint` call reads the new index. Live
resolution changes, no restart.
See [ADR-035](../../decisions/035-concrete-persistence-adapter-shapes.md)
for the full adapter design (the `alknet-store-sqlite` crate, the
schema shape, the `StoreError` type, the writer's-own-process cache
coherence details, and why honker is a hard dependency of the SQLite
adapter rather than an option).
### Resource-scoped ACLs
`Identity.resources` is populated on two paths:
@@ -387,12 +444,14 @@ The endpoint's `AlknetEndpoint` also holds `Arc<dyn IdentityProvider>` for endpo
| CredentialStore repo trait | [ADR-031](../../decisions/031-credentialstore-repo-trait.md) | Second repo trait in core (alongside `IdentityProvider`); `InMemoryCredentialStore` default adapter |
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines traits + in-memory defaults; persistence adapters are separate crates |
| Three remote roles and outgoing-only X.509 | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint / transport relay / hub; `PeerEntry` asymmetry (pure-client X.509 is not a peer); client-side verifier by `PeerEntry` presence |
| Concrete persistence adapter shapes | [ADR-035](../../decisions/035-concrete-persistence-adapter-shapes.md) | Read-sync / write-async split (`IdentityStore` async write trait); SQLite adapter caches in memory, honker NOTIFY for no-restart cache invalidation; `StoreError` type |
## Open Questions
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn client-auth (present Ed25519 key as raw public key client cert); key-type-aware server cert verification (raw key = fingerprint match, X.509 = CA verification); fingerprint normalization (`ed25519:` across quinn/iroh). See OQ-29 in open-questions.md.
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong; `PeerEntry` supports multiple credential paths (fingerprints + auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity. See OQ-35 in open-questions.md.
- **OQ-37** (resolved): X.509 outgoing-only case — three remote roles named (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry is correct (pure-client X.509 connections are not in the peer graph on the client side); client-side verifier selection by `PeerEntry` presence (CA verification for unknown X.509, fingerprint pin for known peers). See ADR-034 and OQ-37 in open-questions.md.
- **OQ-36** (resolved): Concrete persistence adapter shapes — read-sync / write-async split (`IdentityStore` async write trait extends the sync `IdentityProvider` read trait); SQLite adapter caches in memory and uses honker NOTIFY/LISTEN for no-restart cache invalidation; `alknet-store-sqlite` crate implements both `IdentityStore` and `CredentialStore`. See ADR-035 and OQ-36 in open-questions.md.
## Security Constraints

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-27
last_updated: 2026-06-28
---
# Configuration
@@ -245,6 +245,19 @@ field on `AuthPolicy` is non-breaking for existing config files that don't
use it). alknet-ssh will define `CertAuthorityEntry` with the necessary
fields (public key, principals, options).
**Two write paths for `AuthPolicy.peers`** (ADR-035): the
**config-backed** path (`ConfigReloadHandle::reload`, used by
`ConfigIdentityProvider` — edit the config file, signal reload, live
resolution changes via `ArcSwap`) and the **method-call** path
(`IdentityStore::put_peer` / `update_peer` / `remove_peer`, used by
`SqliteIdentityProvider` — a CLI or admin op writes to SQLite, honker
NOTIFY refreshes the in-memory index, live resolution changes). Both
produce the same `PeerEntry` shape; the difference is the source of
truth (config file vs. SQLite) and the write mechanism. A deployment
picks one by wiring the corresponding adapter at the assembly layer.
The `IdentityStore` trait is defined in [auth.md](auth.md#identitystore-write-trait-adr-035);
the adapter design is in [ADR-035](../../decisions/035-concrete-persistence-adapter-shapes.md).
This replaces the reference implementation's `AuthPolicy` which depended on `russh::keys::PublicKey`. The new version stores fingerprints as strings (in `PeerEntry.fingerprint`), not russh types. This removes the russh dependency from alknet-core.
### ApiKeyEntry
@@ -349,3 +362,4 @@ Simplified from the reference implementation. Removes proxy-specific errors (now
| No ListenerConfig | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | Single endpoint, ALPN replaces multiple listener types |
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `authorized_fingerprints: HashSet<String>``peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable), not fingerprint |
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; `AuthPolicy.peers` is the config model for the in-memory `ConfigIdentityProvider` adapter; persistence adapters are separate crates |
| Concrete persistence adapter shapes | [ADR-035](../../decisions/035-concrete-persistence-adapter-shapes.md) | `AuthPolicy.peers` is the **config-backed** write surface (reload via `ConfigReloadHandle`); the SQLite adapter's `IdentityStore` trait is the **method-call** write surface for deployments that want `alknet peer add`-style management without config edits. Both produce the same `PeerEntry` shape; the difference is the write path. |

View File

@@ -4,7 +4,12 @@
Accepted (establishes the second repo-trait in core, alongside
`IdentityProvider`; resolves the credential-persistence dimension of
OQ-34)
OQ-34). **Refined by [ADR-035](035-concrete-persistence-adapter-shapes.md)**:
`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
@@ -47,20 +52,35 @@ lives alongside it, and a future persistence adapter is a separate crate
```rust
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>;
// 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](035-concrete-persistence-adapter-shapes.md) §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 populating `Capabilities`.
- `EncryptedData` — the vault's encrypted-blob type (ADR-020, defined in
`alknet-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.
- `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 as
`CredentialStoreError`; renamed to `StoreError` by ADR-035 §7 — a
single shared type for both `CredentialStore` and `IdentityStore`.)
The trait returns `Option<EncryptedData>` from `get` (not `Result`): a
missing credential is the common case (the provider isn't configured),
@@ -204,6 +224,9 @@ returns `vec![]` from the in-memory adapter until overridden).
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`/`delete` to async; commits the `alknet-store-sqlite` adapter
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 adapter` now, persistence
adapters additive)

View File

@@ -3,7 +3,10 @@
## Status
Accepted (resolves the storage-boundary dimension of OQ-34; establishes the
pattern that ADR-030 and ADR-031 follow)
pattern that ADR-030 and ADR-031 follow). **The concrete adapter shapes
deferred by §"What this does NOT do" are now committed by
[ADR-035](035-concrete-persistence-adapter-shapes.md)** (read/write split,
honker+SQLite, `alknet-store-sqlite` crate).
## Context
@@ -56,10 +59,10 @@ pub trait IdentityProvider: Send + Sync + 'static { // ADR-004
}
pub struct ConfigIdentityProvider { ... } // in-memory default (ADR-030)
pub trait CredentialStore: Send + Sync { // ADR-031
pub trait CredentialStore: Send + Sync { // ADR-031, refined by ADR-035
fn get(&self, provider: &str) -> Option<EncryptedData>;
fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), CredentialStoreError>;
fn delete(&self, provider: &str) -> Result<(), CredentialStoreError>;
async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError>;
async fn delete(&self, provider: &str) -> Result<(), StoreError>;
}
pub struct InMemoryCredentialStore { ... } // in-memory default (ADR-031)
```
@@ -74,6 +77,11 @@ no persistence backend dependency.
A persistence adapter (e.g., `alknet-peer-store-sqlite`,
`alknet-credential-store-sqlite`) is a **separate crate** that implements a
core repo trait against a specific backend. The adapter:
(**Update:** [ADR-035](035-concrete-persistence-adapter-shapes.md)
collapses these two into a single `alknet-store-sqlite` crate
implementing both `IdentityStore` and `CredentialStore` with shared
SQLite connection + honker LISTEN infra; splitting later is a two-way
door.)
- Depends on alknet-core (for the trait and the types it implements
against).
@@ -129,6 +137,13 @@ and passes `Arc<dyn IdentityProvider>` to every handler that needs it.
note is that the repo pattern is a tool to reach for when a storage
concern is concrete, not a one-size-fits-all mold to apply
speculatively. The pattern is committed; the adapters are not.
**Update**: [ADR-035](035-concrete-persistence-adapter-shapes.md) now
commits the concrete adapter shape for the SQLite+honker backend
(read-sync / write-async split, `IdentityStore` write trait, honker
NOTIFY cache invalidation, `alknet-store-sqlite` crate). The deferral
above applied to *which* backend and *what* shape; ADR-035 resolves
both for the SQLite case. Other backends (Redis, Postgres, on-chain)
remain "not needed for current scope."
- **Does not change the no-DB posture of the core crates.** Core remains
DB-free in the sense that it has no backend dependency — only a trait
boundary. The in-memory adapter carries no persistence. The persistence
@@ -212,11 +227,15 @@ and passes `Arc<dyn IdentityProvider>` to every handler that needs it.
`ConfigIdentityProvider` in-memory default)
- ADR-031: CredentialStore Repo Trait (the second application —
`CredentialStore` trait + `InMemoryCredentialStore` default)
- ADR-035: Concrete Persistence Adapter Shapes (commits the concrete
adapter shape this ADR's §"What this does NOT do" deferred —
read/write split, honker+SQLite, `alknet-store-sqlite` crate)
- OQ-34: Persistent Peer Registry (resolved by this ADR — the storage
boundary is `core trait + in-memory default`, persistence adapters
additive)
- OQ-36: Concrete Adapter Shapes (tracked by this ADR — deferred for
exploration; the trait shapes are committed, the adapter shapes are not)
- OQ-36: Concrete Adapter Shapes (resolved by ADR-035 — the concrete
SQLite+honker adapter shape is committed; the trait shapes committed
by this ADR and ADR-030/031 are the one-way doors ADR-035 builds on)
- `docs/research/alknet-storage-strategy/findings.md` §3-4 (the
SQLite+honker foundation and the repo/adapter pattern)
- `/workspace/keypal` — TypeScript repo-pattern reference (the Storage

View File

@@ -368,7 +368,9 @@ It is noted here only to confirm it does not reopen OQ-37.
quinn/iroh); the `SHA256:<hex>` X.509 fingerprint format
- [ADR-033](033-storage-boundary-and-repo-adapter-pattern.md) — the
repo/adapter pattern that an on-chain `IdentityProvider` adapter
follows; OQ-36 (concrete adapter shapes deferred for exploration)
follows; [ADR-035](035-concrete-persistence-adapter-shapes.md) commits
the concrete SQLite adapter shape (the on-chain adapter would follow
the same trait + separate-crate pattern)
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) §7 —
`CallCredentials.remote_identity` (ADR-017 specified "expected
fingerprint or cert"; this ADR §2 extends its semantics so that
@@ -382,9 +384,10 @@ It is noted here only to confirm it does not reopen OQ-37.
that selects the verifier
- OQ-10 (deferred) — git adapter scope; the on-chain / gossip-synced
git-hosting hub use case in §6 is downstream of the git crate
- OQ-36 (open, deferred for exploration) — concrete persistence adapter
shapes; the on-chain `IdentityProvider` adapter in §6 follows this
pattern
- OQ-36 (resolved by ADR-035) — concrete persistence adapter shapes;
the on-chain `IdentityProvider` adapter in §6 follows the same
repo/adapter pattern (trait in core, adapter additive in a separate
crate)
- `docs/research/alknet-http/phase-0-findings.md` — DH-2 (h3 /
WebTransport deferred past v1); the WebTransport-relay-as-proxy
feature noted in this ADR's §5 belongs in that deferral bucket

View File

@@ -0,0 +1,571 @@
# ADR-035: Concrete Persistence Adapter Shapes — Read/Write Split, honker+SQLite
## Status
Accepted. Resolves OQ-36. Refines ADR-031 §1 (`put`/`delete` → async;
`CredentialStoreError``StoreError`). Commits the concrete adapter
shape deferred by ADR-033 §"What this does NOT do."
## Context
ADR-033 committed the repo/adapter pattern — core defines repo traits
+ in-memory defaults; persistence adapters are separate crates; the
assembly layer wires the adapter. ADR-030 (`PeerEntry` /
`ConfigIdentityProvider`) and ADR-031 (`CredentialStore` /
`InMemoryCredentialStore`) committed the two trait shapes and their
in-memory defaults. **OQ-36 left the concrete persistence adapter
shapes open**: table schemas, backend choice, indexing, caching, the
honker+SQLite design. This ADR resolves OQ-36 by committing the
concrete shape.
Two constraints drive the design:
### Constraint 1: The hot-path read trait is sync
`IdentityProvider::resolve_from_fingerprint` is called on **every
incoming QUIC connection** in the accept loop (and on every
`call.requested` for the per-request identity path). It is synchronous
`fn resolve_from_fingerprint(&self, fingerprint: &str) ->
Option<Identity>`, no `async`, no `.await`. This is a deliberate design
choice locked by ADR-004/011: the hot path can't block on a DB query,
and threading `.await` through the accept loop and every handler's
auth resolution would be a rewrite of the dispatch surface.
A SQLite-backed adapter cannot run a SQL query inside a sync call.
Therefore a persistence-backed `IdentityProvider` must hold an
**in-memory index** and serve sync reads from it. The question this
ADR answers: how does that index stay fresh when the DB changes,
without a restart?
### Constraint 2: Auth changes must take effect without a restart
The project explicitly fixed an early issue where changing auth
required restarting the server. `ConfigIdentityProvider` solves this
via `ArcSwap<DynamicConfig>` — a config reload atomically swaps the
config, and the next `resolve_from_fingerprint` call reads the new
state. Live resolution changes, no restart.
A persistence adapter needs the **same property**. A CLI admin tool
(`alknet peer add`), an admin call-protocol operation, or another
node writes a new `PeerEntry` to the SQLite DB. The running alknet
process's in-memory index must reflect that write **without a
restart** and **without polling** (polling re-introduces the staleness
window the `ArcSwap` pattern removed).
### The honker mechanism
[honker](https://github.com/nicholasgriffintn/honker) is a SQLite
extension + language bindings that adds Postgres-style `NOTIFY`/
`LISTEN` to SQLite, with single-digit-millisecond cross-process
wake-up and no polling. It works by watching `PRAGMA data_version`
(every 1ms, single-digit-µs read) and waking listeners on committed
updates.
This is **exactly the cache-invalidation mechanism** the
sync-hot-path + SQLite-backend combination needs:
1. A write hits SQLite (`INSERT`/`UPDATE`/`DELETE` on the `peers`
table) and commits.
2. The write emits a honker `NOTIFY` alongside the business write.
honker is designed so the notify is tied to the committed state —
a rollback drops the notify (no spurious wake for work that didn't
land). The honker docs describe the `PRAGMA data_version` watch as
waking on committed updates and ignoring rolled-back work, which is
the property this design relies on. **Corner case:** a process
crash *between* the SQLite commit and the local `LISTEN` wake
leaves the DB updated but the crashed process's index stale until
its next restart (it reloads from SQLite on boot, so it converges
— just not via the live-notify path). Other live processes still
wake normally. This is acceptable for the single-process-failure
assumption; a multi-process crash-durability guarantee would need
WAL + checkpoint engineering beyond this ADR's scope.
3. The running alknet process's `LISTEN` wakes in single-digit ms.
4. The process reloads its in-memory index from SQLite (one `SELECT
* FROM peers`) and atomically swaps it (same `ArcSwap` pattern as
`ConfigIdentityProvider`).
5. The next `resolve_from_fingerprint` call reads the new index. Live
resolution changes, no restart, no polling.
honker is therefore not "an optional backend choice" — it is the
mechanism that makes the sync-read + cached-index + no-restart
combination work. Without it, the persistence adapter either polls
(stale window) or requires a restart to pick up changes (the bug the
project already fixed once).
### The keypal reference
The keypal TypeScript library (`/workspace/keypal`) demonstrates the
repo pattern: a `Storage` interface with an in-memory default adapter
and backend adapters for Redis, Drizzle, Prisma, Kysely, Convex. The
core logic is backend-agnostic; storage is a trait; the consumer picks
the adapter at wiring time. The alknet adaptation follows the same
shape (core trait + in-memory default + separate adapter crates) but
diverges from keypal in three places, recorded here so a future reader
doesn't wonder why:
- **Two trait families, not one `Storage<T>`.** keypal stores one
kind of thing (API key records), so one trait fits. alknet has two
distinct aggregates — `PeerEntry` (identity + ACL, hot-path read on
every connection) and `EncryptedData` blobs (credentials, read once
at startup into `Capabilities`). Different shapes, different
read/write profiles, different hot-path criticality. ADR-033 §4
already committed to one trait per concern; this ADR keeps that.
- **Read/write trait split.** keypal's `Storage` is uniform (all
methods async). alknet's hot path is sync, so the read trait is sync
and the write trait is a separate async extension. keypal doesn't
face this because JS/TS has no sync-hot-path constraint.
- **No adapter factory.** keypal's `adapter-factory` is a runtime
generic over column mapping and type coercion — a TS/JS affordance
(dynamic objects, runtime schema introspection). In Rust, each
adapter is a concrete type implementing the trait; column mapping is
done at adapter build time with concrete types. The *intent*
("adapters only implement the backend-specific query, cross-cutting
concerns are shared") is achieved in Rust by a shared helper module
(e.g., `alknet-store-sqlite` has a `schema` module both adapters
use) and by the trait itself defining the contract. The factory
pattern is intentionally not ported.
## Decision
### 1. Read trait stays sync; persistence adapters cache in memory
`IdentityProvider` and `CredentialStore::get` are **sync** and
**unchanged**. A persistence-backed adapter serves sync reads from an
in-memory index (`HashMap<fingerprint, PeerEntry>` for identity;
`HashMap<String, EncryptedData>` for credentials), loaded from the
backend at construction and refreshed on honker `NOTIFY`. This is the
same `ArcSwap`-backed "load full state, atomically swap" pattern
`ConfigIdentityProvider` uses for config reload — generalized from
"config file is the source of truth" to "SQLite is the source of
truth, honker signals when it changed."
The in-memory index is a **full reload**, not a delta apply, on each
`NOTIFY`. Peer/credential counts are small (10s100s, per ADR-030
Assumption 4); a `SELECT *` + `HashMap` rebuild is cheap and avoids
the correctness hazards of incremental cache updates (missed deletes,
partial updates). This is the same posture as `ConfigIdentityProvider`
(reload the whole `DynamicConfig`, not a patch).
### 2. Add `IdentityStore` — the async write trait for peer management
`IdentityProvider` is read-only today and stays read-only. Peer
**mutations** (add/update/remove a `PeerEntry`) go through a new
async trait that extends `IdentityProvider`:
```rust
/// Read trait — hot path, sync, unchanged (ADR-004). ConfigIdentityProvider
/// and SqliteIdentityProvider both implement this. The SQLite adapter serves
/// from an in-memory index refreshed by honker LISTEN.
pub trait IdentityProvider: Send + Sync + 'static {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}
/// Write trait — management path, async. ConfigIdentityProvider does NOT
/// implement this (config reload is its write path). SqliteIdentityProvider
/// does: writes hit SQLite, emit honker NOTIFY, and the local LISTEN
/// refreshes the in-memory read index.
#[async_trait]
pub trait IdentityStore: IdentityProvider {
async fn put_peer(&self, peer: &PeerEntry) -> Result<(), StoreError>;
async fn update_peer(&self, peer_id: &str, peer: &PeerEntry) -> Result<(), StoreError>;
async fn remove_peer(&self, peer_id: &str) -> Result<(), StoreError>;
}
```
- `put_peer` — insert or replace a `PeerEntry` (upsert by `peer_id`).
- `update_peer` — update an existing `PeerEntry` (error if `peer_id`
not found; for upsert semantics use `put_peer`).
- `remove_peer` — delete a `PeerEntry` by `peer_id`.
Why a separate trait, not async methods on `IdentityProvider`:
- The hot-path read trait is consumed by the accept loop and every
handler — those call sites are sync and must not gain `.await`. If
`put_peer` were on `IdentityProvider`, every consumer would see the
async method even though only the management path calls it. A
separate `IdentityStore: IdentityProvider` supertrait keeps the read
surface lean and makes the write surface opt-in.
- `ConfigIdentityProvider` does **not** implement `IdentityStore`.
Its write path is config reload (`ConfigReloadHandle::reload`), not
a method call. This preserves the config-is-source-of-truth model
for the in-memory default while the SQLite adapter gains a method-
call write path.
**Cache coherence on the writer's own process:** when a `SqliteIdentityProvider::put_peer` commits, the write's own honker `NOTIFY` wakes the local `LISTEN` and the local index refreshes — the writer's own read index is consistent with the write without special handling. There is no "write-through to local cache" shortcut; the NOTIFY path is the single source of truth for index freshness, on the writer's process and on every other process listening to the same DB. This keeps one mechanism instead of two (write-through for local + NOTIFY for remote), which is simpler and avoids the local/remote divergence bug.
### 3. `CredentialStore` write methods become async
ADR-031 sketched `CredentialStore::put`/`delete` as sync. This ADR
**refines that sketch**: `get` stays sync (cached read, same as
`IdentityProvider`), `put`/`delete` become **async** (they hit the
backend). The refinement is within the one-way door ADR-031 committed
("there IS a `CredentialStore` trait with `get`/`put`/`delete` keyed
by provider, persisting `EncryptedData`, never decrypting") — that
contract stands; the sync-vs-async of the write methods was an
unspecified detail in the sketch, and ADR-033 §"What this does NOT
do" explicitly deferred concrete adapter shapes to this work.
```rust
pub trait CredentialStore: Send + Sync {
fn get(&self, provider: &str) -> Option<EncryptedData>;
async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError>;
async fn delete(&self, provider: &str) -> Result<(), StoreError>;
}
```
`InMemoryCredentialStore`'s `put`/`delete` are async with no `.await`
points (trivially satisfy an async trait) — no behavior change for the
in-memory default, just the signature. The SQLite adapter's
`put`/`delete` hit SQLite and emit honker `NOTIFY`, refreshing the
local and remote read caches.
`get` stays sync for the same reason `IdentityProvider` reads stay
sync: the credential load happens at startup into `Capabilities`
(ADR-031), and a cached sync read serves it. A runtime `get` (e.g., a
handler fetching a newly-stored credential without restart) hits the
in-memory index, which honker keeps fresh.
### 4. `alknet-store-sqlite` — the first concrete adapter crate
A single crate, `alknet-store-sqlite`, implementing **both**
`IdentityStore` and `CredentialStore` against SQLite + honker. Two
adapters in one crate is fine because they share:
- The SQLite connection pool.
- The honker `LISTEN` loop (one listener, multiple channels —
`peers_changed` and `credentials_changed`).
- The migration infrastructure (`CREATE TABLE IF NOT EXISTS` on
first open; the schema is small enough that a hand-rolled
idempotent bootstrap is simpler than pulling in a migration
framework — see §6).
Splitting into `alknet-peer-store-sqlite` +
`alknet-credential-store-sqlite` later is a two-way door (additive) if
a use case forces it (e.g., a deployment that wants peer persistence
but not credential persistence). The default is one crate, both
adapters, shared infra.
```rust
// alknet-store-sqlite — the concrete adapter
pub struct SqliteIdentityProvider {
// SQLite connection (writes) + honker listener handle
conn: Arc<SqliteConn>,
// In-memory read index, atomically swapped on honker NOTIFY
index: Arc<ArcSwap<PeerIndex>>,
}
impl IdentityProvider for SqliteIdentityProvider {
fn resolve_from_fingerprint(&self, fp: &str) -> Option<Identity> {
let idx = self.index.load();
idx.resolve_from_fingerprint(fp)
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
let idx = self.index.load();
idx.resolve_from_token(token)
}
}
#[async_trait]
impl IdentityStore for SqliteIdentityProvider {
async fn put_peer(&self, peer: &PeerEntry) -> Result<(), StoreError> {
// 1. INSERT/UPDATE peers row in SQLite (transactional)
// 2. NOTIFY 'peers_changed' (same transaction — atomic)
// 3. The local+remote LISTEN loops wake, reload the index
self.conn.put_peer(peer).await
}
// update_peer, remove_peer — same shape
}
pub struct SqliteCredentialStore {
conn: Arc<SqliteConn>, // shared with the identity adapter
index: Arc<ArcSwap<HashMap<String, EncryptedData>>>,
}
impl CredentialStore for SqliteCredentialStore {
fn get(&self, provider: &str) -> Option<EncryptedData> {
self.index.load().get(provider).cloned()
}
async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError> {
// INSERT/UPDATE credentials row + NOTIFY 'credentials_changed'
self.conn.put_credential(provider, data).await
}
async fn delete(&self, provider: &str) -> Result<(), StoreError> {
// DELETE credentials row + NOTIFY 'credentials_changed'
self.conn.delete_credential(provider).await
}
}
```
The `PeerIndex` is the in-memory structure that makes
`resolve_from_fingerprint` and `resolve_from_token` O(1) —
`HashMap<fingerprint, &PeerEntry>` + `HashMap<auth_token_hash,
&PeerEntry>`, built once per reload from `SELECT * FROM peers`. This
is the secondary-index pattern keypal's `memory.ts` uses
(`hashIndex`, `ownerIndex`, `tagIndex`); alknet needs the fingerprint
and auth-token-hash indexes. (Implementation note: the index owns
the `Vec<PeerEntry>` loaded from SQLite; the secondary maps borrow
with a lifetime tied to the index struct — self-referential, so the
index is built in one pass and held behind the `ArcSwap` as a single
`Arc<PeerIndex>`. Cloning entries to avoid self-reference is a
two-way-door implementation choice; the trait is agnostic.)
### 5. honker is the cache-invalidation mechanism — a hard dependency of the SQLite adapter
`alknet-store-sqlite` depends on `honker` (the Rust crate,
`honker-core`/`honker-extension`). This is **not** a core dependency
`alknet-core` stays honker-free (ADR-033's "core has no backend
dependency" is preserved). The honker dependency lives in the adapter
crate, alongside the `rusqlite` (or `sqlx`) dependency.
The honker `LISTEN` loop is spawned by the adapter at construction
(`SqliteIdentityProvider::new` starts a tokio task that `LISTEN`s on
`peers_changed` and reloads the index on wake). The loop is
cancellation-safe (dropping the adapter cancels the task). The
listener uses honker's `PRAGMA data_version` watch — single-digit-ms
wake, no polling, no daemon.
**Why honker is load-bearing, not optional:** without it, the
sync-read + cached-index + no-restart combination breaks down into
either (a) polling (re-introduces the staleness window the project
already fixed), or (b) restart-on-change (the bug the project already
fixed). A SQLite adapter without honker would be a strictly worse
`ConfigIdentityProvider` (config reload does the same thing, simpler).
honker is what makes the SQLite adapter worth building: it adds
persistence *and* preserves the no-restart property *and* keeps the
hot path sync.
### 6. Schema (the commitment, not the DDL)
The ADR commits to the **table shape**, not the exact DDL. The DDL is
an implementation-detail two-way door (it lives in the adapter crate's
own code/tests, not an ADR); the shape is the one-way door because it
determines what the trait can express and what indexes the adapter
builds.
**`peers` table** — one row per `PeerEntry`:
| Column | Type | Notes |
|--------|------|-------|
| `peer_id` | TEXT PK | Stable logical id (`"worker-a"`) |
| `fingerprints` | TEXT (JSON array) | `["ed25519:...","SHA256:..."]` |
| `auth_token_hash` | TEXT NULL | SHA-256 of bearer token, or NULL |
| `scopes` | TEXT (JSON array) | `["relay:connect"]` |
| `resources` | TEXT (JSON object) | `{"service":["gitea","registry"]}` |
| `display_name` | TEXT NULL | |
| `enabled` | INTEGER (0/1) | Boolean |
The `PeerIndex` rebuilds from `SELECT * FROM peers` on each honker
wake. The fingerprint index is built by iterating rows and expanding
the `fingerprints` JSON array. The auth-token-hash index is built
from the non-NULL `auth_token_hash` rows.
**`credentials` table** — one row per `EncryptedData` blob:
| Column | Type | Notes |
|--------|------|-------|
| `provider` | TEXT PK | `"openai"`, `"anthropic"`, etc. |
| `key_version` | INTEGER | From `EncryptedData` (ADR-020) |
| `salt` | BLOB | Wire-format compat (OQ-20); unused in v2. Writers echo the vault's `EncryptedData.salt` field (even if unused in v2) so the row round-trips through the core `EncryptedData` mirror without loss; v2 may write a zero-length salt but must not drop the field. |
| `iv` | BLOB | AES-GCM IV (OsRng-generated, ADR-020) |
| `data` | BLOB | Ciphertext |
The `EncryptedData` core mirror (ADR-031 §3) round-trips through these
columns. The store never decrypts (ADR-025); the vault does.
**Migrations:** the adapter bootstraps with `CREATE TABLE IF NOT
EXISTS` on first open. The schema is small and stable (locked by
ADR-020/030); a migration framework (sqlx migrations, refinery) is
not pulled in for v1. If a future schema change requires a real
migration, that's additive (a `migrations/` dir + a migration runner
— two-way door). This is recorded so a future reader doesn't assume
migrations were forgotten.
### 7. The `StoreError` type (renames `CredentialStoreError`)
A shared error enum for both adapters, `#[non_exhaustive]` +
`thiserror::Error`. **This renames the `CredentialStoreError` sketched
in ADR-031 §1 to `StoreError`** — a single shared type for both the
identity and credential store traits, so both adapters and all
consumers reference one error type. The rename is within ADR-031's
one-way door (the contract was "a `#[non_exhaustive]` error enum for
store failures"; the *name* was unspecified detail). ADR-031's sketch
is amended to use `StoreError` by this rename.
```rust
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("backend error: {message}")]
Backend { message: String },
#[error("not found: {entity}")]
NotFound { entity: String },
#[error("serialization error: {message}")]
Serialization { message: String },
}
```
`Backend` covers SQLite errors (constraint failures, disk I/O,
corruption). `NotFound` is for `update_peer`/`remove_peer` on a
missing `peer_id`. `#[non_exhaustive]` lets the adapter add variants
without breaking downstream match arms. A `Duplicate` variant is
**not** in v1: `put_peer` is upsert (insert-or-replace), so
`peer_id` collisions are a replace, not an error; a strict-insert
mode that would return `Duplicate` is a future addition (with its own
method or flag, added non-breakingly). The error type lives in
`alknet-core` (where the traits live) so both adapters and consumers
reference one type; the adapter crate may add a wrapper error for
backend-specific failures it surfaces as `Backend { message }`.
## What this does NOT change
- **`IdentityProvider` trait shape (ADR-004/030)** — unchanged. The
read methods stay sync. `IdentityStore` is a new supertrait, not a
modification.
- **`CredentialStore` contract (ADR-031)** — `get`/`put`/`delete`
keyed by provider, persisting `EncryptedData`, never decrypting:
unchanged. The *signature* of `put`/`delete` changes sync→async (a
breaking change to `impl`s and call sites — acknowledged in
Consequences), which is within the one-way door ADR-031 committed
because ADR-031's sketch left sync-vs-async unspecified and ADR-033
§"What this does NOT do" explicitly deferred concrete adapter shapes
to this work. The error type is renamed `CredentialStoreError`
`StoreError` (§7), within the same one-way door (the contract was "a
`#[non_exhaustive]` error enum"; the name was unspecified).
- **`PeerEntry` struct (ADR-030)** — unchanged.
- **`EncryptedData` core mirror (ADR-031 §3)** — unchanged.
- **`ConfigIdentityProvider`** — unchanged, still read-only, still
config-backed. It does not implement `IdentityStore`.
- **`InMemoryCredentialStore`** — unchanged behavior; `put`/`delete`
become async-with-no-awaits (signature change only).
- **alknet-core has no backend dependency** — ADR-033's commitment is
preserved. `honker` and `rusqlite`/`sqlx` are dependencies of
`alknet-store-sqlite`, not `alknet-core`.
- **The no-env-vars invariant (ADR-014)** — unaffected. The
`CredentialStore` path is the persistence layer for encrypted
blobs; the assembly layer still loads them into `Capabilities`; no
`std::env::var` path exists.
## Consequences
**Positive:**
- OQ-36 is resolved. The concrete persistence adapter shape is
committed: read-sync / write-async / honker-NOTIFY-for-cache-
invalidation, against SQLite, in a separate `alknet-store-sqlite`
crate.
- The no-restart-on-auth-change property is preserved across the
config-backed and SQLite-backed deployments. `ConfigIdentityProvider`
uses `ArcSwap` + config reload; `SqliteIdentityProvider` uses
`ArcSwap` + honker NOTIFY. Same property, same mechanism shape,
different source of truth.
- The hot path stays sync. No `.await` in the accept loop or handler
auth resolution. The SQLite adapter caches in memory and serves
reads from the cache, with honker keeping the cache fresh.
- The keypal-style repo pattern lands in Rust with the adaptations
the alknet constraints require (two trait families, read/write
split, no adapter factory). The pattern is now concrete, not
aspirational.
- A third-party / downstream user can implement `IdentityProvider` or
`CredentialStore` against any backend (Postgres, Redis, a remote
service) by implementing the trait; the `IdentityStore` write
extension is opt-in. The trait shapes are the public contract.
**Negative:**
- `alknet-core` gains the `IdentityStore` trait and the `StoreError`
type. Small surface, but it's a new public trait — downstream
consumers see it. The trade is that peer management (CLI tools,
admin ops) gets a typed write surface instead of each adapter
rolling its own.
- `InMemoryCredentialStore::put`/`delete` change signature (sync →
async). Callers (the assembly layer, tests) add `.await`. This is
within the ADR-031 sketch's unspecified detail; the in-memory
adapter's behavior is unchanged.
- The SQLite adapter has a hard `honker` dependency. A deployment
that wants SQLite persistence but not honker would need a separate
adapter (`alknet-store-sqlite-polling` or similar) that polls or
requires restart — strictly worse, and not built. This is the
trade for the no-restart property; it's explicit.
- The full-reload-on-NOTIFY strategy is O(rows) per wake. At
expected scale (10s100s of peers) this is cheap; at thousands it
would matter. The peer/credential counts are small by design
(ADR-030 Assumption 4); if a future use case pushes to thousands,
a delta-apply strategy is a two-way-door optimization (additive,
behind the same trait).
## Assumptions
1. **The hot path must stay sync.** `IdentityProvider::resolve_from_
fingerprint` and `CredentialStore::get` are called in contexts
that cannot `.await` (accept loop, per-request dispatch). This is
locked by ADR-004/011 and is the one-way door that drives the
read/write split. Reverting to async reads would require rewriting
the dispatch surface — not planned.
2. **Peer/credential counts are small (10s100s).** The full-reload-
on-NOTIFY strategy is cheap at this scale (ADR-030 Assumption 4).
A delta-apply strategy is a future two-way-door optimization if
scale forces it.
3. **honker is the cache-invalidation mechanism for the SQLite
adapter.** It is a hard dependency of `alknet-store-sqlite`, not
of `alknet-core`. A non-honker SQLite adapter is possible but
would poll or require restart — strictly worse and not built.
4. **`ConfigIdentityProvider` does not implement `IdentityStore`.**
Config reload is its write path; the trait stays read-only. This
preserves the config-is-source-of-truth model. A deployment that
wants method-call peer management uses the SQLite adapter, not the
config adapter.
5. **The concrete SQL DDL is an implementation-detail two-way door.**
The table *shape* (one row per `PeerEntry`, one row per
`EncryptedData`, JSON columns for arrays/objects) is the one-way
door this ADR commits; the exact DDL, migration tooling, and
column naming live in the adapter crate.
6. **Redis / Postgres / on-chain adapters are not needed for the
current scope.** The trait shapes make them possible; the adapter
crates get built when a concrete use case forces them. This is a
scoping judgment, not a deferral — the SQLite adapter is the
committed build; the others are not designed here.
7. **One crate (`alknet-store-sqlite`) for both adapters.** Splitting
into `alknet-peer-store-sqlite` + `alknet-credential-store-sqlite`
is a two-way door (additive) if a use case forces it; the default
is one crate, shared infra.
## References
- OQ-36 (resolved by this ADR) — concrete persistence adapter shapes
- [ADR-003](003-crate-decomposition.md) — crate decomposition (core is
lean, adapters are separate crates, the assembly layer wires — the
rules this ADR's adapter-crate structure follows)
- [ADR-009](009-one-way-door-decision-framework.md) — one-way door
decision framework (the door-type vocabulary used throughout)
- [ADR-004](004-auth-as-shared-core.md) — `IdentityProvider` (the
read trait this ADR keeps sync)
- [ADR-011](011-authcontext-structure.md) — AuthContext resolution
flow (where the sync read is called from)
- [ADR-014](014-secret-material-flow-and-capability-injection.md) —
no-env-vars invariant (the `CredentialStore` path supports it)
- [ADR-020](020-hd-derivation-for-encryption-keys.md) —
`EncryptedData` shape (the `credentials` table row shape)
- [ADR-025](025-vault-local-only-dispatch.md) — vault is the sole
decryption boundary; `CredentialStore` never decrypts
- [ADR-030](030-peerentry-and-identity-id-decoupling.md) — `PeerEntry`
model (the `peers` table row shape); Assumption 4 (small peer
counts → full-reload is cheap)
- [ADR-031](031-credentialstore-repo-trait.md) — `CredentialStore`
trait (this ADR refines `put`/`delete` to async, within the
one-way door ADR-031 committed)
- [ADR-033](033-storage-boundary-and-repo-adapter-pattern.md) — the
repo/adapter pattern (this ADR commits the concrete adapter shape
ADR-033 §"What this does NOT do" deferred)
- `docs/research/alknet-storage-strategy/findings.md` — the
SQLite+honker foundation and the repo/adapter pattern research
- `/workspace/keypal` — TypeScript repo-pattern reference (the
`Storage` interface + adapters pattern; the in-memory secondary-
index pattern in `memory.ts`; the adapter-factory intent this ADR
does not port to Rust)
- `/workspace/honker` — honker: SQLite NOTIFY/LISTEN, the
cache-invalidation mechanism the SQLite adapter depends on

View File

@@ -587,12 +587,14 @@ is a feature extension, not an unmade architecture decision.
them, wired by the assembly layer.
The concrete adapter shapes (table schemas, backend choice, indexing,
caching) are the two-way-door remainder, tracked as OQ-36 (deferred for
exploration). The trait shapes are the one-way door, committed by
ADR-030, ADR-031, and ADR-033.
caching) were the two-way-door remainder, tracked as OQ-36 — **now
resolved by [ADR-035](decisions/035-concrete-persistence-adapter-shapes.md)**
(read/write split, honker+SQLite, `alknet-store-sqlite` crate). The
trait shapes are the one-way door, committed by ADR-030, ADR-031, and
ADR-033; ADR-035 builds on them.
- **Cross-references**: ADR-008, ADR-018, ADR-021, ADR-025, ADR-029,
ADR-030, ADR-031, ADR-033, OQ-33, OQ-36, [auth.md](crates/core/auth.md),
[config.md](crates/core/config.md)
ADR-030, ADR-031, ADR-033, ADR-035, OQ-33, OQ-36,
[auth.md](crates/core/auth.md), [config.md](crates/core/config.md)
## Theme: Storage and Adapters
@@ -623,45 +625,68 @@ is a feature extension, not an unmade architecture decision.
- **Cross-references**: ADR-030, [auth.md](crates/core/auth.md),
[config.md](crates/core/config.md)
### OQ-36: Concrete Persistence Adapter Shapes (Deferred for Exploration)
### OQ-36: Concrete Persistence Adapter Shapes
- **Origin**: ADR-033 §"What this does NOT do" (concrete adapter shapes not
specified), the project's note that the repo pattern is a tool to reach
for, not a one-size-fits-all mold
- **Status**: open (deferred for exploration)
- **Status**: **resolved** (2026-06-28 by ADR-035)
- **Door type**: Two-way (adapter shapes are implementation details;
the trait shapes are the one-way doors, already committed by ADR-030/031/033)
- **Priority**: medium (must be addressed before the next round of
implementation; not blocking the current OQ-29 decision)
- **Resolution**: The repo/adapter pattern is committed (ADR-033): core
defines repo traits + in-memory default adapters; persistence adapters
are separate crates; the assembly layer wires the adapter.
- **Priority**: medium → resolved
- **Resolution**: **[ADR-035](decisions/035-concrete-persistence-adapter-shapes.md)
commits the concrete adapter shape.** The design is driven by two
constraints: the hot-path read trait (`IdentityProvider::resolve_from_
fingerprint`, `CredentialStore::get`) is **sync** (called in the
accept loop, no `.await`), and auth changes must take effect **without
a restart** (an early issue the project already fixed for
`ConfigIdentityProvider` via `ArcSwap` config reload).
**What ships with core** (not deferred): the repo traits
(`IdentityProvider`, `CredentialStore`) and their in-memory default
adapters (`ConfigIdentityProvider`, `InMemoryCredentialStore`). These are
the one-way-door commitments — they ship with the core crate, not as
separate adapters. The in-memory adapters are real implementations, not
stubs — a full repo pattern (the same trait surface a persistence
adapter would implement), just backed by config / `HashMap` instead of
a database.
The resolution:
- **Read trait stays sync; persistence adapters cache in memory.** A
SQLite-backed adapter serves sync reads from an in-memory index
(`HashMap<fingerprint, PeerEntry>` / `HashMap<String, EncryptedData>`),
loaded from SQLite at construction and refreshed on honker `NOTIFY`.
Same `ArcSwap`-backed full-reload pattern as `ConfigIdentityProvider`,
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 `IdentityProvider` for peer mutations.
`ConfigIdentityProvider` does NOT implement it (config reload is its
write path); the SQLite adapter does. The read trait stays lean;
the write surface is opt-in.
- **`CredentialStore::put`/`delete` become async** (refines ADR-031's
sync sketch — within the one-way door ADR-031 committed; `get` stays
sync/cached). `InMemoryCredentialStore`'s write methods are
async-with-no-awaits (signature change only).
- **honker is the cache-invalidation mechanism** — a hard dependency of
`alknet-store-sqlite`, NOT of `alknet-core`. honker's SQLite
`NOTIFY`/`LISTEN` (single-digit-ms wake, no polling) is what makes
the sync-read + cached-index + no-restart combination work. Without
it, the adapter either polls (stale window) or requires restart
(the bug already fixed). Not optional for the SQLite adapter.
- **`alknet-store-sqlite`** — one crate, both adapters
(`SqliteIdentityProvider: IdentityProvider + IdentityStore`,
`SqliteCredentialStore: CredentialStore`), shared SQLite connection
pool + honker LISTEN loop + bootstrap migrations. Splitting into
two crates later is a two-way door (additive).
- **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.
- **Shared `StoreError`** (`#[non_exhaustive]`, `thiserror::Error`)
in alknet-core for both adapters.
**What's deferred**: the concrete *persistence* adapter shapes — table
schemas, backend choice (SQLite + honker vs. a key-value store vs. a
remote service), indexing, caching, connection management. These are the
separate-crate adapters (e.g., `alknet-peer-store-sqlite`,
`alknet-credential-store-sqlite`) that implement the core traits against
a specific backend. The project is iterating on adapter simplification;
the trait shapes are the commitment, the persistence adapter shapes are
not. When a concrete use case (peer identity persistence across
restarts, credential persistence across restarts, ACL delegation graph)
forces a persistence adapter build, the adapter shape gets reasoned
through then.
This OQ exists so the deferral is deliberate, not accidental — the
pattern is committed, the in-memory adapters ship with core, and the
persistence adapter shapes are the open exploration.
- **Cross-references**: ADR-030, ADR-031, ADR-033, OQ-34,
The keypal adapter-factory pattern is **intentionally not ported** to
Rust (runtime column-mapping/type-coercion is a TS affordance; in
Rust each adapter is a concrete type, cross-cutting concerns are a
shared helper module). Two trait families (not one generic
`Storage<T>`) preserved per ADR-033 §4. Redis / Postgres / on-chain
adapters are **not needed for current scope** — the trait shapes
make them possible; the adapter crates get built when a use case
forces them.
- **Cross-references**: ADR-004, ADR-011, ADR-014, ADR-020, ADR-025,
ADR-030, ADR-031, ADR-033, ADR-035, OQ-33, OQ-34,
[auth.md](crates/core/auth.md), [config.md](crates/core/config.md)
## Theme: TLS Identity