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

@@ -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