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.
Untangles the conflation of three distinct remote roles under 'X.509
endpoint': (1) public X.509 endpoint — a remote HTTPS/call-over-TLS
server the local node is a client of (no PeerEntry, no PeerId, not in
the peer graph; CA verification + bearer token); (2) transport relay —
iroh's DERP-equivalent, infrastructure, not an alknet peer; (3) hub /
hosting node — an alknet peer that also exposes a public domain + X.509
for browsers (mixed-fingerprint PeerEntry, already supported by
ADR-030).
The load-bearing one-way door is the client-side verifier selection
rule: known peer (PeerEntry present) → fingerprint pin; unknown X.509
remote → CA verification (WebPkiServerVerifier); unknown Ed25519
remote → fails closed. This closes the AcceptAnyServerCertVerifier
security hole OQ-29 flagged, with the peer-model criterion (PeerEntry
presence) made explicit. The 'make PeerEntry symmetric' instinct is
rejected — pure-client connections to public APIs have no stable
logical identity to pin.
Documents that CallCredentials.remote_identity: None is load-bearing
(None = public X.509 endpoint → CA path, not a missing field; Some =
known peer → fingerprint pin), closing a subtle gap where an
implementer could have defaulted to a placeholder or treated None as
skip-verify.
Records WebTransport relay-as-proxy (deferred with h3/WebTransport,
new OQ-HTTP-07) and on-chain/smart-contract peer discovery (fits the
OQ-36 repo/adapter pattern, no auth-model change) so they aren't lost.
Amends auth.md and client-and-adapters.md with the three-role naming,
the verifier selection rule, and the Option semantics; updates OQ-37
to resolved in open-questions.md, README.md, and both crate READMEs.
ADR-009, open-questions.md, and the architect agent spec all had the same
conflation: 'two-way door' was phrased as 'can be decided during
implementation,' which reads as 'defer the decision.' That's not what it
means. A two-way door is a decision you make now and can revert later if
wrong — it's about reversal cost, not urgency.
ADR-009: add §'What this framework is NOT' — explicitly separates door
type (reversal cost) from deferral (scope management). State that
architecture decisions are the architect's regardless of door type.
Reword the two-way-door process from 'can be decided during
implementation' to 'pick the simplest option that works, implement it,
revert if needed.'
open-questions.md: reword the header to clarify door type describes
reversal cost, not urgency. Add 'Door type is separate from whether a
decision is made.'
architect.md: add Key Principle #8 (decisions are made, not deferred),
a new 'Door Types and Decision Urgency' section, and two new anti-patterns
(#8: door type as deferral, #9: hedging language in resolved decisions).
Amend ADR-030 with three changes from the auth-type analysis:
1. PeerEntry is now multi-credential: fingerprints: Vec<String> (Ed25519
and/or X.509) + auth_token_hash: Option<String> (bearer token). All
resolve to the same peer_id. A peer that authenticates via Ed25519
today and via auth_token tomorrow gets the same PeerId. The 'peer
bearer vs auth bearer' distinction was wrong — the correct framing is
the three credential types (Ed25519, X.509, bearer token) and whether
the token needs a stable logical id across rotation (PeerEntry) or not
(ApiKeyEntry).
2. Fingerprint normalization (§6): quinn extracts the raw Ed25519 public
key from the SPKI cert and formats as ed25519:<hex>, matching iroh.
The same key has the same fingerprint regardless of transport. X.509
fingerprints stay as SHA256:<hex of DER>. This also simplifies the
coming WebTransport relay work.
3. The 'API keys' section is replaced with 'Bearer tokens' — correctly
framing the three auth types and the two bearer-token paths
(PeerEntry.auth_token_hash vs ApiKeyEntry).
Resolve OQ-29 (CallClient TLS client-auth): wire quinn client-auth (present
Ed25519 key as raw public key client cert — the server-side extraction
already works); key-type-aware server cert verification (raw key =
fingerprint match, X.509 = CA verification via WebPkiServerVerifier —
AcceptAnyServerCertVerifier is only safe for raw keys); fingerprint
normalization. The iroh path already works (RFC 7250 raw keys, both sides
exchange automatically); the gap was quinn-only.
Dissolve OQ-35: the 'API key asymmetry' framing was wrong. PeerEntry
supports multiple credential paths; ApiKeyEntry is for tokens that ARE the
identity.
Add OQ-37: X.509 outgoing-only case — the three auth types and how X.509
server identity fits the peer model. Not blocking the ADR-029 migration;
downstream (HTTP crate phase).
Update auth.md, config.md, client-and-adapters.md, call/README.md,
core/README.md, open-questions.md, README.md, and call_client.rs source
comment.
Workspace green: 326 tests pass, build clean.
Resolve the call-crate open questions where the decision is made —
OQ-27 (auto-re-import), OQ-28 (same-peer collision = error), OQ-30
(PeerRef::Any insertion-order first-match), OQ-31 (services/list-peers
opt-in). These were previously marked 'open' with 'v1' hedging language
despite having a decided default. What remains (refresh(), richer routing,
services/list-peers the op) is genuine feature addition, not unmade
architecture.
Reframe OQ-32 (multi-hop) as a feature extension rather than a 'v1'
deferral — the one-hop model is the architectural commitment; extending
to multi-hop doesn't break downstream.
Promote OQ-29 (CallClient TLS client-auth) from medium to high priority
and surface its real interaction with ADR-030. Previously framed as
'additive — two-way-door remainder,' but ADR-030's PeerEntry fingerprint
→ peer_id resolution requires the client to present a TLS client cert.
With with_no_client_auth(), no fingerprint is extracted, the PeerEntry
path is dormant, and PeerCompositeEnv keys on None or the API-key prefix
instead of the stable peer_id. This is the activation path for ADR-030's
primary use case, not an additive feature. Three options laid out: (a)
wire client-auth with the ADR-029 migration, (b) ship token-only and
switch later (the 'compounds into a mess' path), (c) extend PeerEntry
to cover auth_token-based identity. Requires a decision before the
migration lands.
Clarify OQ-36 (concrete adapter shapes): the trait shapes and in-memory
adapters ship with core — the deferral is only for the persistence
adapters (SQLite, etc.). The in-memory adapters are real implementations
of a full repo pattern, not stubs.
Update call_client.rs source comment to reference OQ-29 instead of the
'v1' / 'two-way-door remainder' framing.
Workspace green: 326 tests pass, build clean.
Land the storage and auth strategy research (findings.md) as four
accepted ADRs and amend the core and call specs to match:
- ADR-030: PeerEntry and Identity.id decoupling. Replaces
authorized_fingerprints with peers: Vec<PeerEntry>; Identity.id becomes
the stable peer_id, decoupled from the rotating fingerprint. Supersedes
ADR-029 Assumption 1's UUID source (one-way door preserved, source
changes). Resolves OQ-33 and the storage-boundary half of OQ-34. Records
the API-key asymmetry as deliberate (OQ-35).
- ADR-031: CredentialStore repo trait + InMemoryCredentialStore default
adapter in core. Second repo trait alongside IdentityProvider. Vault
encrypts; the store persists the EncryptedData blob; assembly layer
loads into Capabilities. EncryptedData core mirror includes salt for
wire-format compat.
- ADR-032: Forwarded-for identity. forwarded_for field on call.requested
and OperationContext — metadata only, never read by AccessControl::check
(enforced structurally via the check signature). The from_call handler
populates it. Wire-format one-way door, folded into 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;
assembly layer wires. Resolves OQ-34. Concrete adapter shapes deferred
for exploration (OQ-36).
Amends auth.md, config.md, operation-registry.md, client-and-adapters.md,
open-questions.md, README.md, crates/core/README.md. Marks ADR-029
Accepted (Assumption 1 carries the ADR-030 superseded note). Marks the
research findings doc reviewed.
Reworks the storage strategy doc to commit to concrete design, replacing
the 'when storage arrives' / 'future' / 'later' framing that was putting off
important work.
Key changes from the previous draft:
- §4 (Repo/Adapter Pattern): now an explicit design with the trait contracts
(IdentityProvider, CredentialStore), the adapter contracts
(ConfigIdentityProvider with PeerEntry update, SqliteIdentityProvider,
InMemoryCredentialStore, SqliteCredentialStore), and the concrete table
schemas. Not a pattern description — a design commitment.
- §4: PeerEntry config model — AuthPolicy gains peers: Vec<PeerEntry>
replacing authorized_fingerprints: HashSet<String>. This is the
id-fingerprint decoupling (OQ-33) done as a config change, not a storage
change. ConfigIdentityProvider resolves fingerprint → PeerEntry →
Identity { id: peer_id } (stable, not the fingerprint).
- §7 (Decomposition): the 'what goes where' table now has a Status column
(exists / needs adding / needs building / needs PeerEntry update) instead
of 'future'. The crate graph is a concrete build plan.
- §10 (Build Order): replaces 'What This Means for the Immediate Path' (which
had 'when storage arrives' framing) with a 4-tier dependency-driven build
order. Tier 1 = core repo traits + PeerEntry config model. Tier 2 = SQLite
adapters. Tier 3 = ADR-029 migration + forwarded_for. Tier 4 = alknet-graphs
(built when a graph-shaped problem exists, not speculatively).
- §10: explicit 'What does NOT get built (dropped, not deferred)' section —
multi-tenant, accounts/orgs, secrets module, single storage crate are
dropped, not deferred.
- All 'future' / 'when X arrives' / 'v1' / 'phase n' language removed for
things that are needed. The only 'when X is needed' language remaining is
for genuinely non-existent problems (ACL delegation, workflows, taskgraph)
— those are built when the problem exists, not speculatively.
Synthesizes the multi-thread discussion that surfaced during the peer-graph
routing research (ADR-029) and OQ-33/34 resolution. Three separate threads
(peer identity, filesystem POC, old storage spec) converged on the same
question: where does persistent state live in the alknet crate graph, and
what's the shared infrastructure for it.
Key commitments documented:
- SQLite + honker is the foundation (pattern, not a crate — ~20 lines per
consumer). The metagraph is one tool built on it, for graph-shaped
problems. Direct tables are another tool, for table-shaped problems.
- IdentityProvider is the auth repo trait (already exists in core, make the
pattern explicit). Adapters implement it (Config, SQLite, future
Redis/remote/automerge). PeerStore is adapter-internal, not core.
- Per-node ACL, no 'trusted' flag. Each node authorizes its direct callers
via AccessControl::check(identity). No global ACL, no replication. The
hub authorizes the user; the spoke authorizes the hub. Same mechanism.
- Forwarded-for identity as metadata, not authority. The from_call handler
includes the original caller's identity in the call payload; the spoke's
ACL authorizes the hub (direct caller), never the forwarded_for. The ACL
check signature prevents misuse.
- The ACL check stays table-shaped (flat scope match); the delegation graph
(future) produces effective scopes at resolution time. They compose at the
IdentityProvider boundary.
- The hub proxy tangle: ACL (authorize), bucket routing (operation input),
peer routing (PeerRef) are three separate layers. Bucket-level
authorization is handler logic, not protocol logic.
What the old spec had that's dropped: multi-tenant (each tenant gets own
setup), secrets module (replaced by vault), metagraph-as-foundation (demoted
to tool), single storage crate (split by concern), accounts/orgs (deferred —
v1 is a peers table).
Reference: kepal (/workspace/keypal) — TypeScript repo-pattern example
(Storage interface + adapters) that alknet's IdentityProvider follows.
OQ-26 (resolved): AdapterError variants decided — DiscoveryFailed,
SchemaParse, Transport, Unauthorized, SamePeerCollision (replaces flat
Conflict per ADR-029 §5). #[non_exhaustive] for downstream extension.
Two-way door; the initial set is the code's return type.
OQ-33 (resolved): PeerId is a logical identifier, NOT Identity.id. The
research's v1 default (PeerId = fingerprint) is overridden: coupling PeerId
to crypto material breaks every in-flight PeerRef::Specific and every ACL
entry on key rotation. v1 source is a connection-assigned UUID — a
no-storage workaround that works for the immediate use case (head→workers,
reconnect produces fresh PeerRef, in-flight gets NOT_FOUND which is correct).
The one-way door: PeerId is logical, not crypto — this determines
PeerCompositeEnv key type and PeerRef::Specific payload. The id source
(UUID vs configured name vs peer registry) is the two-way-door remainder.
OQ-34 (new): the storage dimension OQ-33 surfaced. The core crates are
deliberately DB-free (smaller, fewer deps, simpler testing) — this served
local-only state (vault, registry) well, but peer identity is the first
cross-node state that wants persistence. The real solution (a persistent
peer registry mapping stable logical name → current crypto material,
surviving key rotation) is not a v1 blocker (UUID works), but tracked so the
no-DB posture's limit is deliberate, not accidental. The storage boundary
(core gets a PeerRegistry trait vs stays storage-free) is the one-way door;
the backend choice is two-way. Key-rotation/ACL note: decoupling PeerId from
crypto keeps the door open for ACL entries that persist across key rotation
— when the peer registry is built, ACLs key on the logical name and key
rotation becomes vault-only with no remote-side ACL update.
ADR-028's remote_safe/trusted_peer was a parallel, weaker authorization system
that duplicated the existing AccessControl/Identity machinery and couldn't
express the head→N-workers pattern (the primary use case). The flat-namespace
single-peer overlay model (one connection layer in CompositeOperationEnv)
structurally breaks the moment a head has two workers both exposing
/container/exec.
ADR-029 replaces it with:
- Peer-keyed overlays: PeerCompositeEnv { connections: HashMap<PeerId, ...> }
replaces CompositeOperationEnv's singular connection layer. A head node
routes invoke_peer() to the right peer via PeerRef::Specific / PeerRef::Any.
- AccessControl-based peer authorization: the existing AccessControl::check
(peer_identity) gates peer calls — the same mechanism that gates every other
call. remote_safe/trusted_peer/RemoteFilter/list_operations_peer_scoped/
services_list_handler_peer_scoped are retired. The op's AccessControl IS the
peer-authorization policy; no parallel system.
- ScopedPeerEnv: peer-qualified reachability (peer-pinned allowlist) replaces
from_call's namespace_prefix as the disambiguation mechanism. Cross-peer
collision dissolves (separate sub-overlays); same-peer collision stays error.
- services/list-peers opt-in for peer-attributed re-export listing.
POC-validated against real types (scratch module written, type-checked,
removed; build clean, 207 tests pass). Petgraph not needed for v1 (one-hop,
shallow); nested HashMap suffices; extends to multi-hop without redesign (OQ-32).
OQ impact: OQ-25 dissolved (no marking); OQ-28 cross-peer dissolved / same-peer
stays; OQ-26/27/29 stay; new OQ-30 (Any routing policy), OQ-31 (list-peers
semantics), OQ-32 (multi-hop federation).
Research: docs/research/alknet-call-peer-routing/findings.md (POC shapes,
prior art — Ray.io actors, Dapr service invocation, full ADR draft).
ADR-028 marked Superseded; ADR-017 DC-1 amendment updated to point at ADR-029.
Post-implementation spec sync after the call-completion batch landed
(commits e4a2594..a3825f5). The sub-agent review flagged no spec drift, but
comparing the implemented types against the spec sketches surfaced five
details the specs didn't name — filled in here so the spec matches what was
built:
- client-and-adapters.md: name the shared Dispatcher (protocol/dispatch.rs)
+ RemoteFilter mechanism that enforces ADR-028's default-deny at dispatch
time (the load-bearing security gate — checks remote_safe before building
context, before any capability material reaches the handler). Add
ClientError/RemoteIdentity types, the spawn_dispatch lower-level API, and
the services_list_handler_peer_scoped wiring (the assembly layer must
register the peer-scoped services/list handler for a CallClient's registry,
not the plain one). Record the v1 TLS client-auth gap (AcceptAnyServerCertVerifier,
with_no_client_auth) as OQ-29.
- call-protocol.md: point the adapter dispatch-loop description at the shared
Dispatcher (dispatch.rs) so readers find the mechanism ADR-017 §1 commits to.
- open-questions.md: OQ-29 — CallClient TLS client-auth + remote-identity
verification is a two-way-door remainder; the no-env-vars invariant is
unaffected (auth_token flows via call-protocol payload, not TLS).
- READMEs: current-state now reflects completion done + reviewed (207 lib +
2 integration tests); OQ-29 added to both OQ summaries.
The #2 gap in alknet-call: discovers the remote peer's External operations
via services/list + services/schema and registers them in the connection's
Layer 2 overlay as FromCall-provenance leaves with forwarding handlers. The
discovery mechanism was already implemented in registry/discovery.rs;
from_call is the client-side consumer of that API.
src/client/from_call.rs:
- from_call(connection, FromCallConfig) -> Result<Vec<HandlerRegistration>,
AdapterError>. Calls services/list then services/schema for each op,
rebuilds OperationSpec from the schema JSON (parsing op_type, visibility,
error_schemas, access_control), constructs a forwarding handler that calls
the remote op via CallConnection::call(), and returns FromCall-provenance
bundles (composition_authority: None, scoped_env: None, empty capabilities,
remote_safe: false per ADR-028 §4).
- FromCallConfig { namespace_prefix: Option<String>, operation_filter:
Option<HashSet<String>> } with builder methods.
- v1 defaults (two-way doors recorded in client-and-adapters.md):
- error-on-collision (DC-3/OQ-28): applying the (possibly empty) prefix
produces a name already seen -> AdapterError::Conflict, not silent
overwrite.
- auto-on-reconnect (DC-2/OQ-27): the overlay is per-connection (Layer 2,
ADR-024), so re-import on reconnect is naturally scoped; the assembly
layer calls from_call immediately after connect().
- Forwarding handler captures an Arc<CallConnection> and, on invocation,
calls the remote op and returns its ResponseEnvelope. The
parent_request_id participates in the cross-node abort cascade
(ADR-016 §6) — if the parent is aborted, the cascade reaches this handler
which sends call.aborted to the remote node; cross-node abort is
transparent.
- Trust is transitive (recorded in spec): a from_call-imported op executes
the remote node's code; scoped_env bounds which ops are reachable, not
what they do.
OperationContext.internal is now pub (was pub(crate)) so downstream
consumers (assembly layer, integration tests) can construct contexts for
overlay-env dispatch.
Tests (207 lib + 2 integration):
- Unit: rebuild_spec name/prefix/op_type/visibility/error_schemas/acl;
unknown op_type -> SchemaParse; missing op_type -> SchemaParse;
FromCallConfig builder; from_call against a mock connection returns
DiscoveryFailed (no transport); FromCall provenance + leaf fields + remote_safe false.
- Integration (tests/two_node_call.rs): from_call over a real QUIC loopback
— CallClient connects, from_call discovers server/echo, registers the
bundle in the overlay, and the forwarding handler round-trips an input
through the overlay env to the remote op and back.
clippy + fmt + test all green.
Refs: tasks/call/client/from-call.md
Refs: docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md §3, §6
Refs: docs/architecture/crates/call/client-and-adapters.md §from_call
The #1 gap in alknet-call: the outbound connection opener. Every downstream
consumer (runner, container service, bilateral exchange, NAPI, agent
cross-node dispatch) is blocked on it.
Shared dispatch loop (ADR-017 §1 — the architectural commitment that keeps
CallClient from becoming a parallel protocol implementation):
- Extracts the accept-path dispatch (sweeper, accept_bi loop, handle_stream,
dispatch_requested, build_root_context, compose_root_env, fail_all on
close) out of CallAdapter into a new protocol/dispatch.rs Dispatcher struct.
Both CallAdapter::handle and CallClient::connect produce a CallConnection
and hand it to Dispatcher::run_loop — the loop is genuinely shared
(refactored, not duplicated).
- CallAdapter keeps its public API and test-facing wrappers (pub(crate),
#[cfg(test)]-gated) that delegate to the Dispatcher.
Peer-scoped default-deny (ADR-028 — the one-way-door security dimension):
- RemoteFilter { trusted_peer: bool } on the Dispatcher. In default-deny
mode (CallClient::new), an incoming call to an op with remote_safe: false
returns NOT_FOUND *before* any capability material reaches the handler —
a remote peer's call must not populate OperationContext.capabilities from
the local registration bundle unless the op is explicitly remote-safe
(ADR-028 Context). Trusted-peer mode (CallClient::trusted_peer, explicit
opt-in) bypasses the filter.
- The accept path (CallAdapter) uses RemoteFilter::trusted() by convention: a
direct QUIC client is not a filtered CallClient peer in the ADR-028 sense.
- OperationRegistry::list_operations_peer_scoped(trusted_peer) +
services_list_handler_peer_scoped for the CallClient's services/list
serving path (ADR-028 Assumption 2: a peer should not see ops it cannot
call, so discovery and dispatch filters agree).
CallClient (src/client/call_client.rs):
- CallClient { registry, identity_provider, trusted_peer: bool }.
- new() default-deny; trusted_peer() explicit opt-in (ADR-028 §3).
- connect(addr, CallCredentials) dials QUIC on ALPN alknet/call (quinn
feature), spawns Dispatcher::run_loop, returns a live CallConnection.
- spawn_dispatch(connection) shared path for connect + tests.
- CallCredentials { tls_identity, auth_token, remote_identity } — all from
Capabilities (ADR-014), never env vars (no-env-vars invariant). v1
connects without client-auth TLS identity (server uses
AcceptAnyCertVerifier); RawKey client-auth is a two-way-door remainder.
- RemoteIdentity { fingerprint } — concrete shape is a two-way door (OQ-25
remainder); the one-way constraint is it comes from Capabilities.
- ClientError { Transport, TlsSetup, ConnectionClosed }.
- CallConnection is now Clone (shares the inner Arcs) so connect can hand
the caller a live clone while the dispatcher task keeps its clone.
Tests (199 lib + 1 integration):
- Unit: default-deny NOT_FOUND for non-remote-safe; remote_safe dispatches;
trusted-peer dispatches all External; default-deny does NOT populate
capabilities (the load-bearing security assertion — verified by a handler
that inspects context.capabilities and the fact that the handler is never
reached for non-remote-safe ops); remote_safe op populates capabilities;
services/list peer-scoped hide/trusted variants; CallClient constructors;
CallCredentials builder; Send+Sync.
- Integration (tests/two_node_call.rs): real QUIC loopback — CallAdapter
server (self-signed cert via rcgen) accepts, CallClient connects,
client.call() round-trips to server/echo. Proves the connect path +
shared dispatch loop work end-to-end.
clippy + fmt + test all green.
Refs: tasks/call/client/call-client.md
Refs: docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md §1, §2, §7
Refs: docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md
Refs: docs/architecture/crates/call/client-and-adapters.md
Pre-existing fmt drift in two files touched during the call-completion
batch (remote_safe field, dispatch helpers). Brings cargo fmt --check
clean for the review gate.
Adds the v1 data shape for peer-scoped default-deny registry filtering,
the one-way-door piece of the call-completion batch (ADR-028):
- HandlerRegistration gains pub remote_safe: bool, defaulting false across
all provenance (Local, Session, FromOpenAPI, FromMCP, FromCall,
FromJsonSchema) per ADR-028 §4. HandlerRegistration::new() keeps its
existing 6-arg signature (defaults remote_safe: false), so all current
call sites compile unchanged.
- Chainable HandlerRegistration::remote_safe(bool) setter + a
OperationRegistryBuilder::remote_safe() helper that marks the
most-recently-registered op (tracked via last_name, not HashMap
iteration order which is unspecified).
- Field is data-only here — the filtering behavior (dispatch path +
services/list hide) is wired in call/client/call-client, not this task.
services/list is unchanged.
- Tests: default false, setter flips field, all six provenance variants
default false, builder setter marks last op, existing call sites
unchanged. 178 tests pass, clippy clean.
Refs: tasks/call/registry/remote-safe-marking.md
Refs: docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md
Resolves the four gap-analysis decisions (DC-1..4) blocking the alknet-call
client/adapter surface specced in ADR-017:
- ADR-028 (new): locks the one-way door for DC-1 — CallClient registry is
default-deny (remote_safe: bool on HandlerRegistration, default false across
all provenance); share-global is an explicit trusted-peer opt-in; filtering
is a dispatch-time read over the single Layer-0 registry, not a copy.
- client-and-adapters.md (new spec): operationally fills the gap ADR-017 left
to implementation — CallClient, from_call, from_jsonschema, OperationAdapter
trait, adapter location map, no-env-vars invariant, exchange-of-operations
pattern. Keeps call-protocol.md and operation-registry.md under the
700-line split threshold.
- ADR-017 amended: records DC-2/3/4 v1 defaults (auto-on-reconnect,
error-on-collision, Result error type) and points DC-1 at ADR-028.
- OQ-25..28 (new): two-way-door remainders (remote_safe shape, AdapterError
variants, re-import trigger, namespace collision) with v1 defaults recorded.
- Index/cross-ref updates across READMEs and the two existing call specs.
Tasks: 6 task files under tasks/call/ decomposing the completion work along
the gap-analysis priority order — remote-safe-marking (one-way door, first)
→ call-client (phase-risk) → from-call → operation-adapter-trait →
from-jsonschema (parallel with call-client) → review-completion. Graph
validated with taskgraph; parallelism designed in (from-jsonschema runs
concurrent with call-client/from-call once the trait lands).
Phase 0 exploration for alknet-http (greenfield crate, no existing arch):
HTTP server (axum, ProtocolHandler for h2/http1.1, h3 deferred), HTTP client
(reqwest, the from_openapi/from_mcp forwarding handlers), MCP streamable HTTP
(feature-gated, stdio excluded as security position), to_openapi/to_mcp
projections.
Records: 8 design points (DH-3 HTTP→call operation mapping as the load-bearing
one), the settled adapter location map (from alknet-call gap analysis), the
no-env-vars invariant (Capabilities → from_openapi handler → HTTP header as the
credential injection point), and the prerequisite on alknet-call's
OperationAdapter trait being defined first.
Gap analysis for completing alknet-call: the server-side core (~5.7k lines,
159 tests) is implemented, but the client side (CallClient), the bilateral
exchange mechanism (from_call), and the adapter contract (OperationAdapter
trait) are specced in ADR-017 and unimplemented.
Records: implementation state (verified against src/), 5 decisions needed
(peer-scoped registry filtering as the load-bearing one), the settled adapter
location map (trait + from_call + from_jsonschema in alknet-call; from_openapi/
from_mcp in alknet-http), the no-env-vars invariant (Capabilities → from_openapi
handler → HTTP header), and the exchange-of-operations runner pattern with
dispatch as the concrete downstream consumer.
Incorporates user clarifications: SOCKS5 and bidirectional port forwarding are
core non-negotiable v1 features (the VPN-like use case + the 3.5k-clones
demand). Adds DP-10 for the bare-TCP SSH listener as a first-class path needed
for future git-over-SSH, with config shape reserved in v1 (off-by-default,
default-deny). Grounds the client/forwarding recommendations in the dispatch
downstream consumer at /workspace/@alkdev/dispatch, which is a textbook russh
SSH client + direct-tcpip forwarder the user wants to replace with this stack.
alknet-ssh now owns both server and client + SOCKS5-server in v1; the SOCKS5
codec may extract to a separate crate later (two-way door).
Phase 0 exploration for alknet-ssh: confirms SSH-over-QUIC-bistream via
tokio::io::join (no custom adapter needed, per reference impl), russh 0.60.2
generic run_stream/connect_stream, and channel-into-bistream multiplexing.
Surfaces 9 decision points for Phase 1: host key sourcing (vault-derived vs
config), channel policy v1 surface, client + SOCKS5 crate split, crypto
backend, auth method coverage, and a stream-handling POC to close russh's
upstream test gap.
First dedicated coverage pass (cargo-llvm-cov --workspace --all-features).
Workspace at 87.1% line coverage (5759/6615), all 224 tests pass. Vault
and registry layers are essentially fully covered; gaps concentrate in
endpoint.rs (56%), types.rs (57%), and connection.rs (54%), all stemming
from tests using MockConnection whose open_bi/accept_bi return Err.
Eight suggestions (S1-S8) ordered by leverage: pure-function tests for
dispatch_envelope / map_*_connection_error / error Display+Debug (S1-S3),
Tier A directly-callable TLS/rustls helpers in endpoint.rs (S4), one
loopback quinn integration test as the real unlock across four files (S5),
ACME event-loop extraction via synthetic stream (S6, the flagged research
item), and two small remaining gaps (S7-S8). No critical or warning
findings — this is a testing-infrastructure gap, not a logic gap.
Three tasks implementing ADR-027:
1. core/rawkey-decouple-from-iroh: TlsIdentity::RawKey now uses
Ed25519SecretKey (alknet-core-owned wrapper over ed25519_dalek)
instead of iroh::SecretKey. RawKeyCertResolver and Ed25519SigningKey
un-gated from #[cfg(all(quinn, iroh))] to #[cfg(quinn)] only.
Quinn-only builds (default) now support RFC 7250 raw-key identity.
iroh transport converts via iroh::SecretKey::from_bytes.
2. core/endpoint-request-client-cert: replaced with_no_client_auth()
with AcceptAnyCertVerifier — a custom ClientCertVerifier that
requests client certs but doesn't require them or verify against
a CA. alknet's identity model is fingerprint-based (the
authorized_fingerprints set is the trust anchor), not PKI-based.
Peer certs are extracted at the TLS layer for fingerprinting;
peers without certs connect normally.
3. core/acme-integration: TlsIdentity::Acme variant (domains,
cache_dir, directory, contact) + AcmeDirectory enum. TlsSetup
two-phase construction: synchronous for X509/RawKey/SelfSigned,
async for Acme (spawns AcmeState event loop, builds ServerConfig
with ResolvesServerCertAcme). acme-tls/1 ALPN added when ACME is
active; dispatch_quinn guard closes challenge connections
gracefully (challenge is TLS-layer-handled). acme feature gate
keeps rustls-acme out of non-ACME builds.
Workspace: build/test/clippy green across all 3 feature configs
(quinn-only, quinn+iroh, quinn+acme, all-features). 331 tests, 0
failures, 0 warnings.
ADR-027 resolves the architectural gap surfaced when ACME integration
became a concrete target:
1. TlsIdentity::Acme variant — static config data (domains, cache_dir,
directory, contact) with async AcmeState constructed at endpoint
setup via two-phase TlsSetup (not stuffed into the Clone-able enum).
2. TlsIdentity::RawKey decoupled from the iroh feature — uses
Ed25519SecretKey (alknet-core-owned wrapper over ed25519_dalek)
instead of iroh::SecretKey. Raw-key TLS identity (RFC 7250, the
default for most alknet nodes) now works in quinn-only builds.
iroh transport converts via SecretKey::from_bytes.
3. ACME feature-gated behind new acme feature (rustls-acme optional
dep). Non-ACME builds don't compile it.
4. dispatch_quinn guard for acme-tls/1 challenge connections — TLS-ALPN-01
is handled at the rustls cert resolver layer during the handshake;
the guard closes challenge connections gracefully instead of logging
a misleading "no handler" warning.
Research confirmed QUIC (quinn) handles ACME challenges differently than
TCP (reverse-proxy): quinn gives no ClientHello peek hook, but the
challenge is fully answered at the cert resolution step before the
connection surfaces to the application. No handler registration needed.
Spec updates: config.md, endpoint.md, open-questions.md (OQ-12),
overview.md + README.md (ADR index), ADR-010 (cross-ref).
Tasks: core/rawkey-decouple-from-iroh (gen 1, no deps),
core/acme-integration (gen 2, depends on rawkey). Graph: 36 tasks.
W1 (call/protocol/abort-cascade-wiring): wire AbortCascade into
CallAdapter handle_stream for EVENT_ABORTED. Cascades with
AbortPolicy::AbortDependents, aborts root, no descendant frames on
wire (ADR-016 Decision 2). Two integration tests added.
W2 (core/endpoint-client-fingerprint): extract TLS client cert
fingerprint in dispatch_quinn (SHA256:<hex> of leaf cert DER via
peer_identity) and dispatch_iroh (ed25519:<hex> of peer NodeId).
Fingerprint format documented in auth.md. Server config change
(with_no_client_auth → request-but-don't-require) deferred to new
follow-up task core/endpoint-request-client-cert.
W3 (vault/mnemonic-debug-redaction): replace Mnemonic derive(Debug)
with manual redacting impl (phrase: "[REDACTED]"). Seed confirmed
no Debug impl. Redaction test added.
W4 (core/auth-apikey-resources): Option B — drop entry.resources from
spec. External identities (token/fingerprint) grant scopes only;
resource-scoped ACLs are composition-internal (ADR-015/022). auth.md
corrected + limitation documented. Two tests confirm empty resources.
review-post-impl-fixes: all 4 verified, workspace green (326 tests,
0 failures, 0 clippy warnings). Review #004 status → resolved.
Graph: 34 tasks, 12 gens.
W1 (call/protocol/abort-cascade-wiring): wire AbortCascade into CallAdapter
handle_stream for EVENT_ABORTED. W2 (core/endpoint-client-fingerprint):
extract TLS client cert fingerprint in dispatch_quinn/dispatch_iroh.
W3 (vault/mnemonic-debug-redaction): replace Mnemonic derive(Debug) with
redacting impl. W4 (core/auth-apikey-resources, level: research): decide
whether ApiKeyEntry should carry resources, then implement or drop from
spec. review-post-impl-fixes gates on all four. Graph: 33 tasks, 12 gens.
Implement AbortCascade in protocol/abort.rs per ADR-016: PendingEntry stores
parent_request_id (Call & Subscribe) and a started flag for tree indexing.
AbortCascade::cascade_abort walks the call tree by parent_request_id and aborts
descendants per AbortPolicy (AbortDependents aborts all; ContinueRunning aborts
only unstarted via mark_started()). Returns sorted list of aborted IDs; unknown
root silently discarded. 20 unit tests covering depth-3 cascade, mixed
Call/Subscribe, determinism, both policies.
Refs: docs/architecture/crates/call/call-protocol.md
Implements: ADR-016
- PendingEntry stores parent_request_id (Call and Subscribe) and started flag
for abort-cascade tree indexing
- register_call/register_subscribe accept optional parent_request_id
- AbortCascade::cascade_abort walks the call tree by parent_request_id and
aborts descendants per AbortPolicy (AbortDependents: all; ContinueRunning:
unstarted only). Returns sorted list of aborted request IDs
- call.aborted for unknown request_id silently discarded (empty result)
- Composed child request_ids stay internal (not sent as call.requested)
- mark_started() tracks dispatch state for ContinueRunning decisions
- 20 unit tests covering AbortDependents/ContinueRunning, depth-3 tree,
unknown root, mixed Call/Subscribe, determinism