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.
-`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.
**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)]
pubenumStoreError{
#[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
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
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.