diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 294b088..5ec76a5 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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` → `peers: Vec`; `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):** diff --git a/docs/architecture/crates/call/client-and-adapters.md b/docs/architecture/crates/call/client-and-adapters.md index 49efe96..4451109 100644 --- a/docs/architecture/crates/call/client-and-adapters.md +++ b/docs/architecture/crates/call/client-and-adapters.md @@ -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 diff --git a/docs/architecture/crates/core/README.md b/docs/architecture/crates/core/README.md index e1d96fe..e4bc9c1 100644 --- a/docs/architecture/crates/core/README.md +++ b/docs/architecture/crates/core/README.md @@ -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 diff --git a/docs/architecture/crates/core/auth.md b/docs/architecture/crates/core/auth.md index d628482..b1948bc 100644 --- a/docs/architecture/crates/core/auth.md +++ b/docs/architecture/crates/core/auth.md @@ -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` 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 diff --git a/docs/architecture/crates/core/config.md b/docs/architecture/crates/core/config.md index 19d7b03..212063f 100644 --- a/docs/architecture/crates/core/config.md +++ b/docs/architecture/crates/core/config.md @@ -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 @@ -348,4 +361,5 @@ Simplified from the reference implementation. Removes proxy-specific errors (now | ArcSwap for dynamic config | Carry-forward from reference | Lock-free reads, atomic swaps | | 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` → `peers: Vec`; `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 | \ No newline at end of file +| 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. | \ No newline at end of file diff --git a/docs/architecture/decisions/031-credentialstore-repo-trait.md b/docs/architecture/decisions/031-credentialstore-repo-trait.md index 318f03f..aee91e1 100644 --- a/docs/architecture/decisions/031-credentialstore-repo-trait.md +++ b/docs/architecture/decisions/031-credentialstore-repo-trait.md @@ -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; - 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` 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) diff --git a/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md b/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md index 0f6fbc3..8d2b0a6 100644 --- a/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md +++ b/docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md @@ -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; - 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` 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` 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 diff --git a/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md b/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md index b07db80..178b350 100644 --- a/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md +++ b/docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md @@ -368,7 +368,9 @@ It is noted here only to confirm it does not reopen OQ-37. quinn/iroh); the `SHA256:` 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 diff --git a/docs/architecture/decisions/035-concrete-persistence-adapter-shapes.md b/docs/architecture/decisions/035-concrete-persistence-adapter-shapes.md new file mode 100644 index 0000000..c8c744c --- /dev/null +++ b/docs/architecture/decisions/035-concrete-persistence-adapter-shapes.md @@ -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`, 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` — 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`.** 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` for identity; +`HashMap` 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 (10s–100s, 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; + fn resolve_from_token(&self, token: &AuthToken) -> Option; +} + +/// 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; + 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, + // In-memory read index, atomically swapped on honker NOTIFY + index: Arc>, +} + +impl IdentityProvider for SqliteIdentityProvider { + fn resolve_from_fingerprint(&self, fp: &str) -> Option { + let idx = self.index.load(); + idx.resolve_from_fingerprint(fp) + } + fn resolve_from_token(&self, token: &AuthToken) -> Option { + 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, // shared with the identity adapter + index: Arc>>, +} + +impl CredentialStore for SqliteCredentialStore { + fn get(&self, provider: &str) -> Option { + 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` + `HashMap`, 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` 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`. 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 (10s–100s 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 (10s–100s).** 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 \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index a17ae14..384d655 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -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` / `HashMap`), + 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`) 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