From 1baa619ce960f18c7787ab94a36ee0eb9e9add7c Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Mon, 6 Jul 2026 16:07:59 +0000 Subject: [PATCH] docs(arch): decompose open-questions.md into per-OQ files under questions/ The monolithic open-questions.md (1310 lines, 47 OQs) was large enough to be unmanageable, with high size variance (OQ-42 at 220 lines next to OQ-06 at 8). Decomposed into one file per OQ under docs/architecture/questions/ (NNN-slug.md, mirroring the ADR convention), with open-questions.md retained as the index: theme-grouped tables plus a cross-theme Deferred/Blocked section that surfaces the 6 deferred OQs with their Blocked-on conditions inline (the safe-exit visibility surface). Per-OQ content moved verbatim; all 62 inbound links stay valid (none used anchors). README's curated OQ summary dropped (now redundant with the index tables). Also seeds tasks/architecture/ with this task plus two follow-ups found during the decompose: OQ-09/10 missing structured Blocked-on fields, and the tasks/architecture/ blocker-task half of the Safe Exit protocol being unenforced. --- docs/architecture/README.md | 67 +- docs/architecture/open-questions.md | 1368 ++--------------- .../questions/001-bistream-type-definition.md | 8 + .../002-authcontext-resolution-timing.md | 8 + .../003-alpn-string-naming-convention.md | 8 + ...tration-at-runtime-vs-static-at-startup.md | 23 + .../005-multi-connectivity-endpoint.md | 8 + ...06-server-side-alpn-vs-client-side-alpn.md | 8 + ...call-protocol-scope-within-a-connection.md | 8 + .../questions/008-vault-integration-point.md | 8 + .../questions/009-wasm-target-boundaries.md | 8 + ...cope-smart-protocol-only-or-full-server.md | 8 + ...ler-level-auth-resolution-observability.md | 31 + ...identity-provisioning-in-alknetendpoint.md | 18 + ...operation-path-format-and-routing-scope.md | 8 + .../014-batch-operation-semantics.md | 8 + ...ll-protocol-client-and-adapter-contract.md | 8 + ...t-operations-for-call-protocol-exposure.md | 8 + ...bort-cascade-semantics-for-nested-calls.md | 8 + ...8-privilege-model-and-authority-context.md | 8 + ...registries-and-agent-written-operations.md | 24 + ...df-and-encryption-key-derivation-method.md | 8 + .../021-remote-vault-administration.md | 14 + .../questions/022-key-rotation-mechanism.md | 8 + ...stration-path-and-composition-authority.md | 8 + .../questions/024-operation-error-schemas.md | 13 + ...r-scoped-filtering-dissolved-by-adr-029.md | 19 + ...dapter-error-type-adaptererror-variants.md | 22 + .../027-from-call-re-import-trigger.md | 15 + ...-from-call-namespace-collision-behavior.md | 23 + ...t-auth-and-remote-identity-verification.md | 52 + .../030-peerref-any-routing-policy.md | 17 + ...services-list-peers-re-export-semantics.md | 17 + .../questions/032-multi-hop-federation.md | 19 + ...c-identity-vs-stable-logical-identifier.md | 37 + ...-peer-registry-cross-node-state-storage.md | 36 + ...key-identity-vs-peer-identity-dissolved.md | 26 + ...036-concrete-persistence-adapter-shapes.md | 63 + ...509-outgoing-only-case-three-peer-roles.md | 60 + ...ransport-standalone-relay-service-scope.md | 46 + ...39-to-openapi-published-spec-versioning.md | 54 + ...st-client-config-and-connection-pooling.md | 54 + .../questions/041-stream-operators-library.md | 42 + ...ownership-for-runtime-spawned-resources.md | 219 +++ .../043-ttycontrol-as-a-clone-trait-object.md | 27 + .../questions/044-terminal-modes-tty-modes.md | 22 + ...flow-control-for-high-throughput-stdout.md | 22 + .../questions/046-runner-api-surface.md | 22 + .../047-stdin-closure-canonical-signal.md | 21 + .../architecture/open-questions-decompose.md | 54 + .../oq-09-10-blocking-conditions.md | 46 + .../safe-exit-blocker-task-mechanism.md | 73 + 52 files changed, 1504 insertions(+), 1306 deletions(-) create mode 100644 docs/architecture/questions/001-bistream-type-definition.md create mode 100644 docs/architecture/questions/002-authcontext-resolution-timing.md create mode 100644 docs/architecture/questions/003-alpn-string-naming-convention.md create mode 100644 docs/architecture/questions/004-dynamic-handler-registration-at-runtime-vs-static-at-startup.md create mode 100644 docs/architecture/questions/005-multi-connectivity-endpoint.md create mode 100644 docs/architecture/questions/006-server-side-alpn-vs-client-side-alpn.md create mode 100644 docs/architecture/questions/007-call-protocol-scope-within-a-connection.md create mode 100644 docs/architecture/questions/008-vault-integration-point.md create mode 100644 docs/architecture/questions/009-wasm-target-boundaries.md create mode 100644 docs/architecture/questions/010-git-adapter-scope-smart-protocol-only-or-full-server.md create mode 100644 docs/architecture/questions/011-handler-level-auth-resolution-observability.md create mode 100644 docs/architecture/questions/012-tls-identity-provisioning-in-alknetendpoint.md create mode 100644 docs/architecture/questions/013-operation-path-format-and-routing-scope.md create mode 100644 docs/architecture/questions/014-batch-operation-semantics.md create mode 100644 docs/architecture/questions/015-call-protocol-client-and-adapter-contract.md create mode 100644 docs/architecture/questions/016-safe-vault-operations-for-call-protocol-exposure.md create mode 100644 docs/architecture/questions/017-abort-cascade-semantics-for-nested-calls.md create mode 100644 docs/architecture/questions/018-privilege-model-and-authority-context.md create mode 100644 docs/architecture/questions/019-session-scoped-operation-registries-and-agent-written-operations.md create mode 100644 docs/architecture/questions/020-salt-kdf-and-encryption-key-derivation-method.md create mode 100644 docs/architecture/questions/021-remote-vault-administration.md create mode 100644 docs/architecture/questions/022-key-rotation-mechanism.md create mode 100644 docs/architecture/questions/023-handler-identity-registration-path-and-composition-authority.md create mode 100644 docs/architecture/questions/024-operation-error-schemas.md create mode 100644 docs/architecture/questions/025-remote-safe-marking-shape-for-callclient-peer-scoped-filtering-dissolved-by-adr-029.md create mode 100644 docs/architecture/questions/026-operationadapter-error-type-adaptererror-variants.md create mode 100644 docs/architecture/questions/027-from-call-re-import-trigger.md create mode 100644 docs/architecture/questions/028-from-call-namespace-collision-behavior.md create mode 100644 docs/architecture/questions/029-callclient-tls-client-auth-and-remote-identity-verification.md create mode 100644 docs/architecture/questions/030-peerref-any-routing-policy.md create mode 100644 docs/architecture/questions/031-services-list-peers-re-export-semantics.md create mode 100644 docs/architecture/questions/032-multi-hop-federation.md create mode 100644 docs/architecture/questions/033-peerid-cryptographic-identity-vs-stable-logical-identifier.md create mode 100644 docs/architecture/questions/034-persistent-peer-registry-cross-node-state-storage.md create mode 100644 docs/architecture/questions/035-api-key-identity-vs-peer-identity-dissolved.md create mode 100644 docs/architecture/questions/036-concrete-persistence-adapter-shapes.md create mode 100644 docs/architecture/questions/037-x-509-outgoing-only-case-three-peer-roles.md create mode 100644 docs/architecture/questions/038-webtransport-standalone-relay-service-scope.md create mode 100644 docs/architecture/questions/039-to-openapi-published-spec-versioning.md create mode 100644 docs/architecture/questions/040-reqwest-client-config-and-connection-pooling.md create mode 100644 docs/architecture/questions/041-stream-operators-library.md create mode 100644 docs/architecture/questions/042-dynamic-resource-ownership-for-runtime-spawned-resources.md create mode 100644 docs/architecture/questions/043-ttycontrol-as-a-clone-trait-object.md create mode 100644 docs/architecture/questions/044-terminal-modes-tty-modes.md create mode 100644 docs/architecture/questions/045-flow-control-for-high-throughput-stdout.md create mode 100644 docs/architecture/questions/046-runner-api-surface.md create mode 100644 docs/architecture/questions/047-stdin-closure-canonical-signal.md create mode 100644 tasks/architecture/open-questions-decompose.md create mode 100644 tasks/architecture/oq-09-10-blocking-conditions.md create mode 100644 tasks/architecture/safe-exit-blocker-task-mechanism.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 658ec40..ccd880f 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -29,7 +29,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c | Document | Status | Description | |----------|--------|-------------| | [overview.md](overview.md) | draft | Workspace-level overview, crate graph, shared types, design principles | -| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker with door-type classifications | +| [open-questions.md](open-questions.md) | draft | OQ index — theme-grouped tables + Deferred/Blocked section; per-OQ files in [`questions/`](open-questions/questions/) | | [crates/core/README.md](crates/core/README.md) | draft | alknet-core crate index | | [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection, BiStream, StreamError | | [crates/core/endpoint.md](crates/core/endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, shutdown | @@ -120,70 +120,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c ## Open Questions -See [open-questions.md](open-questions.md) for the full tracker. - -**Resolved one-way doors:** -- **OQ-01**: BiStream type — trait with Connection parameter (ADR-007) -- **OQ-02**: AuthContext timing — hybrid model (ADR-004) -- **OQ-03**: ALPN naming — `alknet/` prefix, no version (ADR-006) -- **OQ-05**: Multi-connectivity endpoint — quinn + iroh, both feature-gated (ADR-010) -- **OQ-06**: ALPN per connection, not per stream (ADR-006) -- **OQ-08**: Vault integration — CLI-embedded, assembly-layer only (ADR-008, ADR-014) -- **OQ-16**: Safe vault operations for call protocol exposure — none for now (ADR-014) -- **OQ-18**: Privilege model — `internal` = authority switch, External/Internal visibility, handler identity + scoped env (ADR-015) -- **OQ-17**: Abort cascade — `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in (ADR-016) -- **OQ-15**: Call protocol client and adapter contract — `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction (ADR-017) - -**Resolved two-way doors:** -- **OQ-04**: Dynamic handler registration — static at startup (ADR-010); scoped to the `HandlerRegistry` (ALPN-level) by ADR-024, which governs `OperationRegistry` mutability separately -- **OQ-07**: Call protocol scope — bidirectional streams, EventEnvelope, ID-based correlation (ADR-012) -- **OQ-11**: Handler-level auth resolution observability — handlers store resolved identity on Connection (Option B); two identity scopes: connection-level (observability) and per-request (ACL) -- **OQ-12**: TLS identity provisioning — two use cases: RFC 7250 raw keys (default, P2P) and X.509 certs (domain-hosted, browsers). ACME designed in ADR-027; RawKey decoupled from iroh feature. -- **OQ-13**: Operation path format — `/{service}/{op}` is the correct design for alknet-call, not a simplification -- **OQ-14**: Batch operation semantics — multiple correlated `call.requested` events is the correct protocol design, not a simplification -- **OQ-19**: Session-scoped registries — agent-written operations via `OperationEnv` trait layering; protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-024 to cover connection-scoped overlays as well. -- **OQ-20**: Encryption key derivation — HD derivation from BIP39 seed, not PBKDF2; salt field unused in v2 (wire-format compat) (ADR-020) -- **OQ-21**: Remote vault access — resolved (ADR-025): vault is local-only by construction; remote access requires a separate vault-server crate with its own ADR -- **OQ-22**: Key rotation — version-indexed derivation paths; `rotate` method re-encrypts (ADR-021) -- **OQ-23**: Handler identity registration path — registration bundle with provenance, composition authority, scoped env, capabilities (ADR-022) -- **OQ-24**: Operation error schemas — declared domain errors with typed `details` payload; adapter fidelity for `from_openapi`/`to_openapi` (ADR-023) -- **OQ-40**: reqwest client config and connection pooling — `ClientWithMiddleware` + `RetryTransientMiddleware` + inlined `RetryAfterMiddleware`; rebuild-and-swap hot-reload; per-request credential injection; agent-crate SSE normalization sits on top of the client, doesn't replace it - -**Resolved by the storage/repo-pattern ADRs (ADR-030–033):** -- **OQ-33**: ~~PeerId stability~~ — **resolved by ADR-030** (logical id; source is `Identity.id` = `PeerEntry.peer_id`, stable across key rotation; UUID workaround removed) -- **OQ-34**: ~~Persistent peer registry~~ — **resolved by ADR-030 + ADR-031 + ADR-033** (storage boundary: core defines repo traits + in-memory defaults; persistence adapters are separate crates) -- **OQ-35**: ~~API key asymmetry~~ — **dissolved** (the framing was wrong; `PeerEntry` supports multiple credential paths) - -**Resolved by the call-completion / ADR-029 work:** -- **OQ-27**: ~~`from_call` re-import trigger~~ — **resolved** (auto-re-import on connection establishment; `refresh()` is a feature addition) -- **OQ-28**: ~~`from_call` namespace collision~~ — **resolved** (same-peer collision = error; cross-peer dissolved by ADR-029) -- **OQ-29**: ~~CallClient TLS client-auth~~ — **resolved** (wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization to `ed25519:` across quinn/iroh) -- **OQ-30**: ~~`PeerRef::Any` routing policy~~ — **resolved** (insertion-order first-match; richer routing is a feature extension) -- **OQ-31**: ~~`services/list-peers` re-export semantics~~ — **resolved** (opt-in `services/list-peers`; `services/list` is "own ops only") - -**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~~ — **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) -- **OQ-38**: WebTransport standalone relay service scope — the standalone relay (future `alknet-relay`, fork of iroh-relay with WebTransport proxy fallback) is distinct from the in-process ALPN-stream-proxy (ADR-040); scope question, not deferral -- **OQ-39**: ~~`to_openapi` published-spec versioning~~ — **resolved by ADR-045** (`info.version` semver tracks the gateway endpoint contract, not the operation set; per-caller operations discovered via `/search`) -- **OQ-41**: Stream operators library — a handler-level utility library (filter, map, batch, dedupe, window, etc. on `BoxStream`), prior art in `@alkdev/pubsub/operators.ts`; feature extension, not an architectural decision (the architecture decision — stream composition is handler-level, not protocol-level — is made in ADR-049) - -**Resolved (blocks lifted, ADR drafting can proceed):** -- **OQ-42**: Dynamic resource ownership for runtime-spawned resources — **resolved**. Storage reuses the repo/adapter pattern (ADR-033, fourth instance); integration is Option 2 (`AccessControl::check` consults an ownership provider directly, `OperationSpec` gains `resource_id_path`); access pattern is proxy-only (spawner owns, proxy to share, teardown revokes; no grant mechanism in core — "poking holes" is a downstream-app concern, additive if ever needed). Four edge specifics pinned: `list` = scope-gate + result-filter; teardown = automatic, handler-driven; fleet = per-node ownership, downstream app tracks "who is this for"; composition = two orthogonal checks, ADR-015/022 unchanged. Ready for ADR drafting; dependent crate specs (docker, tty, runner, fleet) can declare their `AccessControl` shapes against this model. - -**Resolved by the alknet-tty spec set (ADR-052–055):** -- **OQ-43**: `TtyControl` as a `Clone` trait object — **resolved**. `Arc`-backed `Clone` newtype; confirmed by the local-PTY POC's concrete `PtyControl`. -- **OQ-47**: Stdin closure canonical signal — **resolved**. Either a zero-length stdin chunk or a `{"type":"eof"}` control chunk; both accepted; `eof` recommended. - -**Deferred (not active):** -- **OQ-09**: WASM target boundaries — design constraint, not deliverable -- **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later -- **OQ-44**: Terminal modes (TTY modes) — `TerminalParams.modes` reserved; default terminal modes suffice for the current scope; blocked on a concrete mode-control use case. -- **OQ-46**: Runner API surface — the runner mechanism (pipe mode) is in alknet-tty; runner policy (job management, log persistence, task graph) is a downstream crate, not in scope; blocked on a concrete runner-policy use case. - -**Open (low risk, not blocking):** -- **OQ-45**: Flow control for high-throughput stdout — QUIC per-stream flow control is expected to suffice; a high-volume POC would confirm. +Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (47 OQs across 15 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](open-questions/questions/) (`NNN-slug.md`, mirroring the ADR convention). ## Document Lifecycle diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index a591a54..b7125b9 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -5,13 +5,19 @@ last_updated: 2026-07-06 # Open Questions -Questions are organized by theme. Each question has a stable OQ-ID for cross-referencing from spec documents. +Each open question lives in its own file under [`questions/`](questions/), +named `NNN-slug.md` (mirroring the ADR convention). This file is the index: +theme-grouped tables for scannability, plus a cross-theme +[Deferred / Blocked](#deferred--blocked) section that surfaces the +safe-exit deferrals with their blocking conditions inline — so "what's +currently parked and why" is answerable at a glance. **Status values**: - `open` — Needs to be resolved now. Has a clear path to resolution. - `resolved` — Decided. The resolution is stated cleanly, without caveats about how it could be changed later. - `deferred(scope)` — Cannot be resolved yet. The information doesn't exist. Has a concrete blocking condition (e.g., "blocked on: alknet-agent crate spec"). Not a failure — scope management. - `partially resolved` — Some aspects decided, others deferred or open. +- `dissolved` — The question was reframed out of existence (e.g., superseded by an ADR that retires the premise). Kept for reference. Door type classifications follow ADR-009 — they describe **reversal cost** (how expensive it is to undo), not urgency: - **One-way door**: Reversal requires rewriting significant code or permanently closes a capability. Getting it wrong is expensive — requires ADR before implementation. @@ -19,1292 +25,172 @@ Door type classifications follow ADR-009 — they describe **reversal cost** (ho Door type is separate from whether a decision is made. A two-way door is a decision you make now and can revert later, not a decision to defer. See ADR-009 §"What this framework is NOT." -## Theme: Core Types +## By Theme -### OQ-01: BiStream Type Definition +### Core Types -- **Origin**: [overview.md](overview.md) -- **Status**: resolved -- **Door type**: One-way -- **Priority**: high -- **Resolution**: BiStream is a trait (`AsyncRead + AsyncWrite + Send + Unpin`). Handlers receive a `Connection` (not a single BiStream). This preserves the WASM door — browser clients can implement BiStream over WebTransport streams. See ADR-007. -- **Cross-references**: ADR-002, ADR-007, ADR-009 +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-01](questions/001-bistream-type-definition.md) | BiStream Type Definition | resolved | one | high | +| [OQ-02](questions/002-authcontext-resolution-timing.md) | AuthContext Resolution Timing | resolved | one | high | -### OQ-02: AuthContext Resolution Timing +### ALPN and Routing -- **Origin**: [overview.md](overview.md) -- **Status**: resolved -- **Door type**: One-way -- **Priority**: high -- **Resolution**: Hybrid model (Option C) — endpoint resolves what it can (e.g., TLS client certificate), handler resolves what it must (e.g., AuthToken in first frame). AuthContext may be partial when `handle()` is called. See ADR-004. -- **Cross-references**: ADR-002, ADR-004 +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-03](questions/003-alpn-string-naming-convention.md) | ALPN String Naming Convention | resolved | one | med | +| [OQ-04](questions/004-dynamic-handler-registration-at-runtime-vs-static-at-startup.md) | Dynamic Handler Registration at Runtime vs Static at Startup | resolved | two | low | -## Theme: ALPN and Routing +### Transport and Endpoint -### OQ-03: ALPN String Naming Convention +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-05](questions/005-multi-connectivity-endpoint.md) | Multi-Connectivity Endpoint | resolved | one | high | +| [OQ-06](questions/006-server-side-alpn-vs-client-side-alpn.md) | Server-Side ALPN vs Client-Side ALPN | resolved | one | low | -- **Origin**: [overview.md](overview.md) -- **Status**: resolved -- **Door type**: One-way -- **Priority**: medium -- **Resolution**: Custom ALPNs use `alknet/` prefix (no version), standard ALPNs use IANA strings. No version negotiation initially. See ADR-006. -- **Cross-references**: ADR-001, ADR-006 +### Call Protocol -### OQ-04: Dynamic Handler Registration at Runtime vs Static at Startup +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-07](questions/007-call-protocol-scope-within-a-connection.md) | Call Protocol Scope Within a Connection | resolved | two | med | -- **Origin**: [overview.md](overview.md) -- **Status**: resolved -- **Door type**: Two-way -- **Priority**: low -- **Resolution**: Static registration at startup. `HandlerRegistry` is immutable after construction. ALPN strings in the TLS `ServerConfig` are derived from the registry at startup. See ADR-010. +### Security - **Scope clarification (ADR-024)**: This resolution applies to the - **`HandlerRegistry`** (ALPN string → `ProtocolHandler`), which is what - ADR-010 governs. The call protocol's **`OperationRegistry`** (operation - name → `HandlerRegistration`) is a *separate* registry living inside the - `CallAdapter`, behind the single ALPN `alknet/call`. Its mutability - profile is governed by ADR-024, not by this OQ. ADR-024 layers the - operation registry by trust boundary: curated `Local` ops are immutable - (same rationale as here — composing ops are privileged, the startup trust - boundary is where their authority is granted); `Session` and imported - (`FromCall` etc.) ops are dynamic at their respective trust-boundary - scopes (session, connection). The pre-ADR-024 blanket immutability claim - in `operation-registry.md` was inherited by analogy from this OQ and did - not actually apply — the TLS-config argument that justifies - `HandlerRegistry` immutability does not touch the `OperationRegistry`. -- **Cross-references**: ADR-001, ADR-010, ADR-024, [endpoint.md](crates/core/endpoint.md), [operation-registry.md](crates/call/operation-registry.md) +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-08](questions/008-vault-integration-point.md) | Vault Integration Point | resolved | one | med | -## Theme: Transport and Endpoint +### Deferred Questions -### OQ-05: Multi-Connectivity Endpoint +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-09](questions/009-wasm-target-boundaries.md) | WASM Target Boundaries | deferred | one | low | +| [OQ-10](questions/010-git-adapter-scope-smart-protocol-only-or-full-server.md) | Git Adapter Scope — Smart Protocol Only or Full Server? | deferred | two | low | -- **Origin**: [overview.md](overview.md) -- **Status**: resolved -- **Door type**: One-way -- **Priority**: high -- **Resolution**: `AlknetEndpoint` supports both `quinn::Endpoint` (public QUIC+TLS) and `iroh::Endpoint` (P2P relay-assisted) simultaneously, both optional and feature-gated. Both produce QUIC connections that dispatch through the same `HandlerRegistry` by ALPN string. These are not interchangeable transports — they serve fundamentally different deployment contexts (public IP vs NAT traversal). TCP is not an endpoint concern — bare TCP SSH is handled by the SSH handler directly. See ADR-010. -- **Cross-references**: ADR-001, ADR-010, [endpoint.md](crates/core/endpoint.md) +### alknet-core -### OQ-06: Server-Side ALPN vs Client-Side ALPN +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-11](questions/011-handler-level-auth-resolution-observability.md) | Handler-Level Auth Resolution Observability | resolved | two | med | +| [OQ-12](questions/012-tls-identity-provisioning-in-alknetendpoint.md) | TLS Identity Provisioning in AlknetEndpoint | resolved | one | high | +| [OQ-13](questions/013-operation-path-format-and-routing-scope.md) | Operation Path Format and Routing Scope | resolved | two | med | +| [OQ-14](questions/014-batch-operation-semantics.md) | Batch Operation Semantics | resolved | two | low | -- **Origin**: ADR-001 -- **Status**: resolved -- **Door type**: One-way -- **Priority**: low -- **Resolution**: One ALPN per connection. Clients open one QUIC connection per ALPN. QUIC connections are cheap (multiplexed over the same UDP flow). See ADR-006. -- **Cross-references**: ADR-001, ADR-006 +### alknet-call -## Theme: Call Protocol +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-15](questions/015-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | resolved | one | high | +| [OQ-16](questions/016-safe-vault-operations-for-call-protocol-exposure.md) | Safe Vault Operations for Call Protocol Exposure | resolved | one | high | +| [OQ-17](questions/017-abort-cascade-semantics-for-nested-calls.md) | Abort Cascade Semantics for Nested Calls | resolved | one/two | high | +| [OQ-18](questions/018-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | resolved | one/two | high | +| [OQ-19](questions/019-session-scoped-operation-registries-and-agent-written-operations.md) | Session-Scoped Operation Registries and Agent-Written Operations | resolved | one/two | med | -### OQ-07: Call Protocol Scope Within a Connection +### alknet-vault -- **Origin**: ADR-005 -- **Status**: resolved -- **Door type**: Two-way -- **Priority**: medium -- **Resolution**: The call protocol uses bidirectional QUIC streams with EventEnvelope framing and ID-based correlation via PendingRequestMap. The protocol is stream-agnostic — the client can open one stream per operation, multiplex on one stream, or any mix. Correlation is by request ID, not by stream. Both sides can initiate calls. One `alknet/call` connection gives access to the full operation registry (call, subscribe, batch, schema). No multiplexing layer is needed inside the connection. See ADR-012. -- **Cross-references**: ADR-005, ADR-012 +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-20](questions/020-salt-kdf-and-encryption-key-derivation-method.md) | Salt/KDF and Encryption Key Derivation Method | resolved | one/two | high | +| [OQ-21](questions/021-remote-vault-administration.md) | Remote Vault Administration | resolved | one | med | +| [OQ-22](questions/022-key-rotation-mechanism.md) | Key Rotation Mechanism | resolved | one/two | med | +| [OQ-23](questions/023-handler-identity-registration-path-and-composition-authority.md) | Handler Identity Registration Path and Composition Authority | resolved | one/two | high | +| [OQ-24](questions/024-operation-error-schemas.md) | Operation Error Schemas | resolved | one/two | high | -## Theme: Security +### Call Client and Adapters -### OQ-08: Vault Integration Point +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-25](questions/025-remote-safe-marking-shape-for-callclient-peer-scoped-filtering-dissolved-by-adr-029.md) | ~~Remote-Safe Marking Shape for CallClient Peer-Scoped Filtering~~ (Dissolved by ADR-029) | dissolved | one/two | med | +| [OQ-26](questions/026-operationadapter-error-type-adaptererror-variants.md) | OperationAdapter Error Type (AdapterError Variants) | resolved | two | med | +| [OQ-27](questions/027-from-call-re-import-trigger.md) | from_call Re-Import Trigger | resolved | two | low | +| [OQ-28](questions/028-from-call-namespace-collision-behavior.md) | from_call Namespace Collision Behavior | resolved | two | low | +| [OQ-29](questions/029-callclient-tls-client-auth-and-remote-identity-verification.md) | CallClient TLS Client-Auth and Remote-Identity Verification | resolved | one/two | high | +| [OQ-30](questions/030-peerref-any-routing-policy.md) | PeerRef::Any Routing Policy | resolved | two | low | +| [OQ-31](questions/031-services-list-peers-re-export-semantics.md) | services/list-peers Re-Export Semantics | resolved | two | low | +| [OQ-32](questions/032-multi-hop-federation.md) | Multi-Hop Federation | deferred(scope) | one/two | low | +| [OQ-33](questions/033-peerid-cryptographic-identity-vs-stable-logical-identifier.md) | PeerId — Cryptographic Identity vs Stable Logical Identifier | resolved | one/two | high | +| [OQ-34](questions/034-persistent-peer-registry-cross-node-state-storage.md) | Persistent Peer Registry (Cross-Node State Storage) | resolved | one/two | med | -- **Origin**: [overview.md](overview.md) -- **Status**: resolved -- **Door type**: One-way -- **Priority**: medium -- **Resolution**: CLI-embedded, assembly-layer only. The CLI binary instantiates `VaultServiceHandle` locally at startup, derives and decrypts the credentials each handler needs, and injects them into handler capabilities. alknet-vault has no ALPN, no alknet-core dependency, and no operations registered in the call protocol. The master seed and derived private keys never cross the network. The vault is a capability source, not a network service. See ADR-008 and ADR-014. -- **Cross-references**: ADR-003, ADR-005, ADR-008, ADR-014 +### Storage and Adapters -## Deferred Questions +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-35](questions/035-api-key-identity-vs-peer-identity-dissolved.md) | ~~API Key Identity vs Peer Identity~~ (Dissolved) | dissolved | one | med | +| [OQ-36](questions/036-concrete-persistence-adapter-shapes.md) | Concrete Persistence Adapter Shapes | resolved | two | med | -These questions are acknowledged but not active. They will be promoted to open when their crate is being specified. +### TLS Identity + +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-37](questions/037-x-509-outgoing-only-case-three-peer-roles.md) | X.509 Outgoing-Only Case (Three Peer Roles) | resolved | one | med | + +### alknet-http + +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-38](questions/038-webtransport-standalone-relay-service-scope.md) | WebTransport Standalone Relay Service Scope | open | one/two | low | +| [OQ-39](questions/039-to-openapi-published-spec-versioning.md) | `to_openapi` Published-Spec Versioning | resolved | one/two | med | +| [OQ-40](questions/040-reqwest-client-config-and-connection-pooling.md) | reqwest Client Config and Connection Pooling | resolved | two | low | +| [OQ-41](questions/041-stream-operators-library.md) | Stream Operators Library | deferred(scope) | two | low | + +### Runtime-Spawned Resources and Ownership + +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-42](questions/042-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership for Runtime-Spawned Resources | resolved | one | high | + +### alknet-tty + +| OQ | Title | Status | Door | Pri | +|----|-------|--------|------|-----| +| [OQ-43](questions/043-ttycontrol-as-a-clone-trait-object.md) | `TtyControl` as a `Clone` trait object | resolved | one | med | +| [OQ-44](questions/044-terminal-modes-tty-modes.md) | Terminal Modes (TTY modes) | deferred(scope) | two | low | +| [OQ-45](questions/045-flow-control-for-high-throughput-stdout.md) | Flow Control for High-Throughput stdout | open | two | low | +| [OQ-46](questions/046-runner-api-surface.md) | Runner API Surface | deferred(scope) | two | low | +| [OQ-47](questions/047-stdin-closure-canonical-signal.md) | Stdin Closure Canonical Signal | resolved | two | low | + +## Deferred / Blocked + +The safe-exit visibility surface. These questions are parked because the +information needed to resolve them does not exist yet — each has a concrete +blocking condition. They are not failures; they are scope management. See +ADR-009 §"Safe Exit: Deferred Decisions." This section exists so "what's +currently blocking the architect" is answerable at a glance, not by +filtering the tables above. ### OQ-09: WASM Target Boundaries -- **Origin**: [overview.md](overview.md) -- **Status**: deferred -- **Door type**: One-way (when applicable) +- **Blocked on**: _(no explicit blocking condition recorded — see full file)_ - **Priority**: low -- **Resolution**: Not an active question — WASM compatibility is a design constraint (see ADR-009, overview.md design principles), not a deliverable. Specific WASM targeting decisions will be made when individual crates are implemented. **BiStream being a trait preserves the *client-side* stream door** — a browser can implement BiStream over WebTransport streams. **The *server-side* dispatch door is NOT preserved by ADR-007 and is a known, accepted closure**: `Connection` is a concrete quinn-bound struct (not a trait), the accept loop uses `tokio::spawn` (tokio does not run on WASM), and the call-protocol dispatch internals (`PendingRequestMap`, `CallAdapter`) use tokio `oneshot`/`mpsc` channels. A WASM server-side peer would require a `Connection` trait and a runtime-abstracted accept loop — not planned. The browser path is client-side via a JS SDK, not server-side Rust-to-WASM. This is an explicit one-way door, not an oversight. -- **Cross-references**: ADR-007, ADR-009 +- **Full file**: [OQ-09](questions/009-wasm-target-boundaries.md) ### OQ-10: Git Adapter Scope — Smart Protocol Only or Full Server? -- **Origin**: [overview.md](overview.md) -- **Status**: deferred -- **Door type**: Two-way +- **Blocked on**: _(no explicit blocking condition recorded — see full file)_ - **Priority**: low -- **Resolution**: Deferred per the cleanup plan. Start with git smart protocol over QUIC streams. ERC721 integration and full server capabilities are additive. **Composability fork (review #002 W18)**: whether git operations are registered in the `OperationRegistry` and callable via `env.invoke()`, or only available as raw smart protocol on `alknet/git`, is a separate decision from ERC721 scope. The path of least resistance (raw smart protocol only) forecloses agent composition of git operations — an agent handler that wants to compose `git/clone` cannot, because there's no `OperationSpec`, no `Handler`, no registration. To make git composable, a call-protocol projection (a set of `HandlerRegistration` bundles wrapping git operations behind the registry) must be built alongside or instead of the raw handler. Resolve this when speccing alknet-git, not deferred past it. -- **Cross-references**: ADR-001 - -## Theme: alknet-core - -### OQ-11: Handler-Level Auth Resolution Observability - -- **Origin**: [auth.md](crates/core/auth.md) -- **Status**: resolved -- **Door type**: Two-way -- **Priority**: medium -- **Resolution**: **Option B — handlers store resolved identity on the Connection.** When a handler resolves identity inside `handle()` (the handler-level auth phase), it calls `connection.set_identity(identity)` to store the resolved `Identity` on the connection object. The endpoint and observability layers can read it later for connection logging, audit trails, and metrics. - - Why not Option A (return identity from `handle()`): it changes the `ProtocolHandler` trait signature for all handlers, even those that don't do auth resolution (DNS, health check). It also assumes one identity per connection — but the call protocol can have different identities per request on the same connection (one connection, multiple `call.requested` events with different auth tokens). Returning a single identity from `handle()` would be misleading for the call protocol. - - Why not Option C (identity stays local): the resolved identity is useful beyond the handler. The endpoint may want to log "connection from X authenticated as Y." A connection-level observability layer needs the identity. If it stays local, every handler that resolves identity would need to duplicate logging logic, and the endpoint can't correlate connections to identities. - - **Two identity scopes exist and must not be conflated:** - - **Connection-level identity** (this decision): set once by the handler in `handle()`, stored on `Connection`, read by the endpoint for logging/observability. This is the "connection owner" — who opened this QUIC connection. - - **Per-request identity** (already in the call protocol spec): set per `call.requested` by the `CallAdapter`, stored on `OperationContext.identity`. This is the "call caller" — who is making this specific call, which may upgrade mid-session (different auth tokens on the same connection). - - Both exist. The connection-level identity is the stable "who is this connection from"; the per-request identity is the dynamic "who is this specific call from." The call protocol's per-request resolution (which may produce a different identity than the connection-level resolution) takes precedence for ACL on `OperationContext` — the connection-level identity is for observability only, not for ACL. - - **C13 resolution (review #002)**: the endpoint does **not** read - `identity()` after `handle()` returns. The `Connection` is moved into the - spawned handler task (endpoint.md), so the endpoint no longer has a - reference to it. Connection-level observability (remote addr, ALPN, - connection ID) is logged by the endpoint *before* the move. Identity-level - observability is logged by the handler (the handler knows which identity - it resolved and can log it). There is no `Arc` sharing or - channel-based identity-reporting mechanism — the simplest honest answer - that avoids over-engineering the observability path before there's a - demonstrated need. If a future use case requires the endpoint to - correlate connections to identities, an `Arc` or a - side-channel can be added then. -- **Cross-references**: ADR-004, ADR-011, ADR-015 (per-request identity on OperationContext), [auth.md](crates/core/auth.md) - -### OQ-12: TLS Identity Provisioning in AlknetEndpoint - -- **Origin**: [endpoint.md](crates/core/endpoint.md), [config.md](crates/core/config.md) -- **Status**: resolved -- **Door type**: One-way -- **Priority**: high -- **Resolution**: TLS identity in alknet has two distinct use cases, not one: - - **Use case 1 — P2P / key-based identity (default for most alknet nodes):** RFC 7250 raw Ed25519 public keys. No domain, no CA, no cert renewal. The Ed25519 public key IS the node's identity. This is the same model iroh uses with its `NodeId`. It works natively with SSH auth (same key type) and git (SSH key-based auth). `TlsIdentity::RawKey(Ed25519SecretKey)` in `StaticConfig` covers this. As of [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md), `RawKey` uses `ed25519_dalek::SigningKey` (via an alknet-core wrapper), **not** `iroh::SecretKey` — so raw-key TLS identity is available in quinn-only builds without the `iroh` feature. - - **Use case 2 — Domain-hosted services (relays, public-facing nodes):** X.509 certificates with domain names. Required for browser/WebTransport clients, which don't support RFC 7250. This has two sub-cases: - - **Manual**: Provide cert/key file paths via `TlsIdentity::X509`. Already specified in `StaticConfig`. - - **ACME auto-provisioning**: Let's Encrypt via `rustls-acme`. `TlsIdentity::Acme { domains, cache_dir, directory, contact }` carries static config; the endpoint constructs the `AcmeState` async state machine at setup time. Feature-gated behind `acme`. Designed in [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md). The reverse-proxy project (`/workspace/@alkdev/reverse-proxy`) demonstrates the proven pattern: `AcmeConfig`, `ResolvesServerCertAcme`, TLS-ALPN-01 challenge handling, automatic renewal. - - **Browser constraint**: Browsers require X.509 and don't support RFC 7250. For browser/WebTransport clients, domain-hosted nodes with X.509 certs are mandatory. All other clients (SSH, git, alknet-native) work with raw keys by default. - - The `TlsIdentity` enum in `StaticConfig` captures all four modes (`X509`, `RawKey`, `SelfSigned`, `Acme`). [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) records the design decisions for ACME integration and RawKey decoupling. -- **Cross-references**: ADR-010, ADR-027, [config.md](crates/core/config.md), [endpoint.md](crates/core/endpoint.md) - -### OQ-13: Operation Path Format and Routing Scope - -- **Origin**: [operation-registry.md](crates/call/operation-registry.md) -- **Status**: resolved -- **Door type**: Two-way -- **Priority**: medium -- **Resolution**: alknet-call uses `/{service}/{op}` (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). The `/{node}/{service}/{op}` pattern from the reference implementation served a head/worker routing model that is a separate architectural concern. Remote dispatch (federation / node-level routing) would be a different mechanism at a different layer, not a prefix added to alknet-call's operation paths. See ADR-005, ADR-012. -- **Cross-references**: ADR-005, ADR-012 - -### OQ-14: Batch Operation Semantics - -- **Origin**: [call-protocol.md](crates/call/call-protocol.md) -- **Status**: resolved -- **Door type**: Two-way -- **Priority**: low -- **Resolution**: Batch is a client-side pattern — multiple `call.requested` events with correlated IDs, responses arrive independently. QUIC's stream multiplexing provides the concurrency and ordering guarantees that batch would need. Batch-specific event types (e.g., `batch.requested`, `batch.responded`) would add protocol complexity without clear benefit over sending multiple `call.requested` events. See ADR-012. -- **Cross-references**: ADR-012 - -## Theme: alknet-call - -### OQ-15: Call Protocol Client and Adapter Contract - -- **Origin**: [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md), ADR-013 -- **Status**: resolved -- **Door type**: One-way -- **Priority**: high -- **Resolution**: `CallClient` opens QUIC connections and shares the dispatch loop with `CallAdapter` — both sides can send and receive `call.requested` once connected. Connection direction (who opened the connection) is independent of call direction (who calls whom). `from_call` adapter discovers remote operations via `services/list` + `services/schema` and registers them with forwarding handlers — same pattern as `from_openapi` and `from_mcp`. `to_openapi` and `to_mcp` project local operations to external protocols. Adapter contract trait (`OperationAdapter`) produces `(OperationSpec, Handler)` pairs. Cross-node call tree: abort cascade (ADR-016) propagates across node boundaries through `from_call` handlers. Credentials for connections come from capabilities (ADR-014). Adapter-registered operations are `Internal` by default (ADR-015). See ADR-017. -- **Cross-references**: ADR-005, ADR-013, ADR-014, ADR-015, ADR-016, ADR-017, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) - -### OQ-16: Safe Vault Operations for Call Protocol Exposure - -- **Origin**: [operation-registry.md](crates/call/operation-registry.md), ADR-008 -- **Status**: resolved -- **Door type**: One-way -- **Priority**: high -- **Resolution**: No vault operations are exposed over the call protocol. The vault is accessed only at the assembly layer (CLI binary at startup). Handlers receive secret material through `OperationContext.capabilities`, not by calling vault operations over the wire. The `operation-registry.md` spec previously showed `vault/derive`, `vault/unlock`, and `vault/decrypt` registered as call protocol operations — that was a contradiction with ADR-008's "capability source" model and has been corrected. See ADR-014. -- **Cross-references**: ADR-008, ADR-014, [operation-registry.md](crates/call/operation-registry.md) - -### OQ-17: Abort Cascade Semantics for Nested Calls - -- **Origin**: [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) -- **Status**: resolved -- **Door type**: One-way (protocol schema), two-way (mechanism) -- **Priority**: high -- **Resolution**: `call.aborted` cascades to all non-terminal descendants in the call tree. The CallAdapter walks the tree (indexed by `parent_request_id` in `PendingRequestMap`) and sends `call.aborted` for each descendant. Default policy is `abort-dependents` (abort everything downstream); `continue-running` is an opt-in for long-running work that should survive a parent's abort. Handlers clean up via Rust's async drop semantics (future dropped → `Drop` guards release resources). The cascade is protocol-level (server discovers descendants and propagates); the mechanism (parent-indexed map, cancellation tokens, or a separate graph) is a two-way door. See ADR-016. -- **Cross-references**: ADR-012, ADR-015, ADR-016, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) - -### OQ-18: Privilege Model and Authority Context - -- **Origin**: [operation-registry.md](crates/call/operation-registry.md) -- **Status**: resolved -- **Door type**: One-way (ACL model), two-way (specific APIs) -- **Priority**: high -- **Resolution**: The `internal` flag on `OperationContext` marks calls that originated from composition (a handler calling another operation via `OperationEnv`), as opposed to external calls that arrived as `call.requested` from a wire client. The `internal` flag switches the authority context: the ACL check runs against the composing handler's identity (set at registration), not the caller's identity and not as a blanket skip. This replaces the previous `trusted` flag, which skipped ACL entirely — a privilege escalation vector. Operations have External/Internal visibility. Internal operations return `NOT_FOUND` when called from the wire and are excluded from `services/list`. The composition env is scoped — a handler can only invoke a declared set of operations. Handler identity is carried on `OperationContext` alongside caller identity (the principal/agent pair). See ADR-015. -- **Cross-references**: ADR-014, ADR-015, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) - -### OQ-19: Session-Scoped Operation Registries and Agent-Written Operations - -- **Origin**: [operation-registry.md](crates/call/operation-registry.md) -- **Status**: resolved -- **Door type**: Two-way (protocol doesn't need changes), one-way (if implementation closes the door) -- **Priority**: medium -- **Resolution**: The call protocol supports session-scoped registries through `OperationEnv` trait layering. No protocol changes needed. The pattern is documented here and in [operation-registry.md](crates/call/operation-registry.md) to prevent an implementation from accidentally closing it. - - The registry model has three tiers: - - | Tier | Scope | Lifetime | Visibility | Who populates it | - |------|-------|----------|------------|-------------------| - | Core (global) | All sessions | Process lifetime, static at startup | External + Internal (curated) | Assembly layer at startup | - | Session | One session | Session lifetime, dynamic | Internal only (never wire-facing) | Agent during session (sandbox) | - | Promotion | Session → Core | One-time transition | Manual/curated review | Human or architect agent reviews, then redeploys | - - Session-scoped operations are always `Internal` (ADR-015), run under the handler's identity (the agent handler that authorized the sandbox), can only compose operations in the handler's scoped env, and are ephemeral (gone when the session ends). Core operations are curated — reviewed before promotion. The promotion path is the curation checkpoint where autonomous (session-scoped) becomes curated (core). This is not auto-promotion. - - **Implementation guard**: `OperationEnv` must remain a trait, not a concrete type. A session-scoped env wraps the global env (check session registry first, fall through to global). Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close this pattern. The static registration constraint (OQ-04) applies to the curated (Layer 0) registry only; session registries are dynamic by nature and are a different registry overlaying the curated one. **Generalized by ADR-024**: connection-scoped remote imports (`from_call`) use the same overlay mechanism as session-scoped ops. Both are per-scope dynamic overlays on the static curated base, composed into the per-call `OperationContext.env` by the `CallAdapter`. `OperationEnv` being a trait object (`Arc`) is what enables both overlay patterns. - - Session-scoped operations run in a locked-down sandbox (no direct net/fs/env access), can only reach operations in the handler's scoped env, and their output should be validated against their declared schema before returning. The promotion path requires review — an agent with a `promote` scope (the architect role) performs the promotion; the writing agent (lower-privileged role) requests it. This is the role-based escalation pattern (ADR-015): privileges escalate through a chain of command, not through direct authority. - - The agent-specific mechanism (quickjs sandbox, session registry lifecycle, promotion workflow) belongs to the agent crate spec. The call protocol's job is to keep the `OperationEnv` trait composable and the visibility/ACL model consistent across tiers. -- **Cross-references**: OQ-04, ADR-014, ADR-015, ADR-016, ADR-024, [operation-registry.md](crates/call/operation-registry.md) - -## Theme: alknet-vault - -### OQ-20: Salt/KDF and Encryption Key Derivation Method - -- **Origin**: [encryption.md](crates/vault/encryption.md) -- **Status**: resolved -- **Door type**: One-way (key derivation method), two-way (salt field usage) -- **Priority**: high -- **Resolution**: The vault uses SLIP-0010 HD derivation from the BIP39 seed at path `m/74'/2'/0'/0'` to produce the AES-256-GCM encryption key — not PBKDF2. The `salt` field in `EncryptedData` is unused for key derivation (kept for wire-format compatibility with the TS predecessor). The TypeScript `@alkdev/storage` crypto module used PBKDF2 with a password + salt; data encrypted by that method (key_version=1) cannot be decrypted by the vault and must be migrated via one-time re-encryption to key_version=2. See ADR-020 for the full rationale and migration path. -- **Cross-references**: ADR-020, [encryption.md](crates/vault/encryption.md) - -### OQ-21: Remote Vault Administration - -- **Origin**: [service.md](crates/vault/service.md), [protocol.md](crates/vault/protocol.md), ADR-019 -- **Status**: resolved -- **Door type**: One-way (vault crate is local-only by construction) -- **Priority**: medium -- **Resolution**: Remote vault access is **not a feature of the vault crate**. ADR-025 dropped irpc from the vault, making the vault local-only by construction — no `RemoteService` trait, no wire format for vault messages, no default-insecure remote handler. The vault's API is `VaultServiceHandle` (direct method calls), nothing else. - - If remote vault access is ever needed (e.g., the machine→worker pattern), it requires a **separate vault-server crate** that depends on both alknet-core (for `IdentityProvider`, scopes, auth-wrapping) and alknet-vault (for `VaultServiceHandle`). That crate would define its own threat model, access policy, operation filtering (Unlock/Lock local-only), and wire format — and requires its own ADR. This is a deliberate addition, not a flag flip on a default that was already loaded. - - The pre-ADR-025 deferral framed remote access as "non-breaking" (the wire format was additive). That framing was misleading: once workers build dependencies on the remote vault API, disabling it breaks them — the door is operationally one-way even if the wire format is additive. ADR-025 inverts the default: the vault is local-only by construction, and remote access requires building something new, not removing a default. - - Per-node vaults are the recommended pattern for multi-node deployments: each node has its own vault and mnemonic; credentials are encrypted *for* the receiving node's public key, not decrypted centrally. This is end-to-end encryption between nodes, matching ADR-008's "capability source" model. -- **Cross-references**: ADR-005, ADR-008, ADR-014, ADR-018, ADR-019, ADR-025, [protocol.md](crates/vault/protocol.md), [service.md](crates/vault/service.md) - -### OQ-22: Key Rotation Mechanism - -- **Origin**: [encryption.md](crates/vault/encryption.md) -- **Status**: resolved -- **Door type**: One-way (path scheme), two-way (rotation policy) -- **Priority**: medium -- **Resolution**: Key rotation uses version-indexed derivation paths. Each key version maps to a distinct SLIP-0010 path: `m/74'/2'/0'/{version-2}'`. v2 (current) is at `m/74'/2'/0'/0'`; v3 is at `m/74'/2'/0'/1'`; etc. The `decrypt` method derives the key at the path indicated by `encrypted.key_version` (not always at `PATHS::ENCRYPTION`). The `rotate` method decrypts with the old version's key and re-encrypts with the new version's key — no new mnemonic needed. The assembly layer or a migration tool iterates stored blobs and calls `rotate` on each; the vault does not self-rotate. Partial rotation is safe (old keys remain derivable). See ADR-021. -- **Cross-references**: ADR-020, ADR-021, [encryption.md](crates/vault/encryption.md), [service.md](crates/vault/service.md) - -### OQ-23: Handler Identity Registration Path and Composition Authority - -- **Origin**: [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md), ADR-015 -- **Status**: resolved -- **Door type**: One-way (security model), two-way (bundle shape) -- **Priority**: high -- **Resolution**: ADR-015 said handler identity was "set at registration by the assembly layer" but the registration API (`register(spec, handler)`) had no place for it — meaning every internal call would check ACL against `None`, reproducing the escalation gap ADR-015 was written to close. ADR-022 resolves this with a registration bundle (`HandlerRegistration`) carrying `provenance`, `composition_authority` (replacing `handler_identity: Identity` — it's a declared authority bundle, not a peer identity), `scoped_env`, and `capabilities`. The dispatch path (`build_root_context` and `OperationEnv::invoke()`) reads from the bundle. Provenance determines which ops can compose: only `Local` and `Session` get composition authority; leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) get `None` — they don't compose, so they don't need it. Capabilities are per-request on `OperationContext`, populated from the bundle (resolving the closure-capture vs context ambiguity). The kernel/user analogy: user's authority checked once at the External gate; handler's composition authority used for all composition inside; scoped env bounds reachability. No intersection — the user's authority does not limit internal calls. See ADR-022. -- **Cross-references**: ADR-014, ADR-015, ADR-022, docs/reviews/001-pre-implementation-architecture-sanity-check.md (C1–C4), [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md) - -### OQ-24: Operation Error Schemas - -- **Origin**: [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md), ADR-017 -- **Status**: resolved -- **Door type**: One-way (wire format), two-way (mapping mechanism) -- **Priority**: high -- **Resolution**: `OperationSpec` gains `error_schemas: Vec` where each `ErrorDefinition` carries a `code`, `description`, `schema` (JSON Schema for the error detail payload), and optional `http_status` (for adapter projection). The `call.error` payload gains an optional `details` field carrying the typed error payload. Protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, - `INVALID_OPERATION_TYPE`, `INTERNAL`, `TIMEOUT`) are distinct from - operation-level domain codes (`FILE_NOT_FOUND`, `RATE_LIMITED`, etc.) — - protocol codes are emitted by the dispatch machinery, operation codes by - handlers. The six-code protocol-level list was extended from five by - ADR-049 (`INVALID_OPERATION_TYPE`). `from_openapi`/`to_openapi` map OpenAPI response status codes to/from `ErrorDefinition`s, making the adapter contract from ADR-017 faithful on the error axis. `services/schema` exposes `error_schemas` for client code generation. See ADR-023. -- **Cross-references**: ADR-017, ADR-023, docs/reviews/001-pre-implementation-architecture-sanity-check.md (C5), [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md) - -## Theme: Call Client and Adapters - -These open questions are the remainders from the call-completion gap analysis -(`docs/research/alknet-call-completion/gap-analysis.md`, DC-1..4) and the -peer-graph routing research (`docs/research/alknet-call-peer-routing/findings.md`). -ADR-029 supersedes ADR-028 and dissolves OQ-25 and the cross-peer half of -OQ-28. Most of the remaining OQs are now resolved (decisions made, defaults -recorded). OQ-29 is the exception — it's load-bearing on ADR-030 and -requires a decision before the ADR-029 migration lands. OQ-32 (multi-hop) -is a feature extension, not an unmade architecture decision. - -### OQ-25: ~~Remote-Safe Marking Shape for CallClient Peer-Scoped Filtering~~ (Dissolved by ADR-029) - -- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 (§1 Consequences), ADR-028 -- **Status**: **dissolved** (ADR-029) -- **Door type**: ~~Two-way (shape only — existence is one-way, resolved by ADR-028)~~ -- **Priority**: ~~medium~~ -- **Resolution**: **Dissolved by [ADR-029](decisions/029-peer-graph-routing-model.md).** - ADR-028's `remote_safe: bool` / `trusted_peer` model is superseded — it was a - parallel, weaker authorization system that duplicated the existing - `AccessControl`/`Identity` machinery. ADR-029 retires `remote_safe`/ - `trusted_peer` entirely; peer authorization flows through - `AccessControl::check(peer_identity)`. The op's `AccessControl` *is* the - peer-authorization policy — there is no separate marking. Per-peer - differentiation is via `IdentityProvider` config (different peers get - different scopes), not a per-op boolean. The "shape" question is moot - because there is no marking to shape. See ADR-029 §3. -- **Cross-references**: ADR-009, ADR-014, ADR-015, ADR-017, ADR-022, ADR-024, - ~~ADR-028~~ (superseded), ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md), - [operation-registry.md](crates/call/operation-registry.md) - -### OQ-26: OperationAdapter Error Type (AdapterError Variants) - -- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 §5, [ADR-029](decisions/029-peer-graph-routing-model.md) §5 -- **Status**: **resolved** (2026-06-27) -- **Door type**: Two-way -- **Priority**: medium -- **Resolution**: The `AdapterError` enum is `#[non_exhaustive]` + - `thiserror::Error`, with these v1 variants: - - `DiscoveryFailed { message: String }` — `from_call` remote unreachable / `services/list` failed - - `SchemaParse { message: String }` — `from_openapi` / `from_jsonschema` couldn't parse the spec - - `Transport { message: String }` — underlying transport error (QUIC for `from_call`, HTTP for `from_openapi`/`from_mcp`) - - `Unauthorized { message: String }` — HTTP 401 for `from_openapi`/`from_mcp`, auth rejected for `from_call` - - `SamePeerCollision { message: String }` — namespace collision *within a single peer* (ADR-029 §5: cross-peer collision dissolves; same-peer collision stays an error). Replaces the flat `Conflict` variant from the pre-ADR-029 implementation. - - `#[non_exhaustive]` lets `alknet-http`'s adapters extend without breaking - match arms. The variant payloads are `String` messages — kept simple and - `Send + Sync` by construction. This matches the shipped implementation - (`crates/alknet-call/src/client/mod.rs`) except `Conflict` → - `SamePeerCollision` (the ADR-029 migration renames it). Two-way door: - adding variants later is non-breaking; renaming a variant is a match-arm - update but not an architectural change. -- **Cross-references**: ADR-017, ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md) - -### OQ-27: from_call Re-Import Trigger - -- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 Assumption 4 -- **Status**: **resolved** (2026-06-27) -- **Door type**: Two-way -- **Priority**: low -- **Resolution**: The decision is **auto-re-import on connection - establishment**. The overlay is per-connection (Layer 2, ADR-024), so a - stale overlay dies with the connection; re-import on reconnect is - naturally scoped to the new connection. This is the right default for the - runner pattern (a worker reconnects → the hub re-discovers the worker's - ops automatically). An explicit `CallConnection::refresh()` method is a - genuine feature addition — non-breaking, additive — if a deployment - needs manual control. -- **Cross-references**: ADR-017, ADR-024, [client-and-adapters.md](crates/call/client-and-adapters.md) - -### OQ-28: from_call Namespace Collision Behavior - -- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 §3 -- **Status**: **resolved** (2026-06-27) -- **Door type**: Two-way -- **Priority**: low -- **Resolution**: ADR-017 §3's `FromCallConfig` namespace prefix is - **optional, default no prefix, same-peer collision = error**. A node - importing from a peer that exposes two ops with the same name should fail - loudly rather than silently overwrite. This matches the default-deny, - explicit-allow posture (ADR-015). The alternative (last-wins) would - silently mask one op behind another, which is the kind of surprise the - default-deny posture exists to avoid. - - **Cross-peer collision dissolved by ADR-029.** Under the peer-keyed - overlay model, same name on different peers is fine — they live in - separate peer sub-overlays, no collision, no prefix needed. - `FromCallConfig::namespace_prefix` is optional local-naming sugar for - when the importing node wants to expose a peer's ops under a different - name *locally* — a local-naming concern, not a disambiguation concern. - See ADR-029 §5. -- **Cross-references**: ADR-015, ADR-017, ~~ADR-028~~ (superseded), ADR-029, - [client-and-adapters.md](crates/call/client-and-adapters.md) - -### OQ-29: CallClient TLS Client-Auth and Remote-Identity Verification - -- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 §7 -- **Status**: **resolved** (2026-06-27 by ADR-030 §6 + this decision) -- **Door type**: One-way (identity model interaction), two-way (mechanism) -- **Priority**: ~~high~~ → resolved -- **Resolution**: **Three things are decided:** - - 1. **Wire quinn client-auth.** The client presents its Ed25519 key as an - RFC 7250 raw public key client cert (the client-side equivalent of - the server's `RawKeyCertResolver`). The server's - `AcceptAnyCertVerifier` already requests client certs and extracts - the fingerprint — the gap was entirely on the client side - (`with_no_client_auth()` → present the key). This activates the - `PeerEntry` fingerprint → `peer_id` resolution path on quinn - connections. - - 2. **Key-type-aware server cert verification.** The client's - `ServerCertVerifier` depends on the remote's identity type: - - **Ed25519 raw key** (the common case): accept the cert, extract the - fingerprint, match against `PeerEntry.fingerprints`. The fingerprint - IS the trust anchor — no CA needed. (Same model as iroh.) - - **X.509** (domain-facing endpoints, ACME): verify against a CA - (rustls's `WebPkiServerVerifier` with the platform root store or a - configured CA). `AcceptAnyServerCertVerifier` is a security hole for - X.509 — it's only safe for raw keys. - - The verifier choice is driven by `CallCredentials.remote_identity`, - which carries the expected key type. - - 3. **Fingerprint normalization** (ADR-030 §6): the quinn path extracts - the raw Ed25519 public key from the SPKI cert and formats it as - `ed25519:`, matching iroh. The same key has the same fingerprint - regardless of transport. X.509 fingerprints stay as `SHA256:`. - - **The iroh path already works** — iroh uses RFC 7250 raw keys, both - sides automatically exchange Ed25519 public keys during the TLS - handshake, and `extract_iroh_client_fingerprint` already gets the - `NodeId`. No client-auth wiring needed for iroh (direct or relay). The - gap was quinn-only. - - **What's genuinely additive** (not blocking the ADR-029 migration): - remote-identity verification (the client verifying the server's - fingerprint against an expected value) is additive — the server-side - fingerprint extraction is what matters for `PeerId`, not the client-side - verification. The verifier for raw keys can start as "accept any, extract - fingerprint" and add fingerprint-pinning later. - - See ADR-030 §6 for the fingerprint normalization details. -- **Cross-references**: ADR-014, ADR-017, ADR-027, ADR-029, ADR-030, - [client-and-adapters.md](crates/call/client-and-adapters.md), - [endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md) - -### OQ-30: PeerRef::Any Routing Policy - -- **Origin**: [ADR-029](decisions/029-peer-graph-routing-model.md) §2, [client-and-adapters.md](crates/call/client-and-adapters.md), `docs/research/alknet-call-peer-routing/findings.md` §3.2 -- **Status**: **resolved** (2026-06-27) -- **Door type**: Two-way -- **Priority**: low -- **Resolution**: `PeerRef::Any` uses **insertion-order first-match** — - deterministic but order-dependent (worker A connects before worker B → - `Any` routes to A until A disconnects). This is the simplest routing - policy and is correct for the immediate use case (the head picks the - first worker that serves the op). A richer `RoutingPolicy` (round-robin, - least-loaded, affinity) is a feature extension — the `PeerRef` enum is - designed to compose with a `Route { selector, policy }` struct without - breaking the `invoke_peer` signature. Adding a routing policy is - non-breaking; it's a feature addition when a fan-out use case needs it, - not an unmade architectural decision. -- **Cross-references**: ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md) - -### OQ-31: services/list-peers Re-Export Semantics - -- **Origin**: [ADR-029](decisions/029-peer-graph-routing-model.md) §6, `docs/research/alknet-call-peer-routing/findings.md` §3.5 -- **Status**: **resolved** (2026-06-27) -- **Door type**: Two-way -- **Priority**: low -- **Resolution**: `services/list` defaults to **"own ops only"** — it shows - the head's own Layer 0 `External` ops, filtered by - `AccessControl::check(calling_peer)`, unchanged from today (minus the - retired `remote_safe` filter). A `services/list-peers` opt-in (new - built-in operation) lists the peer overlays with attribution: each - peer's sub-overlay listed as `{ peer: Option, operations: [...] }`, - filtered by the calling peer's authorization. The re-export policy is an - `AccessControl` decision on the listing op. Whether `services/list-peers` - is built now or as a feature addition is a scheduling question — the - decision (opt-in, `AccessControl`-filtered) is made. -- **Cross-references**: ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md) +- **Full file**: [OQ-10](questions/010-git-adapter-scope-smart-protocol-only-or-full-server.md) ### OQ-32: Multi-Hop Federation -- **Origin**: [ADR-029](decisions/029-peer-graph-routing-model.md) §3.7, `docs/research/alknet-call-peer-routing/findings.md` §3.7 -- **Status**: deferred(scope) -- **Door type**: One-way (federation model), two-way (mechanism) -- **Priority**: low - **Blocked on**: A concrete use case for multi-hop federation. The one-hop model covers all current use cases (head→worker, runner→hub). -- **Resolution**: The model is **one-hop** — worker A does not transitively - see worker B's ops through the head unless the head explicitly re-exports - them. The peer-keyed overlay model extends to multi-hop without redesign - (a chain of `PeerRef::Specific` routing decisions), but path-finding - (which peer reaches which op transitively) is where a graph library - (petgraph) would pay off. For one-hop (shallow), a nested - `HashMap>` suffices. Multi-hop federation is - a feature extension — the one-hop model is the architectural commitment; - extending to multi-hop doesn't break downstream crates. Whether multi-hop - becomes a real use case is a future decision; the peer-keyed model does - not foreclose it. -- **Cross-references**: ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md) - -### OQ-33: PeerId — Cryptographic Identity vs Stable Logical Identifier - -- **Origin**: [ADR-029](decisions/029-peer-graph-routing-model.md) Assumption 1, `docs/research/alknet-call-peer-routing/findings.md` §6.1 -- **Status**: **resolved** (2026-06-27 by ADR-030) -- **Door type**: One-way (composition semantics), two-way (id source) -- **Priority**: high -- **Resolution**: `PeerId` is a **logical identifier, decoupled from the - cryptographic identity**. It is *not* the raw fingerprint or API-key - prefix — those change on key rotation, which would break every - in-flight `PeerRef::Specific` and every ACL entry referencing that peer. - - ADR-029 established the one-way door (`PeerId` is logical, not crypto) - with a v1 UUID source as a no-storage workaround. **ADR-030 supersedes - the UUID source**: `Identity.id` becomes `PeerEntry.peer_id` (stable - across key rotation) on the fingerprint path, and `PeerId = - Identity.id` from `IdentityProvider` resolution. The UUID workaround is - removed — the stable logical id is the real thing, sourced from the auth - system, not an ephemeral connection-assigned value. - - The `PeerEntry` config model (`peer_id`, `fingerprint`, `scopes`, - `resources`, `display_name`, `enabled`) lives in `AuthPolicy`. Key - rotation is a single `PeerEntry.fingerprint` update — the `peer_id`, - ACL entries, and `PeerRef::Specific` references stay stable. The - no-DB posture is preserved (core has the trait + the in-memory - `ConfigIdentityProvider` adapter; persistence adapters are additive - separate crates, ADR-033). - - **The one-way door (preserved from ADR-029):** `PeerId` is a logical id, - not `Identity.id` (the fingerprint). This determines the - `PeerCompositeEnv` key type, the `PeerRef::Specific` payload type, and - the `ScopedPeerEnv.peer_pinned` entry shape. The *source* of the logical - id (ADR-029's UUID → ADR-030's `PeerEntry.peer_id`) was the two-way-door - remainder; it is now resolved. -- **Cross-references**: ADR-009, ADR-014, ADR-015, ADR-017, ADR-021, ADR-027, - ADR-029, ADR-030, OQ-34, OQ-35, [client-and-adapters.md](crates/call/client-and-adapters.md), - [operation-registry.md](crates/call/operation-registry.md), - [auth.md](crates/core/auth.md) - -### OQ-34: Persistent Peer Registry (Cross-Node State Storage) - -- **Origin**: OQ-33 (the storage dimension it surfaced), the no-DB posture of ADR-008/018/025 -- **Status**: **resolved** (2026-06-27 by ADR-030 + ADR-031 + ADR-033) -- **Door type**: One-way (storage boundary), two-way (backend choice) -- **Priority**: ~~medium (not a v1 blocker)~~ → resolved -- **Resolution**: The storage boundary is: **core defines repo traits + - in-memory default adapters; persistence adapters are separate crates; - the assembly layer wires the adapter.** This is the repo/adapter - pattern (ADR-033), already established by `IdentityProvider` (ADR-004) - and now extended to `CredentialStore` (ADR-031). - - - `IdentityProvider` (ADR-004) — the auth repo trait, in core. - `ConfigIdentityProvider` is the in-memory default, backed by - `AuthPolicy.peers` (ADR-030). A future `alknet-peer-store-sqlite` - adapter that persists `PeerEntry` records in a `peers` table is - additive — it implements the same trait. - - `CredentialStore` (ADR-031) — the credential repo trait, in core. - `InMemoryCredentialStore` is the in-memory default. A future - persistence adapter is additive. - - The no-DB posture of the core crates is preserved in the sense that - matters: core has **no backend dependency** (no SQLite, no honker). The - in-memory default adapters carry no persistence. The persistence - adapters are additive crates, built when a concrete use case forces - them, wired by the assembly layer. - - The concrete adapter shapes (table schemas, backend choice, indexing, - 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, ADR-035, OQ-33, OQ-36, - [auth.md](crates/core/auth.md), [config.md](crates/core/config.md) - -## Theme: Storage and Adapters - -### OQ-35: ~~API Key Identity vs Peer Identity~~ (Dissolved) - -- **Origin**: ADR-030 §"API keys" (the asymmetry between the two auth paths) -- **Status**: **dissolved** (2026-06-27 — the framing was wrong) -- **Door type**: ~~One-way~~ -- **Priority**: ~~medium~~ -- **Resolution**: **Dissolved.** The original framing ("the fingerprint - path gets `PeerEntry` id-decoupling, the API-key path doesn't — the - asymmetry is deliberate") was based on a false distinction between "peer - bearer" and "auth bearer" tokens. 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` supports multiple credential paths: `fingerprints: Vec` - (Ed25519 and/or X.509) + `auth_token_hash: Option` (bearer - token). All resolve to the same `peer_id`. - - `ApiKeyEntry` is for bearer tokens that ARE the identity (rotation = - new identity, no stable logical id needed). - - A bearer token that is one credential path among several for a stable - peer goes in `PeerEntry.auth_token_hash`. A bearer token that IS the - identity stays in `ApiKeyEntry`. The distinction is whether the token - needs a stable logical id across rotation, not "peer bearer vs auth - bearer." See ADR-030 §"Bearer tokens." -- **Cross-references**: ADR-030, [auth.md](crates/core/auth.md), - [config.md](crates/core/config.md) - -### 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**: **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 → 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). - - 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. - - 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 - -### OQ-37: X.509 Outgoing-Only Case (Three Peer Roles) - -- **Origin**: ADR-030 §"Bearer tokens" (the three credential types), the - discussion that X.509 is fundamentally different from Ed25519 -- **Status**: **resolved** (2026-06-28 by ADR-034) -- **Door type**: One-way (how X.509 server identity integrates with the - peer model) -- **Priority**: medium → resolved -- **Resolution**: **The pre-ADR-034 framing conflated three distinct - remote roles under "X.509 endpoint."** [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) - names them and resolves the peer-model question: - - 1. **Public X.509 endpoint** — a remote HTTPS / `alknet/call`-over-TLS - server reachable by domain, authenticated by CA verification - (`WebPkiServerVerifier`). The local node is a *client*; it - authenticates by bearer token. **Not a `PeerEntry` on the client - side** — it is not in the call-protocol peer graph (ADR-029), gets - no `PeerId`, and is not addressable via `PeerRef::Specific`. Ops - discovered via `from_call`/`from_openapi`/`from_mcp` land in the - connection's Layer 2 overlay and are invoked through the - connection handle. - 2. **Transport relay** — iroh's DERP-equivalent (`iroh-relay`). - Infrastructure, not an alknet peer; no `PeerEntry` / `PeerId`. - Inherited with the `iroh` feature; its identity is iroh's concern. - 3. **Hub / hosting node** — an alknet application peer (head/worker - hub, git-hosting hub) that *also* exposes a public domain + X.509 - for browsers. A single `PeerEntry` with **mixed fingerprints** - (`ed25519:...` + `SHA256:...`), already supported by ADR-030. - Browsers connecting to it are *not* alknet peers — served by - `alknet-http`, bearer-token auth, no `PeerId`. - - **The "make `PeerEntry` symmetric" instinct is rejected.** `PeerEntry` - is for peers in the call-protocol peer graph; pure-client connections - to public X.509 endpoints are not in that graph on the client side. - The asymmetry reflects a real trust-model difference: known peers have - stable logical identities (pin the fingerprint); public APIs don't - (trust the CA, hold the connection handle directly). - - **Client-side verifier selection rule (extends OQ-29):** known peer - (`PeerEntry` present) → fingerprint pin (Ed25519 `ed25519:` or - X.509 `SHA256:`); unknown X.509 remote (`PeerEntry` absent) → CA - verification. An unknown Ed25519 raw-key remote cannot be verified at - all (no CA fallback) and fails closed — same model as iroh. - - **Downstream, not blocking, recorded so they don't get lost:** - WebTransport relay-as-proxy (browser → proxy → P2P hub) is the - remaining scope question tracked as OQ-38 (h3/WebTransport itself is - now in scope, ADR-038); ADR-030 §6's fingerprint normalization already - keeps the proxied path clean. On-chain / smart-contract peer - discovery (relays syncing git repos via iroh gossip) is a *source* of - `PeerEntry` records, fits the OQ-36 repo/adapter pattern - (`alknet-peer-store-onchain` implementing `IdentityProvider`), and - does not change the auth model. - - Not blocking the ADR-029 migration — the Ed25519 path is the primary - use case and was already resolved; this ADR closes the X.509 - outgoing-only remainder. -- **Cross-references**: ADR-027, ADR-029, ADR-030, ADR-033, ADR-034, - OQ-29, OQ-36, [client-and-adapters.md](crates/call/client-and-adapters.md), - [endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md) - -## Theme: alknet-http - -### OQ-38: WebTransport Standalone Relay Service Scope - -- **Origin**: [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) - §5, [webtransport.md](crates/http/webtransport.md) -- **Status**: open (scope, not deferral) -- **Door type**: One-way (crate boundary), two-way (mechanism) - **Priority**: low -- **Resolution**: There are two distinct "WebTransport proxy" concepts - that must not be conflated: - - 1. **In-process ALPN-stream-proxy (resolved, in `alknet-http`).** - The `h3` handler hands a WebTransport stream to another ALPN - handler (`SshAdapter`, `GitAdapter`, etc.) as a `Connection`, so - a browser with a WASM parser can reach any ALPN service via - WebTransport. This is resolved by - [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) and - lives in `alknet-http`'s `h3` handler. Not this OQ. - - 2. **Standalone relay service (this OQ).** A full relay — a fork of - `iroh-relay` — that provides NAT traversal infrastructure with - WebTransport-based proxy as a fallback alongside WebSocket. This - is a separate service, not a mode of the `h3` handler: it - terminates the browser's WebTransport connection and forwards - encrypted traffic to a P2P hub's Ed25519 endpoint (so the hub need - not expose its own public X.509 cert). ADR-034 §5 recorded it in - the h3/WebTransport bucket; ADR-038 brought h3/WebTransport into - scope (later superseded by [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md), - which deferred h3/WebTransport as a scope decision — the browser - bidirectional path uses WebSocket); ADR-040 resolved the in-process - proxy (now parked per ADR-044). This OQ is the remaining scope - question: does the standalone relay live in a future `alknet-relay` - crate (a fork of `iroh-relay` with WebTransport proxy fallback) or - is it out of scope for the current alknet work? - - This is a genuine scope question, not a deferral. The relay use case - is not yet concrete enough to commit the crate boundary — no - deployment has asked for a standalone relay with WebTransport - fallback yet, and the design (transport-only proxy, no auth-model - change per ADR-034 §5) is clear but the home is not. The decision is - made when the browser-to-P2P-peer relay use case becomes concrete; - until then it is tracked here, not deferred with "v1/later" language. - The relay does not change the auth model (bearer token + - `PeerEntry.auth_token_hash`; relay is transport-only), so it does not - block any other ADR. -- **Cross-references**: ADR-027, ADR-030, ADR-034, ADR-038 (superseded), - ADR-040 (parked), ADR-044, [webtransport.md](crates/http/webtransport.md) - -### OQ-39: `to_openapi` Published-Spec Versioning - -- **Origin**: [ADR-017](decisions/017-call-protocol-client-and-adapter-contract.md) - Consequences, [http-adapters.md](crates/http/http-adapters.md) -- **Status**: **resolved** (2026-06-30 by ADR-045) -- **Door type**: One-way (after first publication), two-way (before) -- **Priority**: medium → resolved -- **Resolution**: **[ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md) - commits the versioning scheme.** The gateway pattern (ADR-042) - dissolved most of the original concern: the published doc describes - **5 fixed gateway endpoints** (`/search`, `/schema`, `/call`, - `/batch`, `/subscribe`), not the per-operation surface. Per-caller - operation changes (add/remove/modify an operation, change an - operation's schema) do **not** change the published doc — the - operation set is discovered at runtime via `AccessControl`-filtered - `/search`, not preloaded into the doc. So the version does not churn - on every operation change (the original OQ-39 worry, framed under the - pre-ADR-042 per-operation-paths model). - - What remains is narrow: how the published gateway doc signals its - version. The decision: - - 1. **`to_openapi` emits `info.version` as semver.** Standard OpenAPI - field, standard semver interpretation — no alknet-specific - detection mechanism. - 2. **The version tracks the gateway endpoint contract, not the - operation set.** Major = breaking change to the gateway (endpoint - removed/renamed, required request field added, response shape - changed, error-mapping semantics changed per ADR-023); Minor = - additive (new endpoint, new optional field); Patch = wording/docs. - Per-caller operation changes do **not** bump the version. - 3. **Bump on change to the gateway shape, not on regeneration.** - A restart that regenerates the same gateway shape yields the same - version. - 4. **Consumers detect breaking changes via the major version.** A - client compares `info.version`'s major component to the version it - built against; a major bump signals "re-read the doc, something - broke." Minor/patch are informational. - 5. **The additive traditional per-operation-paths projection - (ADR-042 §5) versions independently** on its own schedule — its - surface *does* change with the operation set, so its versioning is - the per-operation churn the original OQ-39 framed. That projection - is opt-in and out of scope for ADR-045; the gateway doc is the - default published contract and the one ADR-045 governs. - - The original "version marker emitted so consumers can detect mapping - changes" constraint (from ADR-017 Consequences) is satisfied by - `info.version` semver. ADR-045 lifts the "published artifact is a - contract" blind spot in ADR-009's framework (it classifies doors by - reversal cost in the codebase, not by compatibility cost for external - consumers) into its Context and honors the constraint without changing - ADR-009's framework. -- **Cross-references**: ADR-009, ADR-017, ADR-023, ADR-036, ADR-042, - ADR-045, [http-adapters.md](crates/http/http-adapters.md) - -### OQ-40: reqwest Client Config and Connection Pooling - -- **Origin**: [http-adapters.md](crates/http/http-adapters.md), - [http-mcp.md](crates/http/http-mcp.md), the alknet-http Phase 0 - findings DH-7 -- **Status**: resolved (2026-06-30) -- **Door type**: Two-way -- **Priority**: low -- **Resolution**: `alknet-http` owns a shared HTTP client constructed - once and reused across all `from_openapi`/`from_mcp` forwarding - handlers. The client carries connection pooling, keep-alive, TLS, - and a retry stack. The config shape is: - - | Aspect | Decision | - |--------|----------| - | Shared client type | `reqwest_middleware::ClientWithMiddleware` (not a bare `reqwest::Client`) — required because both retry and Retry-After are middleware on the stack | - | Middleware stack | `RetryTransientMiddleware` (from `reqwest-retry` — exponential backoff on transient failures: connection errors, 5xx) + inlined `RetryAfterMiddleware` (parses the `Retry-After` header on 429/503 and sleeps before the next request to that URL) | - | `Retry-After` handler | Inlined from `melotic/reqwest-retry-after` (MIT, ~50 lines of real logic). The crate is complementary to `reqwest-retry`, not a replacement — `reqwest-retry`'s default strategy does not honor `Retry-After`, which is why the separate middleware exists. Inlining lets the unbounded `HashMap` storage in the upstream crate be bounded (the melotic version grows without limit over a long-running process). | - | Pooling / keep-alive / TLS | `reqwest::ClientBuilder` defaults; system trust store for outbound HTTPS (standard calls to OpenAI, Anthropic, etc.) | - | Hot-reload | Rebuild-and-swap the `ClientWithMiddleware` via `ArcSwap` (same pattern as `ConfigIdentityProvider`, ADR-035). A rebuild drops the connection pool / keep-alive state — acceptable, since a config change wanting a fresh pool is the case that triggers it. Retry policy is baked into the middleware at `ClientBuilder::build()` time; live policy mutation is not supported by `reqwest-retry` (no cheap per-policy update path exists). | - | Credentials | Per-request from `OperationContext.capabilities` — see the one-way constraints below | - - The one-way constraints (settled before this OQ, restated unchanged): - (1) `alknet-http` owns its HTTP client — no env-var-based client - config, no shared global client; (2) credential injection happens - per-request (from `OperationContext.capabilities`), not at client - construction — the client is shared across all operations, the - credentials are per-call; (3) TLS for outbound calls uses the - system trust store by default (custom CA bundle + client certs are - an optional config for self-hosted API gateways). - - **Downstream layering boundary (so the agent crate doesn't - accidentally re-invent a client).** The agent crate's provider SSE - normalization (replicating the solid part of aisdk's pattern — the - Vercel-UI-message normalization that maps different providers' SSE - to a common shape) sits *on top of* this `ClientWithMiddleware`: it - consumes the `reqwest::Response` stream the forwarding handler - produces and emits `call.responded` events. It does not replace the - client or own transport/pooling/retry. `alknet-http` owns transport; - the agent crate owns provider-specific SSE → Vercel-UI-message - mapping. The aisdk `core/client.rs` reference for HTTP client - construction is *not* carried forward — its env-var config and - hand-rolled retry are the anti-patterns being discarded; the - aisdk/`@alkdev/operations/src/from_openapi.ts` SSE *normalization* - pattern is separate and stays referenced in the forwarding-handler - section of [http-adapters.md](crates/http/http-adapters.md). - - No ADR — the decision is internal to `alknet-http`: the client type - does not cross crate boundaries (`alknet-call` never sees reqwest), - the library choice is reversible, and it does not touch the - system's structure, constraints, or API surface across crates. -- **Cross-references**: ADR-014, ADR-017, ADR-035, - [http-adapters.md](crates/http/http-adapters.md), - [http-mcp.md](crates/http/http-mcp.md) +- **Full file**: [OQ-32](questions/032-multi-hop-federation.md) ### OQ-41: Stream Operators Library -- **Origin**: [ADR-049](decisions/049-streaming-handler-for-subscriptions.md), - [operation-registry.md](crates/call/operation-registry.md) §"OperationEnv" -- **Status**: deferred(scope) -- **Door type**: Two-way (additive utility library; no protocol or API-surface - change) +- **Blocked on**: A handler that needs stream operators and finds the existing combinators (`Box::pin(stream::iter(...))`, `async_stream::stream!`, `futures::stream`) insufficient. The operators library is a convenience, not a prerequisite for any handler. - **Priority**: low -- **Blocked on**: A handler that needs stream operators and finds the existing - combinators (`Box::pin(stream::iter(...))`, `async_stream::stream!`, - `futures::stream`) insufficient. The operators library is a convenience, not - a prerequisite for any handler. -- **Resolution**: ADR-049 establishes that stream composition (filter, map, - combine, window, dedupe) is a **handler-level concern**, not a protocol - composition concern. `OperationEnv::invoke()` is request/response-only; - stream manipulation happens at the handler level with stream operators on - the `BoxStream` the handler itself produces. The - `@alkdev/pubsub` `operators.ts` is the prior art: 13 operators (`filter`, - `map`, `take`, `batch`, `dedupe`, `window`, `chain`, `join`, `reduce`, - `groupBy`, `flat`, `pipe`, `toArray`) that operate on `AsyncIterable`, - forked from graphql-yoga's subscription implementation. - - The Rust analogue — a stream-operators utility crate or module providing - the same set of operators on `BoxStream` / `impl Stream` — is - a feature extension. Handlers can - produce streams today without it (`Box::pin(stream::iter(...))`, - `async_stream::stream!`, `futures::stream` combinators all work); the - operators library is a convenience that reduces boilerplate for handlers - that transform streams (filter, batch, dedupe, window). No ADR is needed - for the library itself — it's internal utility code that doesn't cross - crate boundaries as a contract. An ADR would be warranted only if the - operators become part of a public API surface (e.g., a handler-registration - DSL that references operator names). - - This OQ exists so the operators library is tracked and findable, not left - as inline hedging in the spec docs. It is not a deferral of a decision — - the architectural decision (stream composition is handler-level, not - protocol-level) is made in ADR-049. This tracks the *implementation* of - the utility library, which is scheduling work, not architecture work. -- **Cross-references**: ADR-049, - [operation-registry.md](crates/call/operation-registry.md) §"OperationEnv", - `/workspace/@alkdev/pubsub/src/operators.ts` (TS prior art) - -## Theme: Runtime-Spawned Resources and Ownership - -### OQ-42: Dynamic Resource Ownership for Runtime-Spawned Resources - -- **Origin**: [alknet-docker POC summary](../../research/alknet-docker/poc-summary.md) - §"Open Unknowns" #3 (container-as-resource identity model); generalized - during the Phase 1 review pass triggered by that research finding. -- **Status**: resolved — all five sub-questions are decided (storage - shape, integration point, proxy-vs-grant, and the four clarifications). - The ADR will write these into decision text; the dependent crate specs - can declare their `AccessControl` shapes against this model. -- **Door type**: One-way (the `AccessControl::check` signature change and - the `OperationSpec.resource_id_path` addition in core/call), two-way (the - ownership provider mechanism, per the established repo/adapter pattern) -- **Priority**: high — blocks the alknet-docker, alknet-tty, opencode-runner - wrapper, and `alknet-container` (fleet normalization) crate specs. None of - those specs can declare their `AccessControl` shapes until this is - resolved, because the model available to them determines what ACL - declarations are even expressible. Permitting "docker picks a per-crate - default and the others follow" is the door-type-as-deferral anti-pattern - (ADR-009 §"What this framework is NOT"): each crate bakes in an ACL shape - and downstream crates build on whatever default was picked, making the - "cheap reversal" expensive. - -- **Resolution.** - - **Decision 1 — storage side: reuse the repo/adapter pattern.** The - ownership store is a fourth instance of the established repo/adapter - pattern (ADR-033), alongside `IdentityProvider` (ADR-004), `IdentityStore` - (ADR-035), and `CredentialStore` (ADR-031). Concretely: a trait in - `alknet-core` (read method: "does identity X own resource R with action - A?" / "what resources of type T does X own?"; write method: "record X - spawned R", "revoke R on teardown"), with an in-memory default adapter in - core. The in-memory default carries the docker/runner cases with no - backend dependency — ownership is runtime state, meaningless across - restarts (a container ID from a previous process doesn't exist), so the - default case has no persistence requirement. A persistence adapter (e.g. - sqlite/honker-backed, for a hub that wants fleet ownership to survive - restarts) is separable and built when a concrete use case forces it, same - as `alknet-store-sqlite` for peer/credential persistence. The read stays - sync (called from `AccessControl::check` on the dispatch hot path, no - `.await`), with persistence adapters caching in memory and using honker - NOTIFY for invalidation — same `ArcSwap`-backed full-reload pattern as - `ConfigIdentityProvider` (ADR-035). No new shape invented on the storage - side; no Phase 0 needed for it. - - **Decision 2 — integration point: Option 2, `check` consults the - ownership provider directly.** `AccessControl::check` grows a parameter - for the ownership provider (or reads one carried on `OperationContext`), - and consults it for `resource_type`/`resource_action` checks against - runtime-spawned resources. The alternative considered and rejected — - Option 1, augmenting `Identity.resources` with a per-request snapshot - before calling `check` — preserves `check`'s purity by moving the - impurity one frame up the stack: the dispatcher would pull owned - resources into a per-request identity snapshot so `check` *looks* - unchanged while reading state that was never part of the static - identity. The purity was always theatrical (the question "can X exec - into container C" was never purely a function of identity; it just - looked that way because the resource set was static). Option 2 makes - `check`'s signature honest about what ACL checking *is* in the presence - of dynamic resources: a function of (ACL, Identity, current-ownership- - state). The impurity is real either way; Option 2 puts it in the - signature where it's visible, rather than hiding it in a per-request - snapshot pretending to be static identity. Option 3 (handler-level - ownership check, `AccessControl` gates only scope) was rejected because - it splits the ACL story — some resources statically checked, some - handler-checked — which is the kind of inconsistency that creates the - "figure out how it fits with what is there" cleanup this OQ exists to - prevent. - - The cost of Option 2 is a `check` signature change — a one-way door, - every call site and test updates. Per the project's decision principle - (implementation workload is a non-issue relative to semantic correctness - and long-term clarity; "path of least resistance compounded over many - decisions is strictly dominated"), this is implementation cost, not a - semantic cost, and does not bias the choice. - - **Refinement that makes Option 2 work cleanly: `OperationSpec` declares - where the resource ID lives in the input.** `OperationSpec` gains a - `resource_id_path: Option` — a JSON pointer into the operation - input, e.g. `"$.containerId"` for `docker/container/exec`. The dispatcher - extracts the resource ID from the input using the spec-declared path, - passes it to `check`, and `check` asks the provider "does this identity - own `/` with action ``?" — - a single targeted lookup, not a whole-resource-set pull. The fit with - JSON Schema is load-bearing, not incidental: `OperationSpec.input_schema` - is already a JSON Schema, so `resource_id_path` is a pointer *within* an - existing schema on the same spec. The `OperationSpec` becomes fully - self-describing for authorization — what resource type, what action, and - *which input field* drives the resource lookup. No per-namespace - conventions, no handler-level knowledge, no "the dispatcher just knows." - The contract is on the spec, where it belongs. - -- **Resolved specifics (the four questions the ADR must write into - decision text).** The decisions above settle the storage shape and the - integration point. The four specifics below settle how the model - behaves at the edges: - - 1. **No-specific-resource operations (the `list` case) — scope-gate + - result-filter, composing.** Operations with `resource_type` set but - `resource_id_path` absent — e.g. `docker/container/list`, which - doesn't reference a specific container. When a coordinator lists - containers it owns, it should see only its own — not every container - on the host. That's not just scope-gating ("can you call - `container/list` at all?") and not just result-filtering ("return - only owned") — it's both: scope-gate the call (does the peer have the - `container:list` scope), then filter the result to owned resources. - The default is "allow if scoped, filter to owned." `list` is the case - that forces this; `exec`/`inspect`/`stop` against a specific - container are the clean case (single targeted ownership lookup via - `resource_id_path`). The ADR states the default and how a spec - declares which it wants. - - 2. **Teardown coupling — automatic, handler-driven.** The ownership - store's write path (revoke on teardown) is coupled to the spawned - resource's lifecycle. The "burn it and start over" capability depends - on ownership state tracking the lifecycle correctly. When a container - dies or is destroyed, the ownership entry is revoked *by the handler - that managed the lifecycle* (the docker handler calls revoke on - container exit), not by an operator workflow or a background reaper. - The burn-and-start-over pattern is: destroy container → ownership - revoked automatically → spawn new container → new ownership recorded. - If teardown weren't automatic, stale ownership entries would - accumulate and the "burn" path would leave dangling ACL state. The - architectural commitment is: handler-driven revoke on lifecycle end, - not a reaper. The coupling mechanism (explicit handler call vs. - lifecycle-hook abstraction) is two-way-door implementation work. - - 3. **Fleet representation (spoke resources on the hub) — per-node - ownership, downstream app tracks "who is this for."** Under the - proxy pattern (Decision 3 below), the docker node records "coordinator - owns C" in its local ownership store. The coordinator's "I started C - for agent Y" mapping lives in the coordinator's own downstream-app - state, not in the core ownership store. The ownership store is - per-node (each docker node records its local ownership); the hub's - agent-to-workspace mapping is app state. There is no cross-node - ownership propagation in the base model — the spoke sees the hub as - the owner, and the hub's "who is this for" is its own concern. The - proxy pattern keeps ownership local, which is why this question is - less consequential than originally framed. - - 4. **Composition interaction — two separate checks, no change to - `CompositionAuthority`.** In the proxy pattern, the coordinator - composes `docker/container/exec` on behalf of an agent. Two checks - must pass: (a) the coordinator's `CompositionAuthority` has the - `container:exec` scope (static, ADR-015/022 unchanged), and (b) the - coordinator owns this specific container (dynamic, ownership store). - The composition authority stays static — it doesn't grow a dynamic - path. The ownership store handles the dynamic resource-level check. - Both must pass; they're orthogonal. **ADR-015/022 don't need - amendment** — the composition authority is unchanged, and the - ownership store is an additional check, not a modification to the - existing one. - -- **Decision 3 — access pattern: proxy-only as the base model.** The base - model is "spawner owns, proxy to share, teardown revokes" — with no - grant/transfer mechanism in the core ownership store. Two patterns for - how a downstream consumer reaches a runtime-spawned resource were - identified: - - - **Proxy pattern (the common case, and the only one the core model - supports).** A coordinator starts a container and manages its - lifecycle; the end user never talks to docker directly. The - coordinator re-exports the docker operations it wants to expose (via - `from_call` — the adapter that imports a peer's operations and - re-registers them locally, ADR-017 — or by composing them in its own - handlers), and when the end user invokes one, the coordinator is the - *direct caller* to the docker endpoint. Docker's ownership store sees the coordinator as the - owner and as the caller — the check passes. The end user's identity - rides as `forwarded_for` metadata (ADR-032), and the coordinator does - whatever end-user-level ACL it wants at its own layer. This is the - kernel/user-land + forwarded-for model: the hub's authority is used, - `forwarded_for` is metadata, the hub handles its own ACL. - - - **Grant pattern ("poking holes") — not in the core model.** A - downstream app wants to give an end user *direct* call-protocol - access to the docker endpoint for specific containers — the end user - calls `docker/container/exec` themselves, not through a proxy. Docker's - ownership store would need a record that the end user has access to - that container, even though the downstream app spawned it. No - described use case requires this. The agent-workspace case — the - concrete one — is entirely the proxy pattern: the coordinator starts - the workspace container; the agent interacts with what's *inside* the - container (a TTY, an opencode instance's API surface), not with - docker operations on the container. Docker-level operations (stop, - remove, inspect) are the coordinator's job. - - "Poking holes" is a downstream-app concern — the app that owns the - resources re-exports the operations it wants to share via `from_call` - with its own ACL layer, rather than the core ownership store growing a - grant API. The ADR commits to proxy-only and explicitly states that - "poking holes" is a downstream app's job. - - **A future grant mechanism is additive, not a one-way door closure.** - If a use case forces the grant pattern, it's a new method on the - ownership store trait (`grant(identity, resource)` / - `revoke_grant(...)`). `AccessControl::check` already consults the - ownership provider; a grant-aware provider would answer "yes" for - grantees in addition to owners, without a trait-shape change. The - two-way-door classification (additive) is stated here as reversal-cost - classification, not as a reason to defer the decision — the decision is - made (proxy-only), and the cost of reversing it if a future use case - forces it is low. If the grant pattern is later admitted, specifics 3 - and 4 above are revisited: cross-node ownership propagation returns to - the table (3), and composition under a grant would need - `CompositionAuthority` to grow a dynamic path, amending ADR-015/022 (4). - -- **Cross-references**: ADR-009 (door-type-as-deferral anti-pattern), - ADR-015, ADR-022 (the static `CompositionAuthority.resources` model this - extends — see open question 4), ADR-030, ADR-032 (`forwarded_for` - metadata — the proxy pattern's end-user-identity carrier), ADR-033 - (repo/adapter pattern — reused for the ownership store), ADR-035 - (`IdentityStore` — administrative peer mutations, a different concern - from runtime resource ownership, but the sync-read + ArcSwap + - honker-NOTIFY shape is reused), - [auth.md](crates/core/auth.md) (`Identity.resources`, - `AccessControl::check` interaction — both under edit by this decision), - [operation-registry.md](crates/call/operation-registry.md) - (`AccessControl`, `OperationSpec` — `resource_id_path` addition), - [alknet-docker POC summary](../../research/alknet-docker/poc-summary.md) - §"Open Unknowns" #3 - -## Theme: alknet-tty - -### OQ-43: `TtyControl` as a `Clone` trait object - -- **Origin**: [crates/tty/tty-backend.md](crates/tty/tty-backend.md) - (the `TtyHandle.control` field shape); - `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-01 (the trait- - shape open question the local-PTY POC resolved). -- **Status**: resolved -- **Door type**: One-way (the `TtyControl` trait shape is part of the - `TtyBackend` API surface — ADR-053), two-way (the concrete `Clone` - newtype mechanism) -- **Priority**: medium -- **Resolution**: `TtyHandle.control` is - `Option>`. The `Clone` - trait-object bound is satisfied via an `Arc`-backed `Clone` newtype: a - small struct holding `Arc` where `TtyControlInner: - Send + Sync` has the `resize`/`signal` methods, and the public - `TtyControl` newtype implements `Clone` by cloning the `Arc`. The - local-PTY POC used a concrete `PtyControl` struct (inherently `Clone` — - it held `Arc>` fields); the trait-object form generalizes it - so a backend can produce its own control type without the adapter - knowing the concrete shape. The `Clone` constraint exists because the - adapter's control-chunk dispatcher needs to be handed off to the - spawned pump task (the POC's `session::drive_session` clones - `pty.control` for the client→backend pump). See ADR-053 and - `tty-backend.md`. -- **Cross-references**: ADR-053, [tty-backend.md](crates/tty/tty-backend.md), - `/workspace/alknet-tty-poc/src/local_pty.rs` (`PtyControl`) +- **Full file**: [OQ-41](questions/041-stream-operators-library.md) ### OQ-44: Terminal Modes (TTY modes) -- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-02; - [crates/tty/tty-backend.md](crates/tty/tty-backend.md) - (`TerminalParams.modes` field). -- **Status**: deferred(scope) -- **Door type**: Two-way +- **Blocked on**: a concrete mode-control use case (a deployment that needs to set echo/raw/canonical/etc. modes on a PTY, beyond the backend's defaults). - **Priority**: low -- **Blocked on**: a concrete mode-control use case (a deployment that - needs to set echo/raw/canonical/etc. modes on a PTY, beyond the backend's - defaults). -- **Resolution**: Not yet decidable. SSH's `pty_request` carries TTY - modes (echo, raw, canonical, etc.) as a packed bitmask. The common - case is "default terminal modes" — the `modes` field in - `TerminalParams` is `serde_json::Value` (reserved as `{}` in v1) for - when a concrete use case requires mode control. The backends - (`portable_pty`, docker `tty: true`, russh `pty_request`) all have - defaults that work for the common terminal case. Adding mode control - is additive (extend the `modes` JSON shape) and does not break - downstream; the decision is deferred until a use case forces it. -- **Cross-references**: ADR-053, [tty-backend.md](crates/tty/tty-backend.md), - [tty-wire.md](crates/tty/tty-wire.md) - -### OQ-45: Flow Control for High-Throughput stdout - -- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-03; - [crates/tty/tty-wire.md](crates/tty/tty-wire.md) (no windowing in the - chunk format). -- **Status**: open (low risk) -- **Door type**: Two-way -- **Priority**: low -- **Resolution**: The chunk format has no windowing — QUIC's per-stream - flow control handles backpressure. This is expected to suffice for - high-throughput stdout (e.g., `cargo build` output): the docker POC's - logs subscription handled multi-line output without issue, and QUIC's - bidi-stream flow control is the established backpressure mechanism. A - POC with real high-volume output (a deliberately large `cargo build` - or `find /` over a high-bandwidth connection) would confirm. If a - flow-control problem surfaces, the reversal is a per-stream - flow-control window in the chunk format (a two-way-door extension to - the wire format, additive — a new control message type for window - updates, not a header change). Not blocking; the default assumption is - QUIC flow control suffices. -- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md), - [tty-adapter.md](crates/tty/tty-adapter.md) +- **Full file**: [OQ-44](questions/044-terminal-modes-tty-modes.md) ### OQ-46: Runner API Surface -- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-05; - [crates/tty/tty-local.md](crates/tty/tty-local.md) (the runner pattern - in pipe mode). -- **Status**: deferred(scope) -- **Door type**: Two-way +- **Blocked on**: a concrete runner-policy use case that forces the API surface (job management, log persistence, task graph integration). - **Priority**: low -- **Blocked on**: a concrete runner-policy use case that forces the API - surface (job management, log persistence, task graph integration). -- **Resolution**: Not yet decidable. The runner *mechanism* (pipe mode — - `TtyParams.terminal = None` → `std::process::Command` with piped stdio - → framed byte stream + exit code) is in alknet-tty (ADR-054). The - runner *policy* (job management, log persistence, task graph - integration) is a downstream crate's job, not in scope for alknet-tty. - This OQ tracks whether a runner-policy crate (e.g., an - `alknet-runner` crate that builds on the pipe mode + the wire format - to provide job management) is needed, and what its API surface would - be. The decision is deferred until a concrete use case forces it; the - mechanism is preserved regardless. See ADR-054 and `tty-local.md` - §"The Runner Pattern". -- **Cross-references**: ADR-054, [tty-local.md](crates/tty/tty-local.md) +- **Full file**: [OQ-46](questions/046-runner-api-surface.md) -### OQ-47: Stdin Closure Canonical Signal - -- **Origin**: `docs/research/alknet-docker/poc-summary.md` §"Open - Unknowns" #4 (stdin closure semantics for raw carriage); - [crates/tty/tty-wire.md](crates/tty/tty-wire.md) §"Stdin Closure". -- **Status**: resolved -- **Door type**: Two-way -- **Priority**: low -- **Resolution**: Either a zero-length stdin chunk (stream_type 0, - length 0 — the docker POC's sentinel) or a `{"type":"eof"}` - control chunk (stream_type 3 — the tty POC's explicit signal) closes - the client's stdin. Both are accepted by the adapter; the spec - recommends `eof` for explicitness (it's a control message, not a - data-length hack). The adapter handles both identically: signal EOF - to the backend's stdin (`ChildStdin::drop` / PTY writer close) and - keep pumping stdout until the exit resolves — the client may still - want to receive remaining output + the exit code. A third path - (client closes the write half of the bidi stream) is also accepted - and handled the same way. See ADR-052 and `tty-wire.md`. -- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md), - [tty-adapter.md](crates/tty/tty-adapter.md) diff --git a/docs/architecture/questions/001-bistream-type-definition.md b/docs/architecture/questions/001-bistream-type-definition.md new file mode 100644 index 0000000..d457975 --- /dev/null +++ b/docs/architecture/questions/001-bistream-type-definition.md @@ -0,0 +1,8 @@ +# OQ-01: BiStream Type Definition + +- **Origin**: [overview.md](overview.md) +- **Status**: resolved +- **Door type**: One-way +- **Priority**: high +- **Resolution**: BiStream is a trait (`AsyncRead + AsyncWrite + Send + Unpin`). Handlers receive a `Connection` (not a single BiStream). This preserves the WASM door — browser clients can implement BiStream over WebTransport streams. See ADR-007. +- **Cross-references**: ADR-002, ADR-007, ADR-009 diff --git a/docs/architecture/questions/002-authcontext-resolution-timing.md b/docs/architecture/questions/002-authcontext-resolution-timing.md new file mode 100644 index 0000000..18ddea0 --- /dev/null +++ b/docs/architecture/questions/002-authcontext-resolution-timing.md @@ -0,0 +1,8 @@ +# OQ-02: AuthContext Resolution Timing + +- **Origin**: [overview.md](overview.md) +- **Status**: resolved +- **Door type**: One-way +- **Priority**: high +- **Resolution**: Hybrid model (Option C) — endpoint resolves what it can (e.g., TLS client certificate), handler resolves what it must (e.g., AuthToken in first frame). AuthContext may be partial when `handle()` is called. See ADR-004. +- **Cross-references**: ADR-002, ADR-004 diff --git a/docs/architecture/questions/003-alpn-string-naming-convention.md b/docs/architecture/questions/003-alpn-string-naming-convention.md new file mode 100644 index 0000000..862339d --- /dev/null +++ b/docs/architecture/questions/003-alpn-string-naming-convention.md @@ -0,0 +1,8 @@ +# OQ-03: ALPN String Naming Convention + +- **Origin**: [overview.md](overview.md) +- **Status**: resolved +- **Door type**: One-way +- **Priority**: medium +- **Resolution**: Custom ALPNs use `alknet/` prefix (no version), standard ALPNs use IANA strings. No version negotiation initially. See ADR-006. +- **Cross-references**: ADR-001, ADR-006 diff --git a/docs/architecture/questions/004-dynamic-handler-registration-at-runtime-vs-static-at-startup.md b/docs/architecture/questions/004-dynamic-handler-registration-at-runtime-vs-static-at-startup.md new file mode 100644 index 0000000..767fd42 --- /dev/null +++ b/docs/architecture/questions/004-dynamic-handler-registration-at-runtime-vs-static-at-startup.md @@ -0,0 +1,23 @@ +# OQ-04: Dynamic Handler Registration at Runtime vs Static at Startup + +- **Origin**: [overview.md](overview.md) +- **Status**: resolved +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: Static registration at startup. `HandlerRegistry` is immutable after construction. ALPN strings in the TLS `ServerConfig` are derived from the registry at startup. See ADR-010. + + **Scope clarification (ADR-024)**: This resolution applies to the + **`HandlerRegistry`** (ALPN string → `ProtocolHandler`), which is what + ADR-010 governs. The call protocol's **`OperationRegistry`** (operation + name → `HandlerRegistration`) is a *separate* registry living inside the + `CallAdapter`, behind the single ALPN `alknet/call`. Its mutability + profile is governed by ADR-024, not by this OQ. ADR-024 layers the + operation registry by trust boundary: curated `Local` ops are immutable + (same rationale as here — composing ops are privileged, the startup trust + boundary is where their authority is granted); `Session` and imported + (`FromCall` etc.) ops are dynamic at their respective trust-boundary + scopes (session, connection). The pre-ADR-024 blanket immutability claim + in `operation-registry.md` was inherited by analogy from this OQ and did + not actually apply — the TLS-config argument that justifies + `HandlerRegistry` immutability does not touch the `OperationRegistry`. +- **Cross-references**: ADR-001, ADR-010, ADR-024, [endpoint.md](crates/core/endpoint.md), [operation-registry.md](crates/call/operation-registry.md) diff --git a/docs/architecture/questions/005-multi-connectivity-endpoint.md b/docs/architecture/questions/005-multi-connectivity-endpoint.md new file mode 100644 index 0000000..c53ff6d --- /dev/null +++ b/docs/architecture/questions/005-multi-connectivity-endpoint.md @@ -0,0 +1,8 @@ +# OQ-05: Multi-Connectivity Endpoint + +- **Origin**: [overview.md](overview.md) +- **Status**: resolved +- **Door type**: One-way +- **Priority**: high +- **Resolution**: `AlknetEndpoint` supports both `quinn::Endpoint` (public QUIC+TLS) and `iroh::Endpoint` (P2P relay-assisted) simultaneously, both optional and feature-gated. Both produce QUIC connections that dispatch through the same `HandlerRegistry` by ALPN string. These are not interchangeable transports — they serve fundamentally different deployment contexts (public IP vs NAT traversal). TCP is not an endpoint concern — bare TCP SSH is handled by the SSH handler directly. See ADR-010. +- **Cross-references**: ADR-001, ADR-010, [endpoint.md](crates/core/endpoint.md) diff --git a/docs/architecture/questions/006-server-side-alpn-vs-client-side-alpn.md b/docs/architecture/questions/006-server-side-alpn-vs-client-side-alpn.md new file mode 100644 index 0000000..afed422 --- /dev/null +++ b/docs/architecture/questions/006-server-side-alpn-vs-client-side-alpn.md @@ -0,0 +1,8 @@ +# OQ-06: Server-Side ALPN vs Client-Side ALPN + +- **Origin**: ADR-001 +- **Status**: resolved +- **Door type**: One-way +- **Priority**: low +- **Resolution**: One ALPN per connection. Clients open one QUIC connection per ALPN. QUIC connections are cheap (multiplexed over the same UDP flow). See ADR-006. +- **Cross-references**: ADR-001, ADR-006 diff --git a/docs/architecture/questions/007-call-protocol-scope-within-a-connection.md b/docs/architecture/questions/007-call-protocol-scope-within-a-connection.md new file mode 100644 index 0000000..7b46e7d --- /dev/null +++ b/docs/architecture/questions/007-call-protocol-scope-within-a-connection.md @@ -0,0 +1,8 @@ +# OQ-07: Call Protocol Scope Within a Connection + +- **Origin**: ADR-005 +- **Status**: resolved +- **Door type**: Two-way +- **Priority**: medium +- **Resolution**: The call protocol uses bidirectional QUIC streams with EventEnvelope framing and ID-based correlation via PendingRequestMap. The protocol is stream-agnostic — the client can open one stream per operation, multiplex on one stream, or any mix. Correlation is by request ID, not by stream. Both sides can initiate calls. One `alknet/call` connection gives access to the full operation registry (call, subscribe, batch, schema). No multiplexing layer is needed inside the connection. See ADR-012. +- **Cross-references**: ADR-005, ADR-012 diff --git a/docs/architecture/questions/008-vault-integration-point.md b/docs/architecture/questions/008-vault-integration-point.md new file mode 100644 index 0000000..f1ad0a7 --- /dev/null +++ b/docs/architecture/questions/008-vault-integration-point.md @@ -0,0 +1,8 @@ +# OQ-08: Vault Integration Point + +- **Origin**: [overview.md](overview.md) +- **Status**: resolved +- **Door type**: One-way +- **Priority**: medium +- **Resolution**: CLI-embedded, assembly-layer only. The CLI binary instantiates `VaultServiceHandle` locally at startup, derives and decrypts the credentials each handler needs, and injects them into handler capabilities. alknet-vault has no ALPN, no alknet-core dependency, and no operations registered in the call protocol. The master seed and derived private keys never cross the network. The vault is a capability source, not a network service. See ADR-008 and ADR-014. +- **Cross-references**: ADR-003, ADR-005, ADR-008, ADR-014 diff --git a/docs/architecture/questions/009-wasm-target-boundaries.md b/docs/architecture/questions/009-wasm-target-boundaries.md new file mode 100644 index 0000000..3693298 --- /dev/null +++ b/docs/architecture/questions/009-wasm-target-boundaries.md @@ -0,0 +1,8 @@ +# OQ-09: WASM Target Boundaries + +- **Origin**: [overview.md](overview.md) +- **Status**: deferred +- **Door type**: One-way (when applicable) +- **Priority**: low +- **Resolution**: Not an active question — WASM compatibility is a design constraint (see ADR-009, overview.md design principles), not a deliverable. Specific WASM targeting decisions will be made when individual crates are implemented. **BiStream being a trait preserves the *client-side* stream door** — a browser can implement BiStream over WebTransport streams. **The *server-side* dispatch door is NOT preserved by ADR-007 and is a known, accepted closure**: `Connection` is a concrete quinn-bound struct (not a trait), the accept loop uses `tokio::spawn` (tokio does not run on WASM), and the call-protocol dispatch internals (`PendingRequestMap`, `CallAdapter`) use tokio `oneshot`/`mpsc` channels. A WASM server-side peer would require a `Connection` trait and a runtime-abstracted accept loop — not planned. The browser path is client-side via a JS SDK, not server-side Rust-to-WASM. This is an explicit one-way door, not an oversight. +- **Cross-references**: ADR-007, ADR-009 diff --git a/docs/architecture/questions/010-git-adapter-scope-smart-protocol-only-or-full-server.md b/docs/architecture/questions/010-git-adapter-scope-smart-protocol-only-or-full-server.md new file mode 100644 index 0000000..f0b7ea8 --- /dev/null +++ b/docs/architecture/questions/010-git-adapter-scope-smart-protocol-only-or-full-server.md @@ -0,0 +1,8 @@ +# OQ-10: Git Adapter Scope — Smart Protocol Only or Full Server? + +- **Origin**: [overview.md](overview.md) +- **Status**: deferred +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: Deferred per the cleanup plan. Start with git smart protocol over QUIC streams. ERC721 integration and full server capabilities are additive. **Composability fork (review #002 W18)**: whether git operations are registered in the `OperationRegistry` and callable via `env.invoke()`, or only available as raw smart protocol on `alknet/git`, is a separate decision from ERC721 scope. The path of least resistance (raw smart protocol only) forecloses agent composition of git operations — an agent handler that wants to compose `git/clone` cannot, because there's no `OperationSpec`, no `Handler`, no registration. To make git composable, a call-protocol projection (a set of `HandlerRegistration` bundles wrapping git operations behind the registry) must be built alongside or instead of the raw handler. Resolve this when speccing alknet-git, not deferred past it. +- **Cross-references**: ADR-001 diff --git a/docs/architecture/questions/011-handler-level-auth-resolution-observability.md b/docs/architecture/questions/011-handler-level-auth-resolution-observability.md new file mode 100644 index 0000000..c746da8 --- /dev/null +++ b/docs/architecture/questions/011-handler-level-auth-resolution-observability.md @@ -0,0 +1,31 @@ +# OQ-11: Handler-Level Auth Resolution Observability + +- **Origin**: [auth.md](crates/core/auth.md) +- **Status**: resolved +- **Door type**: Two-way +- **Priority**: medium +- **Resolution**: **Option B — handlers store resolved identity on the Connection.** When a handler resolves identity inside `handle()` (the handler-level auth phase), it calls `connection.set_identity(identity)` to store the resolved `Identity` on the connection object. The endpoint and observability layers can read it later for connection logging, audit trails, and metrics. + + Why not Option A (return identity from `handle()`): it changes the `ProtocolHandler` trait signature for all handlers, even those that don't do auth resolution (DNS, health check). It also assumes one identity per connection — but the call protocol can have different identities per request on the same connection (one connection, multiple `call.requested` events with different auth tokens). Returning a single identity from `handle()` would be misleading for the call protocol. + + Why not Option C (identity stays local): the resolved identity is useful beyond the handler. The endpoint may want to log "connection from X authenticated as Y." A connection-level observability layer needs the identity. If it stays local, every handler that resolves identity would need to duplicate logging logic, and the endpoint can't correlate connections to identities. + + **Two identity scopes exist and must not be conflated:** + - **Connection-level identity** (this decision): set once by the handler in `handle()`, stored on `Connection`, read by the endpoint for logging/observability. This is the "connection owner" — who opened this QUIC connection. + - **Per-request identity** (already in the call protocol spec): set per `call.requested` by the `CallAdapter`, stored on `OperationContext.identity`. This is the "call caller" — who is making this specific call, which may upgrade mid-session (different auth tokens on the same connection). + + Both exist. The connection-level identity is the stable "who is this connection from"; the per-request identity is the dynamic "who is this specific call from." The call protocol's per-request resolution (which may produce a different identity than the connection-level resolution) takes precedence for ACL on `OperationContext` — the connection-level identity is for observability only, not for ACL. + + **C13 resolution (review #002)**: the endpoint does **not** read + `identity()` after `handle()` returns. The `Connection` is moved into the + spawned handler task (endpoint.md), so the endpoint no longer has a + reference to it. Connection-level observability (remote addr, ALPN, + connection ID) is logged by the endpoint *before* the move. Identity-level + observability is logged by the handler (the handler knows which identity + it resolved and can log it). There is no `Arc` sharing or + channel-based identity-reporting mechanism — the simplest honest answer + that avoids over-engineering the observability path before there's a + demonstrated need. If a future use case requires the endpoint to + correlate connections to identities, an `Arc` or a + side-channel can be added then. +- **Cross-references**: ADR-004, ADR-011, ADR-015 (per-request identity on OperationContext), [auth.md](crates/core/auth.md) diff --git a/docs/architecture/questions/012-tls-identity-provisioning-in-alknetendpoint.md b/docs/architecture/questions/012-tls-identity-provisioning-in-alknetendpoint.md new file mode 100644 index 0000000..0c4778c --- /dev/null +++ b/docs/architecture/questions/012-tls-identity-provisioning-in-alknetendpoint.md @@ -0,0 +1,18 @@ +# OQ-12: TLS Identity Provisioning in AlknetEndpoint + +- **Origin**: [endpoint.md](crates/core/endpoint.md), [config.md](crates/core/config.md) +- **Status**: resolved +- **Door type**: One-way +- **Priority**: high +- **Resolution**: TLS identity in alknet has two distinct use cases, not one: + + **Use case 1 — P2P / key-based identity (default for most alknet nodes):** RFC 7250 raw Ed25519 public keys. No domain, no CA, no cert renewal. The Ed25519 public key IS the node's identity. This is the same model iroh uses with its `NodeId`. It works natively with SSH auth (same key type) and git (SSH key-based auth). `TlsIdentity::RawKey(Ed25519SecretKey)` in `StaticConfig` covers this. As of [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md), `RawKey` uses `ed25519_dalek::SigningKey` (via an alknet-core wrapper), **not** `iroh::SecretKey` — so raw-key TLS identity is available in quinn-only builds without the `iroh` feature. + + **Use case 2 — Domain-hosted services (relays, public-facing nodes):** X.509 certificates with domain names. Required for browser/WebTransport clients, which don't support RFC 7250. This has two sub-cases: + - **Manual**: Provide cert/key file paths via `TlsIdentity::X509`. Already specified in `StaticConfig`. + - **ACME auto-provisioning**: Let's Encrypt via `rustls-acme`. `TlsIdentity::Acme { domains, cache_dir, directory, contact }` carries static config; the endpoint constructs the `AcmeState` async state machine at setup time. Feature-gated behind `acme`. Designed in [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md). The reverse-proxy project (`/workspace/@alkdev/reverse-proxy`) demonstrates the proven pattern: `AcmeConfig`, `ResolvesServerCertAcme`, TLS-ALPN-01 challenge handling, automatic renewal. + + **Browser constraint**: Browsers require X.509 and don't support RFC 7250. For browser/WebTransport clients, domain-hosted nodes with X.509 certs are mandatory. All other clients (SSH, git, alknet-native) work with raw keys by default. + + The `TlsIdentity` enum in `StaticConfig` captures all four modes (`X509`, `RawKey`, `SelfSigned`, `Acme`). [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) records the design decisions for ACME integration and RawKey decoupling. +- **Cross-references**: ADR-010, ADR-027, [config.md](crates/core/config.md), [endpoint.md](crates/core/endpoint.md) diff --git a/docs/architecture/questions/013-operation-path-format-and-routing-scope.md b/docs/architecture/questions/013-operation-path-format-and-routing-scope.md new file mode 100644 index 0000000..0cf560e --- /dev/null +++ b/docs/architecture/questions/013-operation-path-format-and-routing-scope.md @@ -0,0 +1,8 @@ +# OQ-13: Operation Path Format and Routing Scope + +- **Origin**: [operation-registry.md](crates/call/operation-registry.md) +- **Status**: resolved +- **Door type**: Two-way +- **Priority**: medium +- **Resolution**: alknet-call uses `/{service}/{op}` (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). The `/{node}/{service}/{op}` pattern from the reference implementation served a head/worker routing model that is a separate architectural concern. Remote dispatch (federation / node-level routing) would be a different mechanism at a different layer, not a prefix added to alknet-call's operation paths. See ADR-005, ADR-012. +- **Cross-references**: ADR-005, ADR-012 diff --git a/docs/architecture/questions/014-batch-operation-semantics.md b/docs/architecture/questions/014-batch-operation-semantics.md new file mode 100644 index 0000000..aa6e2a9 --- /dev/null +++ b/docs/architecture/questions/014-batch-operation-semantics.md @@ -0,0 +1,8 @@ +# OQ-14: Batch Operation Semantics + +- **Origin**: [call-protocol.md](crates/call/call-protocol.md) +- **Status**: resolved +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: Batch is a client-side pattern — multiple `call.requested` events with correlated IDs, responses arrive independently. QUIC's stream multiplexing provides the concurrency and ordering guarantees that batch would need. Batch-specific event types (e.g., `batch.requested`, `batch.responded`) would add protocol complexity without clear benefit over sending multiple `call.requested` events. See ADR-012. +- **Cross-references**: ADR-012 diff --git a/docs/architecture/questions/015-call-protocol-client-and-adapter-contract.md b/docs/architecture/questions/015-call-protocol-client-and-adapter-contract.md new file mode 100644 index 0000000..a339eba --- /dev/null +++ b/docs/architecture/questions/015-call-protocol-client-and-adapter-contract.md @@ -0,0 +1,8 @@ +# OQ-15: Call Protocol Client and Adapter Contract + +- **Origin**: [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md), ADR-013 +- **Status**: resolved +- **Door type**: One-way +- **Priority**: high +- **Resolution**: `CallClient` opens QUIC connections and shares the dispatch loop with `CallAdapter` — both sides can send and receive `call.requested` once connected. Connection direction (who opened the connection) is independent of call direction (who calls whom). `from_call` adapter discovers remote operations via `services/list` + `services/schema` and registers them with forwarding handlers — same pattern as `from_openapi` and `from_mcp`. `to_openapi` and `to_mcp` project local operations to external protocols. Adapter contract trait (`OperationAdapter`) produces `(OperationSpec, Handler)` pairs. Cross-node call tree: abort cascade (ADR-016) propagates across node boundaries through `from_call` handlers. Credentials for connections come from capabilities (ADR-014). Adapter-registered operations are `Internal` by default (ADR-015). See ADR-017. +- **Cross-references**: ADR-005, ADR-013, ADR-014, ADR-015, ADR-016, ADR-017, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) diff --git a/docs/architecture/questions/016-safe-vault-operations-for-call-protocol-exposure.md b/docs/architecture/questions/016-safe-vault-operations-for-call-protocol-exposure.md new file mode 100644 index 0000000..97d36a5 --- /dev/null +++ b/docs/architecture/questions/016-safe-vault-operations-for-call-protocol-exposure.md @@ -0,0 +1,8 @@ +# OQ-16: Safe Vault Operations for Call Protocol Exposure + +- **Origin**: [operation-registry.md](crates/call/operation-registry.md), ADR-008 +- **Status**: resolved +- **Door type**: One-way +- **Priority**: high +- **Resolution**: No vault operations are exposed over the call protocol. The vault is accessed only at the assembly layer (CLI binary at startup). Handlers receive secret material through `OperationContext.capabilities`, not by calling vault operations over the wire. The `operation-registry.md` spec previously showed `vault/derive`, `vault/unlock`, and `vault/decrypt` registered as call protocol operations — that was a contradiction with ADR-008's "capability source" model and has been corrected. See ADR-014. +- **Cross-references**: ADR-008, ADR-014, [operation-registry.md](crates/call/operation-registry.md) diff --git a/docs/architecture/questions/017-abort-cascade-semantics-for-nested-calls.md b/docs/architecture/questions/017-abort-cascade-semantics-for-nested-calls.md new file mode 100644 index 0000000..4160d9b --- /dev/null +++ b/docs/architecture/questions/017-abort-cascade-semantics-for-nested-calls.md @@ -0,0 +1,8 @@ +# OQ-17: Abort Cascade Semantics for Nested Calls + +- **Origin**: [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) +- **Status**: resolved +- **Door type**: One-way (protocol schema), two-way (mechanism) +- **Priority**: high +- **Resolution**: `call.aborted` cascades to all non-terminal descendants in the call tree. The CallAdapter walks the tree (indexed by `parent_request_id` in `PendingRequestMap`) and sends `call.aborted` for each descendant. Default policy is `abort-dependents` (abort everything downstream); `continue-running` is an opt-in for long-running work that should survive a parent's abort. Handlers clean up via Rust's async drop semantics (future dropped → `Drop` guards release resources). The cascade is protocol-level (server discovers descendants and propagates); the mechanism (parent-indexed map, cancellation tokens, or a separate graph) is a two-way door. See ADR-016. +- **Cross-references**: ADR-012, ADR-015, ADR-016, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) diff --git a/docs/architecture/questions/018-privilege-model-and-authority-context.md b/docs/architecture/questions/018-privilege-model-and-authority-context.md new file mode 100644 index 0000000..4340c2e --- /dev/null +++ b/docs/architecture/questions/018-privilege-model-and-authority-context.md @@ -0,0 +1,8 @@ +# OQ-18: Privilege Model and Authority Context + +- **Origin**: [operation-registry.md](crates/call/operation-registry.md) +- **Status**: resolved +- **Door type**: One-way (ACL model), two-way (specific APIs) +- **Priority**: high +- **Resolution**: The `internal` flag on `OperationContext` marks calls that originated from composition (a handler calling another operation via `OperationEnv`), as opposed to external calls that arrived as `call.requested` from a wire client. The `internal` flag switches the authority context: the ACL check runs against the composing handler's identity (set at registration), not the caller's identity and not as a blanket skip. This replaces the previous `trusted` flag, which skipped ACL entirely — a privilege escalation vector. Operations have External/Internal visibility. Internal operations return `NOT_FOUND` when called from the wire and are excluded from `services/list`. The composition env is scoped — a handler can only invoke a declared set of operations. Handler identity is carried on `OperationContext` alongside caller identity (the principal/agent pair). See ADR-015. +- **Cross-references**: ADR-014, ADR-015, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) diff --git a/docs/architecture/questions/019-session-scoped-operation-registries-and-agent-written-operations.md b/docs/architecture/questions/019-session-scoped-operation-registries-and-agent-written-operations.md new file mode 100644 index 0000000..d278938 --- /dev/null +++ b/docs/architecture/questions/019-session-scoped-operation-registries-and-agent-written-operations.md @@ -0,0 +1,24 @@ +# OQ-19: Session-Scoped Operation Registries and Agent-Written Operations + +- **Origin**: [operation-registry.md](crates/call/operation-registry.md) +- **Status**: resolved +- **Door type**: Two-way (protocol doesn't need changes), one-way (if implementation closes the door) +- **Priority**: medium +- **Resolution**: The call protocol supports session-scoped registries through `OperationEnv` trait layering. No protocol changes needed. The pattern is documented here and in [operation-registry.md](crates/call/operation-registry.md) to prevent an implementation from accidentally closing it. + + The registry model has three tiers: + + | Tier | Scope | Lifetime | Visibility | Who populates it | + |------|-------|----------|------------|-------------------| + | Core (global) | All sessions | Process lifetime, static at startup | External + Internal (curated) | Assembly layer at startup | + | Session | One session | Session lifetime, dynamic | Internal only (never wire-facing) | Agent during session (sandbox) | + | Promotion | Session → Core | One-time transition | Manual/curated review | Human or architect agent reviews, then redeploys | + + Session-scoped operations are always `Internal` (ADR-015), run under the handler's identity (the agent handler that authorized the sandbox), can only compose operations in the handler's scoped env, and are ephemeral (gone when the session ends). Core operations are curated — reviewed before promotion. The promotion path is the curation checkpoint where autonomous (session-scoped) becomes curated (core). This is not auto-promotion. + + **Implementation guard**: `OperationEnv` must remain a trait, not a concrete type. A session-scoped env wraps the global env (check session registry first, fall through to global). Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close this pattern. The static registration constraint (OQ-04) applies to the curated (Layer 0) registry only; session registries are dynamic by nature and are a different registry overlaying the curated one. **Generalized by ADR-024**: connection-scoped remote imports (`from_call`) use the same overlay mechanism as session-scoped ops. Both are per-scope dynamic overlays on the static curated base, composed into the per-call `OperationContext.env` by the `CallAdapter`. `OperationEnv` being a trait object (`Arc`) is what enables both overlay patterns. + + Session-scoped operations run in a locked-down sandbox (no direct net/fs/env access), can only reach operations in the handler's scoped env, and their output should be validated against their declared schema before returning. The promotion path requires review — an agent with a `promote` scope (the architect role) performs the promotion; the writing agent (lower-privileged role) requests it. This is the role-based escalation pattern (ADR-015): privileges escalate through a chain of command, not through direct authority. + + The agent-specific mechanism (quickjs sandbox, session registry lifecycle, promotion workflow) belongs to the agent crate spec. The call protocol's job is to keep the `OperationEnv` trait composable and the visibility/ACL model consistent across tiers. +- **Cross-references**: OQ-04, ADR-014, ADR-015, ADR-016, ADR-024, [operation-registry.md](crates/call/operation-registry.md) diff --git a/docs/architecture/questions/020-salt-kdf-and-encryption-key-derivation-method.md b/docs/architecture/questions/020-salt-kdf-and-encryption-key-derivation-method.md new file mode 100644 index 0000000..402add0 --- /dev/null +++ b/docs/architecture/questions/020-salt-kdf-and-encryption-key-derivation-method.md @@ -0,0 +1,8 @@ +# OQ-20: Salt/KDF and Encryption Key Derivation Method + +- **Origin**: [encryption.md](crates/vault/encryption.md) +- **Status**: resolved +- **Door type**: One-way (key derivation method), two-way (salt field usage) +- **Priority**: high +- **Resolution**: The vault uses SLIP-0010 HD derivation from the BIP39 seed at path `m/74'/2'/0'/0'` to produce the AES-256-GCM encryption key — not PBKDF2. The `salt` field in `EncryptedData` is unused for key derivation (kept for wire-format compatibility with the TS predecessor). The TypeScript `@alkdev/storage` crypto module used PBKDF2 with a password + salt; data encrypted by that method (key_version=1) cannot be decrypted by the vault and must be migrated via one-time re-encryption to key_version=2. See ADR-020 for the full rationale and migration path. +- **Cross-references**: ADR-020, [encryption.md](crates/vault/encryption.md) diff --git a/docs/architecture/questions/021-remote-vault-administration.md b/docs/architecture/questions/021-remote-vault-administration.md new file mode 100644 index 0000000..0dcf703 --- /dev/null +++ b/docs/architecture/questions/021-remote-vault-administration.md @@ -0,0 +1,14 @@ +# OQ-21: Remote Vault Administration + +- **Origin**: [service.md](crates/vault/service.md), [protocol.md](crates/vault/protocol.md), ADR-019 +- **Status**: resolved +- **Door type**: One-way (vault crate is local-only by construction) +- **Priority**: medium +- **Resolution**: Remote vault access is **not a feature of the vault crate**. ADR-025 dropped irpc from the vault, making the vault local-only by construction — no `RemoteService` trait, no wire format for vault messages, no default-insecure remote handler. The vault's API is `VaultServiceHandle` (direct method calls), nothing else. + + If remote vault access is ever needed (e.g., the machine→worker pattern), it requires a **separate vault-server crate** that depends on both alknet-core (for `IdentityProvider`, scopes, auth-wrapping) and alknet-vault (for `VaultServiceHandle`). That crate would define its own threat model, access policy, operation filtering (Unlock/Lock local-only), and wire format — and requires its own ADR. This is a deliberate addition, not a flag flip on a default that was already loaded. + + The pre-ADR-025 deferral framed remote access as "non-breaking" (the wire format was additive). That framing was misleading: once workers build dependencies on the remote vault API, disabling it breaks them — the door is operationally one-way even if the wire format is additive. ADR-025 inverts the default: the vault is local-only by construction, and remote access requires building something new, not removing a default. + + Per-node vaults are the recommended pattern for multi-node deployments: each node has its own vault and mnemonic; credentials are encrypted *for* the receiving node's public key, not decrypted centrally. This is end-to-end encryption between nodes, matching ADR-008's "capability source" model. +- **Cross-references**: ADR-005, ADR-008, ADR-014, ADR-018, ADR-019, ADR-025, [protocol.md](crates/vault/protocol.md), [service.md](crates/vault/service.md) diff --git a/docs/architecture/questions/022-key-rotation-mechanism.md b/docs/architecture/questions/022-key-rotation-mechanism.md new file mode 100644 index 0000000..c8c7c65 --- /dev/null +++ b/docs/architecture/questions/022-key-rotation-mechanism.md @@ -0,0 +1,8 @@ +# OQ-22: Key Rotation Mechanism + +- **Origin**: [encryption.md](crates/vault/encryption.md) +- **Status**: resolved +- **Door type**: One-way (path scheme), two-way (rotation policy) +- **Priority**: medium +- **Resolution**: Key rotation uses version-indexed derivation paths. Each key version maps to a distinct SLIP-0010 path: `m/74'/2'/0'/{version-2}'`. v2 (current) is at `m/74'/2'/0'/0'`; v3 is at `m/74'/2'/0'/1'`; etc. The `decrypt` method derives the key at the path indicated by `encrypted.key_version` (not always at `PATHS::ENCRYPTION`). The `rotate` method decrypts with the old version's key and re-encrypts with the new version's key — no new mnemonic needed. The assembly layer or a migration tool iterates stored blobs and calls `rotate` on each; the vault does not self-rotate. Partial rotation is safe (old keys remain derivable). See ADR-021. +- **Cross-references**: ADR-020, ADR-021, [encryption.md](crates/vault/encryption.md), [service.md](crates/vault/service.md) diff --git a/docs/architecture/questions/023-handler-identity-registration-path-and-composition-authority.md b/docs/architecture/questions/023-handler-identity-registration-path-and-composition-authority.md new file mode 100644 index 0000000..38eb934 --- /dev/null +++ b/docs/architecture/questions/023-handler-identity-registration-path-and-composition-authority.md @@ -0,0 +1,8 @@ +# OQ-23: Handler Identity Registration Path and Composition Authority + +- **Origin**: [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md), ADR-015 +- **Status**: resolved +- **Door type**: One-way (security model), two-way (bundle shape) +- **Priority**: high +- **Resolution**: ADR-015 said handler identity was "set at registration by the assembly layer" but the registration API (`register(spec, handler)`) had no place for it — meaning every internal call would check ACL against `None`, reproducing the escalation gap ADR-015 was written to close. ADR-022 resolves this with a registration bundle (`HandlerRegistration`) carrying `provenance`, `composition_authority` (replacing `handler_identity: Identity` — it's a declared authority bundle, not a peer identity), `scoped_env`, and `capabilities`. The dispatch path (`build_root_context` and `OperationEnv::invoke()`) reads from the bundle. Provenance determines which ops can compose: only `Local` and `Session` get composition authority; leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) get `None` — they don't compose, so they don't need it. Capabilities are per-request on `OperationContext`, populated from the bundle (resolving the closure-capture vs context ambiguity). The kernel/user analogy: user's authority checked once at the External gate; handler's composition authority used for all composition inside; scoped env bounds reachability. No intersection — the user's authority does not limit internal calls. See ADR-022. +- **Cross-references**: ADR-014, ADR-015, ADR-022, docs/reviews/001-pre-implementation-architecture-sanity-check.md (C1–C4), [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md) diff --git a/docs/architecture/questions/024-operation-error-schemas.md b/docs/architecture/questions/024-operation-error-schemas.md new file mode 100644 index 0000000..85a6ded --- /dev/null +++ b/docs/architecture/questions/024-operation-error-schemas.md @@ -0,0 +1,13 @@ +# OQ-24: Operation Error Schemas + +- **Origin**: [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md), ADR-017 +- **Status**: resolved +- **Door type**: One-way (wire format), two-way (mapping mechanism) +- **Priority**: high +- **Resolution**: `OperationSpec` gains `error_schemas: Vec` where each `ErrorDefinition` carries a `code`, `description`, `schema` (JSON Schema for the error detail payload), and optional `http_status` (for adapter projection). The `call.error` payload gains an optional `details` field carrying the typed error payload. Protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, + `INVALID_OPERATION_TYPE`, `INTERNAL`, `TIMEOUT`) are distinct from + operation-level domain codes (`FILE_NOT_FOUND`, `RATE_LIMITED`, etc.) — + protocol codes are emitted by the dispatch machinery, operation codes by + handlers. The six-code protocol-level list was extended from five by + ADR-049 (`INVALID_OPERATION_TYPE`). `from_openapi`/`to_openapi` map OpenAPI response status codes to/from `ErrorDefinition`s, making the adapter contract from ADR-017 faithful on the error axis. `services/schema` exposes `error_schemas` for client code generation. See ADR-023. +- **Cross-references**: ADR-017, ADR-023, docs/reviews/001-pre-implementation-architecture-sanity-check.md (C5), [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md) diff --git a/docs/architecture/questions/025-remote-safe-marking-shape-for-callclient-peer-scoped-filtering-dissolved-by-adr-029.md b/docs/architecture/questions/025-remote-safe-marking-shape-for-callclient-peer-scoped-filtering-dissolved-by-adr-029.md new file mode 100644 index 0000000..805ecd9 --- /dev/null +++ b/docs/architecture/questions/025-remote-safe-marking-shape-for-callclient-peer-scoped-filtering-dissolved-by-adr-029.md @@ -0,0 +1,19 @@ +# OQ-25: ~~Remote-Safe Marking Shape for CallClient Peer-Scoped Filtering~~ (Dissolved by ADR-029) + +- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 (§1 Consequences), ADR-028 +- **Status**: **dissolved** (ADR-029) +- **Door type**: ~~Two-way (shape only — existence is one-way, resolved by ADR-028)~~ +- **Priority**: ~~medium~~ +- **Resolution**: **Dissolved by [ADR-029](decisions/029-peer-graph-routing-model.md).** + ADR-028's `remote_safe: bool` / `trusted_peer` model is superseded — it was a + parallel, weaker authorization system that duplicated the existing + `AccessControl`/`Identity` machinery. ADR-029 retires `remote_safe`/ + `trusted_peer` entirely; peer authorization flows through + `AccessControl::check(peer_identity)`. The op's `AccessControl` *is* the + peer-authorization policy — there is no separate marking. Per-peer + differentiation is via `IdentityProvider` config (different peers get + different scopes), not a per-op boolean. The "shape" question is moot + because there is no marking to shape. See ADR-029 §3. +- **Cross-references**: ADR-009, ADR-014, ADR-015, ADR-017, ADR-022, ADR-024, + ~~ADR-028~~ (superseded), ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md), + [operation-registry.md](crates/call/operation-registry.md) diff --git a/docs/architecture/questions/026-operationadapter-error-type-adaptererror-variants.md b/docs/architecture/questions/026-operationadapter-error-type-adaptererror-variants.md new file mode 100644 index 0000000..17ad438 --- /dev/null +++ b/docs/architecture/questions/026-operationadapter-error-type-adaptererror-variants.md @@ -0,0 +1,22 @@ +# OQ-26: OperationAdapter Error Type (AdapterError Variants) + +- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 §5, [ADR-029](decisions/029-peer-graph-routing-model.md) §5 +- **Status**: **resolved** (2026-06-27) +- **Door type**: Two-way +- **Priority**: medium +- **Resolution**: The `AdapterError` enum is `#[non_exhaustive]` + + `thiserror::Error`, with these v1 variants: + - `DiscoveryFailed { message: String }` — `from_call` remote unreachable / `services/list` failed + - `SchemaParse { message: String }` — `from_openapi` / `from_jsonschema` couldn't parse the spec + - `Transport { message: String }` — underlying transport error (QUIC for `from_call`, HTTP for `from_openapi`/`from_mcp`) + - `Unauthorized { message: String }` — HTTP 401 for `from_openapi`/`from_mcp`, auth rejected for `from_call` + - `SamePeerCollision { message: String }` — namespace collision *within a single peer* (ADR-029 §5: cross-peer collision dissolves; same-peer collision stays an error). Replaces the flat `Conflict` variant from the pre-ADR-029 implementation. + + `#[non_exhaustive]` lets `alknet-http`'s adapters extend without breaking + match arms. The variant payloads are `String` messages — kept simple and + `Send + Sync` by construction. This matches the shipped implementation + (`crates/alknet-call/src/client/mod.rs`) except `Conflict` → + `SamePeerCollision` (the ADR-029 migration renames it). Two-way door: + adding variants later is non-breaking; renaming a variant is a match-arm + update but not an architectural change. +- **Cross-references**: ADR-017, ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md) diff --git a/docs/architecture/questions/027-from-call-re-import-trigger.md b/docs/architecture/questions/027-from-call-re-import-trigger.md new file mode 100644 index 0000000..69e1e47 --- /dev/null +++ b/docs/architecture/questions/027-from-call-re-import-trigger.md @@ -0,0 +1,15 @@ +# OQ-27: from_call Re-Import Trigger + +- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 Assumption 4 +- **Status**: **resolved** (2026-06-27) +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: The decision is **auto-re-import on connection + establishment**. The overlay is per-connection (Layer 2, ADR-024), so a + stale overlay dies with the connection; re-import on reconnect is + naturally scoped to the new connection. This is the right default for the + runner pattern (a worker reconnects → the hub re-discovers the worker's + ops automatically). An explicit `CallConnection::refresh()` method is a + genuine feature addition — non-breaking, additive — if a deployment + needs manual control. +- **Cross-references**: ADR-017, ADR-024, [client-and-adapters.md](crates/call/client-and-adapters.md) diff --git a/docs/architecture/questions/028-from-call-namespace-collision-behavior.md b/docs/architecture/questions/028-from-call-namespace-collision-behavior.md new file mode 100644 index 0000000..846c366 --- /dev/null +++ b/docs/architecture/questions/028-from-call-namespace-collision-behavior.md @@ -0,0 +1,23 @@ +# OQ-28: from_call Namespace Collision Behavior + +- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 §3 +- **Status**: **resolved** (2026-06-27) +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: ADR-017 §3's `FromCallConfig` namespace prefix is + **optional, default no prefix, same-peer collision = error**. A node + importing from a peer that exposes two ops with the same name should fail + loudly rather than silently overwrite. This matches the default-deny, + explicit-allow posture (ADR-015). The alternative (last-wins) would + silently mask one op behind another, which is the kind of surprise the + default-deny posture exists to avoid. + + **Cross-peer collision dissolved by ADR-029.** Under the peer-keyed + overlay model, same name on different peers is fine — they live in + separate peer sub-overlays, no collision, no prefix needed. + `FromCallConfig::namespace_prefix` is optional local-naming sugar for + when the importing node wants to expose a peer's ops under a different + name *locally* — a local-naming concern, not a disambiguation concern. + See ADR-029 §5. +- **Cross-references**: ADR-015, ADR-017, ~~ADR-028~~ (superseded), ADR-029, + [client-and-adapters.md](crates/call/client-and-adapters.md) diff --git a/docs/architecture/questions/029-callclient-tls-client-auth-and-remote-identity-verification.md b/docs/architecture/questions/029-callclient-tls-client-auth-and-remote-identity-verification.md new file mode 100644 index 0000000..ffbb821 --- /dev/null +++ b/docs/architecture/questions/029-callclient-tls-client-auth-and-remote-identity-verification.md @@ -0,0 +1,52 @@ +# OQ-29: CallClient TLS Client-Auth and Remote-Identity Verification + +- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 §7 +- **Status**: **resolved** (2026-06-27 by ADR-030 §6 + this decision) +- **Door type**: One-way (identity model interaction), two-way (mechanism) +- **Priority**: ~~high~~ → resolved +- **Resolution**: **Three things are decided:** + + 1. **Wire quinn client-auth.** The client presents its Ed25519 key as an + RFC 7250 raw public key client cert (the client-side equivalent of + the server's `RawKeyCertResolver`). The server's + `AcceptAnyCertVerifier` already requests client certs and extracts + the fingerprint — the gap was entirely on the client side + (`with_no_client_auth()` → present the key). This activates the + `PeerEntry` fingerprint → `peer_id` resolution path on quinn + connections. + + 2. **Key-type-aware server cert verification.** The client's + `ServerCertVerifier` depends on the remote's identity type: + - **Ed25519 raw key** (the common case): accept the cert, extract the + fingerprint, match against `PeerEntry.fingerprints`. The fingerprint + IS the trust anchor — no CA needed. (Same model as iroh.) + - **X.509** (domain-facing endpoints, ACME): verify against a CA + (rustls's `WebPkiServerVerifier` with the platform root store or a + configured CA). `AcceptAnyServerCertVerifier` is a security hole for + X.509 — it's only safe for raw keys. + - The verifier choice is driven by `CallCredentials.remote_identity`, + which carries the expected key type. + + 3. **Fingerprint normalization** (ADR-030 §6): the quinn path extracts + the raw Ed25519 public key from the SPKI cert and formats it as + `ed25519:`, matching iroh. The same key has the same fingerprint + regardless of transport. X.509 fingerprints stay as `SHA256:`. + + **The iroh path already works** — iroh uses RFC 7250 raw keys, both + sides automatically exchange Ed25519 public keys during the TLS + handshake, and `extract_iroh_client_fingerprint` already gets the + `NodeId`. No client-auth wiring needed for iroh (direct or relay). The + gap was quinn-only. + + **What's genuinely additive** (not blocking the ADR-029 migration): + remote-identity verification (the client verifying the server's + fingerprint against an expected value) is additive — the server-side + fingerprint extraction is what matters for `PeerId`, not the client-side + verification. The verifier for raw keys can start as "accept any, extract + fingerprint" and add fingerprint-pinning later. + + See ADR-030 §6 for the fingerprint normalization details. +- **Cross-references**: ADR-014, ADR-017, ADR-027, ADR-029, ADR-030, + [client-and-adapters.md](crates/call/client-and-adapters.md), + [endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md) diff --git a/docs/architecture/questions/030-peerref-any-routing-policy.md b/docs/architecture/questions/030-peerref-any-routing-policy.md new file mode 100644 index 0000000..61695f3 --- /dev/null +++ b/docs/architecture/questions/030-peerref-any-routing-policy.md @@ -0,0 +1,17 @@ +# OQ-30: PeerRef::Any Routing Policy + +- **Origin**: [ADR-029](decisions/029-peer-graph-routing-model.md) §2, [client-and-adapters.md](crates/call/client-and-adapters.md), `docs/research/alknet-call-peer-routing/findings.md` §3.2 +- **Status**: **resolved** (2026-06-27) +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: `PeerRef::Any` uses **insertion-order first-match** — + deterministic but order-dependent (worker A connects before worker B → + `Any` routes to A until A disconnects). This is the simplest routing + policy and is correct for the immediate use case (the head picks the + first worker that serves the op). A richer `RoutingPolicy` (round-robin, + least-loaded, affinity) is a feature extension — the `PeerRef` enum is + designed to compose with a `Route { selector, policy }` struct without + breaking the `invoke_peer` signature. Adding a routing policy is + non-breaking; it's a feature addition when a fan-out use case needs it, + not an unmade architectural decision. +- **Cross-references**: ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md) diff --git a/docs/architecture/questions/031-services-list-peers-re-export-semantics.md b/docs/architecture/questions/031-services-list-peers-re-export-semantics.md new file mode 100644 index 0000000..140354e --- /dev/null +++ b/docs/architecture/questions/031-services-list-peers-re-export-semantics.md @@ -0,0 +1,17 @@ +# OQ-31: services/list-peers Re-Export Semantics + +- **Origin**: [ADR-029](decisions/029-peer-graph-routing-model.md) §6, `docs/research/alknet-call-peer-routing/findings.md` §3.5 +- **Status**: **resolved** (2026-06-27) +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: `services/list` defaults to **"own ops only"** — it shows + the head's own Layer 0 `External` ops, filtered by + `AccessControl::check(calling_peer)`, unchanged from today (minus the + retired `remote_safe` filter). A `services/list-peers` opt-in (new + built-in operation) lists the peer overlays with attribution: each + peer's sub-overlay listed as `{ peer: Option, operations: [...] }`, + filtered by the calling peer's authorization. The re-export policy is an + `AccessControl` decision on the listing op. Whether `services/list-peers` + is built now or as a feature addition is a scheduling question — the + decision (opt-in, `AccessControl`-filtered) is made. +- **Cross-references**: ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md) diff --git a/docs/architecture/questions/032-multi-hop-federation.md b/docs/architecture/questions/032-multi-hop-federation.md new file mode 100644 index 0000000..e0704df --- /dev/null +++ b/docs/architecture/questions/032-multi-hop-federation.md @@ -0,0 +1,19 @@ +# OQ-32: Multi-Hop Federation + +- **Origin**: [ADR-029](decisions/029-peer-graph-routing-model.md) §3.7, `docs/research/alknet-call-peer-routing/findings.md` §3.7 +- **Status**: deferred(scope) +- **Door type**: One-way (federation model), two-way (mechanism) +- **Priority**: low +- **Blocked on**: A concrete use case for multi-hop federation. The one-hop model covers all current use cases (head→worker, runner→hub). +- **Resolution**: The model is **one-hop** — worker A does not transitively + see worker B's ops through the head unless the head explicitly re-exports + them. The peer-keyed overlay model extends to multi-hop without redesign + (a chain of `PeerRef::Specific` routing decisions), but path-finding + (which peer reaches which op transitively) is where a graph library + (petgraph) would pay off. For one-hop (shallow), a nested + `HashMap>` suffices. Multi-hop federation is + a feature extension — the one-hop model is the architectural commitment; + extending to multi-hop doesn't break downstream crates. Whether multi-hop + becomes a real use case is a future decision; the peer-keyed model does + not foreclose it. +- **Cross-references**: ADR-029, [client-and-adapters.md](crates/call/client-and-adapters.md) diff --git a/docs/architecture/questions/033-peerid-cryptographic-identity-vs-stable-logical-identifier.md b/docs/architecture/questions/033-peerid-cryptographic-identity-vs-stable-logical-identifier.md new file mode 100644 index 0000000..c0ae16c --- /dev/null +++ b/docs/architecture/questions/033-peerid-cryptographic-identity-vs-stable-logical-identifier.md @@ -0,0 +1,37 @@ +# OQ-33: PeerId — Cryptographic Identity vs Stable Logical Identifier + +- **Origin**: [ADR-029](decisions/029-peer-graph-routing-model.md) Assumption 1, `docs/research/alknet-call-peer-routing/findings.md` §6.1 +- **Status**: **resolved** (2026-06-27 by ADR-030) +- **Door type**: One-way (composition semantics), two-way (id source) +- **Priority**: high +- **Resolution**: `PeerId` is a **logical identifier, decoupled from the + cryptographic identity**. It is *not* the raw fingerprint or API-key + prefix — those change on key rotation, which would break every + in-flight `PeerRef::Specific` and every ACL entry referencing that peer. + + ADR-029 established the one-way door (`PeerId` is logical, not crypto) + with a v1 UUID source as a no-storage workaround. **ADR-030 supersedes + the UUID source**: `Identity.id` becomes `PeerEntry.peer_id` (stable + across key rotation) on the fingerprint path, and `PeerId = + Identity.id` from `IdentityProvider` resolution. The UUID workaround is + removed — the stable logical id is the real thing, sourced from the auth + system, not an ephemeral connection-assigned value. + + The `PeerEntry` config model (`peer_id`, `fingerprint`, `scopes`, + `resources`, `display_name`, `enabled`) lives in `AuthPolicy`. Key + rotation is a single `PeerEntry.fingerprint` update — the `peer_id`, + ACL entries, and `PeerRef::Specific` references stay stable. The + no-DB posture is preserved (core has the trait + the in-memory + `ConfigIdentityProvider` adapter; persistence adapters are additive + separate crates, ADR-033). + + **The one-way door (preserved from ADR-029):** `PeerId` is a logical id, + not `Identity.id` (the fingerprint). This determines the + `PeerCompositeEnv` key type, the `PeerRef::Specific` payload type, and + the `ScopedPeerEnv.peer_pinned` entry shape. The *source* of the logical + id (ADR-029's UUID → ADR-030's `PeerEntry.peer_id`) was the two-way-door + remainder; it is now resolved. +- **Cross-references**: ADR-009, ADR-014, ADR-015, ADR-017, ADR-021, ADR-027, + ADR-029, ADR-030, OQ-34, OQ-35, [client-and-adapters.md](crates/call/client-and-adapters.md), + [operation-registry.md](crates/call/operation-registry.md), + [auth.md](crates/core/auth.md) diff --git a/docs/architecture/questions/034-persistent-peer-registry-cross-node-state-storage.md b/docs/architecture/questions/034-persistent-peer-registry-cross-node-state-storage.md new file mode 100644 index 0000000..33033f5 --- /dev/null +++ b/docs/architecture/questions/034-persistent-peer-registry-cross-node-state-storage.md @@ -0,0 +1,36 @@ +# OQ-34: Persistent Peer Registry (Cross-Node State Storage) + +- **Origin**: OQ-33 (the storage dimension it surfaced), the no-DB posture of ADR-008/018/025 +- **Status**: **resolved** (2026-06-27 by ADR-030 + ADR-031 + ADR-033) +- **Door type**: One-way (storage boundary), two-way (backend choice) +- **Priority**: ~~medium (not a v1 blocker)~~ → resolved +- **Resolution**: The storage boundary is: **core defines repo traits + + in-memory default adapters; persistence adapters are separate crates; + the assembly layer wires the adapter.** This is the repo/adapter + pattern (ADR-033), already established by `IdentityProvider` (ADR-004) + and now extended to `CredentialStore` (ADR-031). + + - `IdentityProvider` (ADR-004) — the auth repo trait, in core. + `ConfigIdentityProvider` is the in-memory default, backed by + `AuthPolicy.peers` (ADR-030). A future `alknet-peer-store-sqlite` + adapter that persists `PeerEntry` records in a `peers` table is + additive — it implements the same trait. + - `CredentialStore` (ADR-031) — the credential repo trait, in core. + `InMemoryCredentialStore` is the in-memory default. A future + persistence adapter is additive. + + The no-DB posture of the core crates is preserved in the sense that + matters: core has **no backend dependency** (no SQLite, no honker). The + in-memory default adapters carry no persistence. The persistence + adapters are additive crates, built when a concrete use case forces + them, wired by the assembly layer. + + The concrete adapter shapes (table schemas, backend choice, indexing, + 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, ADR-035, OQ-33, OQ-36, + [auth.md](crates/core/auth.md), [config.md](crates/core/config.md) diff --git a/docs/architecture/questions/035-api-key-identity-vs-peer-identity-dissolved.md b/docs/architecture/questions/035-api-key-identity-vs-peer-identity-dissolved.md new file mode 100644 index 0000000..5eb0227 --- /dev/null +++ b/docs/architecture/questions/035-api-key-identity-vs-peer-identity-dissolved.md @@ -0,0 +1,26 @@ +# OQ-35: ~~API Key Identity vs Peer Identity~~ (Dissolved) + +- **Origin**: ADR-030 §"API keys" (the asymmetry between the two auth paths) +- **Status**: **dissolved** (2026-06-27 — the framing was wrong) +- **Door type**: ~~One-way~~ +- **Priority**: ~~medium~~ +- **Resolution**: **Dissolved.** The original framing ("the fingerprint + path gets `PeerEntry` id-decoupling, the API-key path doesn't — the + asymmetry is deliberate") was based on a false distinction between "peer + bearer" and "auth bearer" tokens. 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` supports multiple credential paths: `fingerprints: Vec` + (Ed25519 and/or X.509) + `auth_token_hash: Option` (bearer + token). All resolve to the same `peer_id`. + - `ApiKeyEntry` is for bearer tokens that ARE the identity (rotation = + new identity, no stable logical id needed). + + A bearer token that is one credential path among several for a stable + peer goes in `PeerEntry.auth_token_hash`. A bearer token that IS the + identity stays in `ApiKeyEntry`. The distinction is whether the token + needs a stable logical id across rotation, not "peer bearer vs auth + bearer." See ADR-030 §"Bearer tokens." +- **Cross-references**: ADR-030, [auth.md](crates/core/auth.md), + [config.md](crates/core/config.md) diff --git a/docs/architecture/questions/036-concrete-persistence-adapter-shapes.md b/docs/architecture/questions/036-concrete-persistence-adapter-shapes.md new file mode 100644 index 0000000..bb25a19 --- /dev/null +++ b/docs/architecture/questions/036-concrete-persistence-adapter-shapes.md @@ -0,0 +1,63 @@ +# 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**: **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 → 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). + + 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. + + 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) diff --git a/docs/architecture/questions/037-x-509-outgoing-only-case-three-peer-roles.md b/docs/architecture/questions/037-x-509-outgoing-only-case-three-peer-roles.md new file mode 100644 index 0000000..34fd504 --- /dev/null +++ b/docs/architecture/questions/037-x-509-outgoing-only-case-three-peer-roles.md @@ -0,0 +1,60 @@ +# OQ-37: X.509 Outgoing-Only Case (Three Peer Roles) + +- **Origin**: ADR-030 §"Bearer tokens" (the three credential types), the + discussion that X.509 is fundamentally different from Ed25519 +- **Status**: **resolved** (2026-06-28 by ADR-034) +- **Door type**: One-way (how X.509 server identity integrates with the + peer model) +- **Priority**: medium → resolved +- **Resolution**: **The pre-ADR-034 framing conflated three distinct + remote roles under "X.509 endpoint."** [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) + names them and resolves the peer-model question: + + 1. **Public X.509 endpoint** — a remote HTTPS / `alknet/call`-over-TLS + server reachable by domain, authenticated by CA verification + (`WebPkiServerVerifier`). The local node is a *client*; it + authenticates by bearer token. **Not a `PeerEntry` on the client + side** — it is not in the call-protocol peer graph (ADR-029), gets + no `PeerId`, and is not addressable via `PeerRef::Specific`. Ops + discovered via `from_call`/`from_openapi`/`from_mcp` land in the + connection's Layer 2 overlay and are invoked through the + connection handle. + 2. **Transport relay** — iroh's DERP-equivalent (`iroh-relay`). + Infrastructure, not an alknet peer; no `PeerEntry` / `PeerId`. + Inherited with the `iroh` feature; its identity is iroh's concern. + 3. **Hub / hosting node** — an alknet application peer (head/worker + hub, git-hosting hub) that *also* exposes a public domain + X.509 + for browsers. A single `PeerEntry` with **mixed fingerprints** + (`ed25519:...` + `SHA256:...`), already supported by ADR-030. + Browsers connecting to it are *not* alknet peers — served by + `alknet-http`, bearer-token auth, no `PeerId`. + + **The "make `PeerEntry` symmetric" instinct is rejected.** `PeerEntry` + is for peers in the call-protocol peer graph; pure-client connections + to public X.509 endpoints are not in that graph on the client side. + The asymmetry reflects a real trust-model difference: known peers have + stable logical identities (pin the fingerprint); public APIs don't + (trust the CA, hold the connection handle directly). + + **Client-side verifier selection rule (extends OQ-29):** known peer + (`PeerEntry` present) → fingerprint pin (Ed25519 `ed25519:` or + X.509 `SHA256:`); unknown X.509 remote (`PeerEntry` absent) → CA + verification. An unknown Ed25519 raw-key remote cannot be verified at + all (no CA fallback) and fails closed — same model as iroh. + + **Downstream, not blocking, recorded so they don't get lost:** + WebTransport relay-as-proxy (browser → proxy → P2P hub) is the + remaining scope question tracked as OQ-38 (h3/WebTransport itself is + now in scope, ADR-038); ADR-030 §6's fingerprint normalization already + keeps the proxied path clean. On-chain / smart-contract peer + discovery (relays syncing git repos via iroh gossip) is a *source* of + `PeerEntry` records, fits the OQ-36 repo/adapter pattern + (`alknet-peer-store-onchain` implementing `IdentityProvider`), and + does not change the auth model. + + Not blocking the ADR-029 migration — the Ed25519 path is the primary + use case and was already resolved; this ADR closes the X.509 + outgoing-only remainder. +- **Cross-references**: ADR-027, ADR-029, ADR-030, ADR-033, ADR-034, + OQ-29, OQ-36, [client-and-adapters.md](crates/call/client-and-adapters.md), + [endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md) diff --git a/docs/architecture/questions/038-webtransport-standalone-relay-service-scope.md b/docs/architecture/questions/038-webtransport-standalone-relay-service-scope.md new file mode 100644 index 0000000..89ff6ad --- /dev/null +++ b/docs/architecture/questions/038-webtransport-standalone-relay-service-scope.md @@ -0,0 +1,46 @@ +# OQ-38: WebTransport Standalone Relay Service Scope + +- **Origin**: [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) + §5, [webtransport.md](crates/http/webtransport.md) +- **Status**: open (scope, not deferral) +- **Door type**: One-way (crate boundary), two-way (mechanism) +- **Priority**: low +- **Resolution**: There are two distinct "WebTransport proxy" concepts + that must not be conflated: + + 1. **In-process ALPN-stream-proxy (resolved, in `alknet-http`).** + The `h3` handler hands a WebTransport stream to another ALPN + handler (`SshAdapter`, `GitAdapter`, etc.) as a `Connection`, so + a browser with a WASM parser can reach any ALPN service via + WebTransport. This is resolved by + [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) and + lives in `alknet-http`'s `h3` handler. Not this OQ. + + 2. **Standalone relay service (this OQ).** A full relay — a fork of + `iroh-relay` — that provides NAT traversal infrastructure with + WebTransport-based proxy as a fallback alongside WebSocket. This + is a separate service, not a mode of the `h3` handler: it + terminates the browser's WebTransport connection and forwards + encrypted traffic to a P2P hub's Ed25519 endpoint (so the hub need + not expose its own public X.509 cert). ADR-034 §5 recorded it in + the h3/WebTransport bucket; ADR-038 brought h3/WebTransport into + scope (later superseded by [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md), + which deferred h3/WebTransport as a scope decision — the browser + bidirectional path uses WebSocket); ADR-040 resolved the in-process + proxy (now parked per ADR-044). This OQ is the remaining scope + question: does the standalone relay live in a future `alknet-relay` + crate (a fork of `iroh-relay` with WebTransport proxy fallback) or + is it out of scope for the current alknet work? + + This is a genuine scope question, not a deferral. The relay use case + is not yet concrete enough to commit the crate boundary — no + deployment has asked for a standalone relay with WebTransport + fallback yet, and the design (transport-only proxy, no auth-model + change per ADR-034 §5) is clear but the home is not. The decision is + made when the browser-to-P2P-peer relay use case becomes concrete; + until then it is tracked here, not deferred with "v1/later" language. + The relay does not change the auth model (bearer token + + `PeerEntry.auth_token_hash`; relay is transport-only), so it does not + block any other ADR. +- **Cross-references**: ADR-027, ADR-030, ADR-034, ADR-038 (superseded), + ADR-040 (parked), ADR-044, [webtransport.md](crates/http/webtransport.md) diff --git a/docs/architecture/questions/039-to-openapi-published-spec-versioning.md b/docs/architecture/questions/039-to-openapi-published-spec-versioning.md new file mode 100644 index 0000000..e89c94f --- /dev/null +++ b/docs/architecture/questions/039-to-openapi-published-spec-versioning.md @@ -0,0 +1,54 @@ +# OQ-39: `to_openapi` Published-Spec Versioning + +- **Origin**: [ADR-017](decisions/017-call-protocol-client-and-adapter-contract.md) + Consequences, [http-adapters.md](crates/http/http-adapters.md) +- **Status**: **resolved** (2026-06-30 by ADR-045) +- **Door type**: One-way (after first publication), two-way (before) +- **Priority**: medium → resolved +- **Resolution**: **[ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md) + commits the versioning scheme.** The gateway pattern (ADR-042) + dissolved most of the original concern: the published doc describes + **5 fixed gateway endpoints** (`/search`, `/schema`, `/call`, + `/batch`, `/subscribe`), not the per-operation surface. Per-caller + operation changes (add/remove/modify an operation, change an + operation's schema) do **not** change the published doc — the + operation set is discovered at runtime via `AccessControl`-filtered + `/search`, not preloaded into the doc. So the version does not churn + on every operation change (the original OQ-39 worry, framed under the + pre-ADR-042 per-operation-paths model). + + What remains is narrow: how the published gateway doc signals its + version. The decision: + + 1. **`to_openapi` emits `info.version` as semver.** Standard OpenAPI + field, standard semver interpretation — no alknet-specific + detection mechanism. + 2. **The version tracks the gateway endpoint contract, not the + operation set.** Major = breaking change to the gateway (endpoint + removed/renamed, required request field added, response shape + changed, error-mapping semantics changed per ADR-023); Minor = + additive (new endpoint, new optional field); Patch = wording/docs. + Per-caller operation changes do **not** bump the version. + 3. **Bump on change to the gateway shape, not on regeneration.** + A restart that regenerates the same gateway shape yields the same + version. + 4. **Consumers detect breaking changes via the major version.** A + client compares `info.version`'s major component to the version it + built against; a major bump signals "re-read the doc, something + broke." Minor/patch are informational. + 5. **The additive traditional per-operation-paths projection + (ADR-042 §5) versions independently** on its own schedule — its + surface *does* change with the operation set, so its versioning is + the per-operation churn the original OQ-39 framed. That projection + is opt-in and out of scope for ADR-045; the gateway doc is the + default published contract and the one ADR-045 governs. + + The original "version marker emitted so consumers can detect mapping + changes" constraint (from ADR-017 Consequences) is satisfied by + `info.version` semver. ADR-045 lifts the "published artifact is a + contract" blind spot in ADR-009's framework (it classifies doors by + reversal cost in the codebase, not by compatibility cost for external + consumers) into its Context and honors the constraint without changing + ADR-009's framework. +- **Cross-references**: ADR-009, ADR-017, ADR-023, ADR-036, ADR-042, + ADR-045, [http-adapters.md](crates/http/http-adapters.md) diff --git a/docs/architecture/questions/040-reqwest-client-config-and-connection-pooling.md b/docs/architecture/questions/040-reqwest-client-config-and-connection-pooling.md new file mode 100644 index 0000000..78d663b --- /dev/null +++ b/docs/architecture/questions/040-reqwest-client-config-and-connection-pooling.md @@ -0,0 +1,54 @@ +# OQ-40: reqwest Client Config and Connection Pooling + +- **Origin**: [http-adapters.md](crates/http/http-adapters.md), + [http-mcp.md](crates/http/http-mcp.md), the alknet-http Phase 0 + findings DH-7 +- **Status**: resolved (2026-06-30) +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: `alknet-http` owns a shared HTTP client constructed + once and reused across all `from_openapi`/`from_mcp` forwarding + handlers. The client carries connection pooling, keep-alive, TLS, + and a retry stack. The config shape is: + + | Aspect | Decision | + |--------|----------| + | Shared client type | `reqwest_middleware::ClientWithMiddleware` (not a bare `reqwest::Client`) — required because both retry and Retry-After are middleware on the stack | + | Middleware stack | `RetryTransientMiddleware` (from `reqwest-retry` — exponential backoff on transient failures: connection errors, 5xx) + inlined `RetryAfterMiddleware` (parses the `Retry-After` header on 429/503 and sleeps before the next request to that URL) | + | `Retry-After` handler | Inlined from `melotic/reqwest-retry-after` (MIT, ~50 lines of real logic). The crate is complementary to `reqwest-retry`, not a replacement — `reqwest-retry`'s default strategy does not honor `Retry-After`, which is why the separate middleware exists. Inlining lets the unbounded `HashMap` storage in the upstream crate be bounded (the melotic version grows without limit over a long-running process). | + | Pooling / keep-alive / TLS | `reqwest::ClientBuilder` defaults; system trust store for outbound HTTPS (standard calls to OpenAI, Anthropic, etc.) | + | Hot-reload | Rebuild-and-swap the `ClientWithMiddleware` via `ArcSwap` (same pattern as `ConfigIdentityProvider`, ADR-035). A rebuild drops the connection pool / keep-alive state — acceptable, since a config change wanting a fresh pool is the case that triggers it. Retry policy is baked into the middleware at `ClientBuilder::build()` time; live policy mutation is not supported by `reqwest-retry` (no cheap per-policy update path exists). | + | Credentials | Per-request from `OperationContext.capabilities` — see the one-way constraints below | + + The one-way constraints (settled before this OQ, restated unchanged): + (1) `alknet-http` owns its HTTP client — no env-var-based client + config, no shared global client; (2) credential injection happens + per-request (from `OperationContext.capabilities`), not at client + construction — the client is shared across all operations, the + credentials are per-call; (3) TLS for outbound calls uses the + system trust store by default (custom CA bundle + client certs are + an optional config for self-hosted API gateways). + + **Downstream layering boundary (so the agent crate doesn't + accidentally re-invent a client).** The agent crate's provider SSE + normalization (replicating the solid part of aisdk's pattern — the + Vercel-UI-message normalization that maps different providers' SSE + to a common shape) sits *on top of* this `ClientWithMiddleware`: it + consumes the `reqwest::Response` stream the forwarding handler + produces and emits `call.responded` events. It does not replace the + client or own transport/pooling/retry. `alknet-http` owns transport; + the agent crate owns provider-specific SSE → Vercel-UI-message + mapping. The aisdk `core/client.rs` reference for HTTP client + construction is *not* carried forward — its env-var config and + hand-rolled retry are the anti-patterns being discarded; the + aisdk/`@alkdev/operations/src/from_openapi.ts` SSE *normalization* + pattern is separate and stays referenced in the forwarding-handler + section of [http-adapters.md](crates/http/http-adapters.md). + + No ADR — the decision is internal to `alknet-http`: the client type + does not cross crate boundaries (`alknet-call` never sees reqwest), + the library choice is reversible, and it does not touch the + system's structure, constraints, or API surface across crates. +- **Cross-references**: ADR-014, ADR-017, ADR-035, + [http-adapters.md](crates/http/http-adapters.md), + [http-mcp.md](crates/http/http-mcp.md) diff --git a/docs/architecture/questions/041-stream-operators-library.md b/docs/architecture/questions/041-stream-operators-library.md new file mode 100644 index 0000000..856632e --- /dev/null +++ b/docs/architecture/questions/041-stream-operators-library.md @@ -0,0 +1,42 @@ +# OQ-41: Stream Operators Library + +- **Origin**: [ADR-049](decisions/049-streaming-handler-for-subscriptions.md), + [operation-registry.md](crates/call/operation-registry.md) §"OperationEnv" +- **Status**: deferred(scope) +- **Door type**: Two-way (additive utility library; no protocol or API-surface + change) +- **Priority**: low +- **Blocked on**: A handler that needs stream operators and finds the existing + combinators (`Box::pin(stream::iter(...))`, `async_stream::stream!`, + `futures::stream`) insufficient. The operators library is a convenience, not + a prerequisite for any handler. +- **Resolution**: ADR-049 establishes that stream composition (filter, map, + combine, window, dedupe) is a **handler-level concern**, not a protocol + composition concern. `OperationEnv::invoke()` is request/response-only; + stream manipulation happens at the handler level with stream operators on + the `BoxStream` the handler itself produces. The + `@alkdev/pubsub` `operators.ts` is the prior art: 13 operators (`filter`, + `map`, `take`, `batch`, `dedupe`, `window`, `chain`, `join`, `reduce`, + `groupBy`, `flat`, `pipe`, `toArray`) that operate on `AsyncIterable`, + forked from graphql-yoga's subscription implementation. + + The Rust analogue — a stream-operators utility crate or module providing + the same set of operators on `BoxStream` / `impl Stream` — is + a feature extension. Handlers can + produce streams today without it (`Box::pin(stream::iter(...))`, + `async_stream::stream!`, `futures::stream` combinators all work); the + operators library is a convenience that reduces boilerplate for handlers + that transform streams (filter, batch, dedupe, window). No ADR is needed + for the library itself — it's internal utility code that doesn't cross + crate boundaries as a contract. An ADR would be warranted only if the + operators become part of a public API surface (e.g., a handler-registration + DSL that references operator names). + + This OQ exists so the operators library is tracked and findable, not left + as inline hedging in the spec docs. It is not a deferral of a decision — + the architectural decision (stream composition is handler-level, not + protocol-level) is made in ADR-049. This tracks the *implementation* of + the utility library, which is scheduling work, not architecture work. +- **Cross-references**: ADR-049, + [operation-registry.md](crates/call/operation-registry.md) §"OperationEnv", + `/workspace/@alkdev/pubsub/src/operators.ts` (TS prior art) diff --git a/docs/architecture/questions/042-dynamic-resource-ownership-for-runtime-spawned-resources.md b/docs/architecture/questions/042-dynamic-resource-ownership-for-runtime-spawned-resources.md new file mode 100644 index 0000000..e971846 --- /dev/null +++ b/docs/architecture/questions/042-dynamic-resource-ownership-for-runtime-spawned-resources.md @@ -0,0 +1,219 @@ +# OQ-42: Dynamic Resource Ownership for Runtime-Spawned Resources + +- **Origin**: [alknet-docker POC summary](../../research/alknet-docker/poc-summary.md) + §"Open Unknowns" #3 (container-as-resource identity model); generalized + during the Phase 1 review pass triggered by that research finding. +- **Status**: resolved — all five sub-questions are decided (storage + shape, integration point, proxy-vs-grant, and the four clarifications). + The ADR will write these into decision text; the dependent crate specs + can declare their `AccessControl` shapes against this model. +- **Door type**: One-way (the `AccessControl::check` signature change and + the `OperationSpec.resource_id_path` addition in core/call), two-way (the + ownership provider mechanism, per the established repo/adapter pattern) +- **Priority**: high — blocks the alknet-docker, alknet-tty, opencode-runner + wrapper, and `alknet-container` (fleet normalization) crate specs. None of + those specs can declare their `AccessControl` shapes until this is + resolved, because the model available to them determines what ACL + declarations are even expressible. Permitting "docker picks a per-crate + default and the others follow" is the door-type-as-deferral anti-pattern + (ADR-009 §"What this framework is NOT"): each crate bakes in an ACL shape + and downstream crates build on whatever default was picked, making the + "cheap reversal" expensive. + +- **Resolution.** + + **Decision 1 — storage side: reuse the repo/adapter pattern.** The + ownership store is a fourth instance of the established repo/adapter + pattern (ADR-033), alongside `IdentityProvider` (ADR-004), `IdentityStore` + (ADR-035), and `CredentialStore` (ADR-031). Concretely: a trait in + `alknet-core` (read method: "does identity X own resource R with action + A?" / "what resources of type T does X own?"; write method: "record X + spawned R", "revoke R on teardown"), with an in-memory default adapter in + core. The in-memory default carries the docker/runner cases with no + backend dependency — ownership is runtime state, meaningless across + restarts (a container ID from a previous process doesn't exist), so the + default case has no persistence requirement. A persistence adapter (e.g. + sqlite/honker-backed, for a hub that wants fleet ownership to survive + restarts) is separable and built when a concrete use case forces it, same + as `alknet-store-sqlite` for peer/credential persistence. The read stays + sync (called from `AccessControl::check` on the dispatch hot path, no + `.await`), with persistence adapters caching in memory and using honker + NOTIFY for invalidation — same `ArcSwap`-backed full-reload pattern as + `ConfigIdentityProvider` (ADR-035). No new shape invented on the storage + side; no Phase 0 needed for it. + + **Decision 2 — integration point: Option 2, `check` consults the + ownership provider directly.** `AccessControl::check` grows a parameter + for the ownership provider (or reads one carried on `OperationContext`), + and consults it for `resource_type`/`resource_action` checks against + runtime-spawned resources. The alternative considered and rejected — + Option 1, augmenting `Identity.resources` with a per-request snapshot + before calling `check` — preserves `check`'s purity by moving the + impurity one frame up the stack: the dispatcher would pull owned + resources into a per-request identity snapshot so `check` *looks* + unchanged while reading state that was never part of the static + identity. The purity was always theatrical (the question "can X exec + into container C" was never purely a function of identity; it just + looked that way because the resource set was static). Option 2 makes + `check`'s signature honest about what ACL checking *is* in the presence + of dynamic resources: a function of (ACL, Identity, current-ownership- + state). The impurity is real either way; Option 2 puts it in the + signature where it's visible, rather than hiding it in a per-request + snapshot pretending to be static identity. Option 3 (handler-level + ownership check, `AccessControl` gates only scope) was rejected because + it splits the ACL story — some resources statically checked, some + handler-checked — which is the kind of inconsistency that creates the + "figure out how it fits with what is there" cleanup this OQ exists to + prevent. + + The cost of Option 2 is a `check` signature change — a one-way door, + every call site and test updates. Per the project's decision principle + (implementation workload is a non-issue relative to semantic correctness + and long-term clarity; "path of least resistance compounded over many + decisions is strictly dominated"), this is implementation cost, not a + semantic cost, and does not bias the choice. + + **Refinement that makes Option 2 work cleanly: `OperationSpec` declares + where the resource ID lives in the input.** `OperationSpec` gains a + `resource_id_path: Option` — a JSON pointer into the operation + input, e.g. `"$.containerId"` for `docker/container/exec`. The dispatcher + extracts the resource ID from the input using the spec-declared path, + passes it to `check`, and `check` asks the provider "does this identity + own `/` with action ``?" — + a single targeted lookup, not a whole-resource-set pull. The fit with + JSON Schema is load-bearing, not incidental: `OperationSpec.input_schema` + is already a JSON Schema, so `resource_id_path` is a pointer *within* an + existing schema on the same spec. The `OperationSpec` becomes fully + self-describing for authorization — what resource type, what action, and + *which input field* drives the resource lookup. No per-namespace + conventions, no handler-level knowledge, no "the dispatcher just knows." + The contract is on the spec, where it belongs. + +- **Resolved specifics (the four questions the ADR must write into + decision text).** The decisions above settle the storage shape and the + integration point. The four specifics below settle how the model + behaves at the edges: + + 1. **No-specific-resource operations (the `list` case) — scope-gate + + result-filter, composing.** Operations with `resource_type` set but + `resource_id_path` absent — e.g. `docker/container/list`, which + doesn't reference a specific container. When a coordinator lists + containers it owns, it should see only its own — not every container + on the host. That's not just scope-gating ("can you call + `container/list` at all?") and not just result-filtering ("return + only owned") — it's both: scope-gate the call (does the peer have the + `container:list` scope), then filter the result to owned resources. + The default is "allow if scoped, filter to owned." `list` is the case + that forces this; `exec`/`inspect`/`stop` against a specific + container are the clean case (single targeted ownership lookup via + `resource_id_path`). The ADR states the default and how a spec + declares which it wants. + + 2. **Teardown coupling — automatic, handler-driven.** The ownership + store's write path (revoke on teardown) is coupled to the spawned + resource's lifecycle. The "burn it and start over" capability depends + on ownership state tracking the lifecycle correctly. When a container + dies or is destroyed, the ownership entry is revoked *by the handler + that managed the lifecycle* (the docker handler calls revoke on + container exit), not by an operator workflow or a background reaper. + The burn-and-start-over pattern is: destroy container → ownership + revoked automatically → spawn new container → new ownership recorded. + If teardown weren't automatic, stale ownership entries would + accumulate and the "burn" path would leave dangling ACL state. The + architectural commitment is: handler-driven revoke on lifecycle end, + not a reaper. The coupling mechanism (explicit handler call vs. + lifecycle-hook abstraction) is two-way-door implementation work. + + 3. **Fleet representation (spoke resources on the hub) — per-node + ownership, downstream app tracks "who is this for."** Under the + proxy pattern (Decision 3 below), the docker node records "coordinator + owns C" in its local ownership store. The coordinator's "I started C + for agent Y" mapping lives in the coordinator's own downstream-app + state, not in the core ownership store. The ownership store is + per-node (each docker node records its local ownership); the hub's + agent-to-workspace mapping is app state. There is no cross-node + ownership propagation in the base model — the spoke sees the hub as + the owner, and the hub's "who is this for" is its own concern. The + proxy pattern keeps ownership local, which is why this question is + less consequential than originally framed. + + 4. **Composition interaction — two separate checks, no change to + `CompositionAuthority`.** In the proxy pattern, the coordinator + composes `docker/container/exec` on behalf of an agent. Two checks + must pass: (a) the coordinator's `CompositionAuthority` has the + `container:exec` scope (static, ADR-015/022 unchanged), and (b) the + coordinator owns this specific container (dynamic, ownership store). + The composition authority stays static — it doesn't grow a dynamic + path. The ownership store handles the dynamic resource-level check. + Both must pass; they're orthogonal. **ADR-015/022 don't need + amendment** — the composition authority is unchanged, and the + ownership store is an additional check, not a modification to the + existing one. + +- **Decision 3 — access pattern: proxy-only as the base model.** The base + model is "spawner owns, proxy to share, teardown revokes" — with no + grant/transfer mechanism in the core ownership store. Two patterns for + how a downstream consumer reaches a runtime-spawned resource were + identified: + + - **Proxy pattern (the common case, and the only one the core model + supports).** A coordinator starts a container and manages its + lifecycle; the end user never talks to docker directly. The + coordinator re-exports the docker operations it wants to expose (via + `from_call` — the adapter that imports a peer's operations and + re-registers them locally, ADR-017 — or by composing them in its own + handlers), and when the end user invokes one, the coordinator is the + *direct caller* to the docker endpoint. Docker's ownership store sees the coordinator as the + owner and as the caller — the check passes. The end user's identity + rides as `forwarded_for` metadata (ADR-032), and the coordinator does + whatever end-user-level ACL it wants at its own layer. This is the + kernel/user-land + forwarded-for model: the hub's authority is used, + `forwarded_for` is metadata, the hub handles its own ACL. + + - **Grant pattern ("poking holes") — not in the core model.** A + downstream app wants to give an end user *direct* call-protocol + access to the docker endpoint for specific containers — the end user + calls `docker/container/exec` themselves, not through a proxy. Docker's + ownership store would need a record that the end user has access to + that container, even though the downstream app spawned it. No + described use case requires this. The agent-workspace case — the + concrete one — is entirely the proxy pattern: the coordinator starts + the workspace container; the agent interacts with what's *inside* the + container (a TTY, an opencode instance's API surface), not with + docker operations on the container. Docker-level operations (stop, + remove, inspect) are the coordinator's job. + + "Poking holes" is a downstream-app concern — the app that owns the + resources re-exports the operations it wants to share via `from_call` + with its own ACL layer, rather than the core ownership store growing a + grant API. The ADR commits to proxy-only and explicitly states that + "poking holes" is a downstream app's job. + + **A future grant mechanism is additive, not a one-way door closure.** + If a use case forces the grant pattern, it's a new method on the + ownership store trait (`grant(identity, resource)` / + `revoke_grant(...)`). `AccessControl::check` already consults the + ownership provider; a grant-aware provider would answer "yes" for + grantees in addition to owners, without a trait-shape change. The + two-way-door classification (additive) is stated here as reversal-cost + classification, not as a reason to defer the decision — the decision is + made (proxy-only), and the cost of reversing it if a future use case + forces it is low. If the grant pattern is later admitted, specifics 3 + and 4 above are revisited: cross-node ownership propagation returns to + the table (3), and composition under a grant would need + `CompositionAuthority` to grow a dynamic path, amending ADR-015/022 (4). + +- **Cross-references**: ADR-009 (door-type-as-deferral anti-pattern), + ADR-015, ADR-022 (the static `CompositionAuthority.resources` model this + extends — see open question 4), ADR-030, ADR-032 (`forwarded_for` + metadata — the proxy pattern's end-user-identity carrier), ADR-033 + (repo/adapter pattern — reused for the ownership store), ADR-035 + (`IdentityStore` — administrative peer mutations, a different concern + from runtime resource ownership, but the sync-read + ArcSwap + + honker-NOTIFY shape is reused), + [auth.md](crates/core/auth.md) (`Identity.resources`, + `AccessControl::check` interaction — both under edit by this decision), + [operation-registry.md](crates/call/operation-registry.md) + (`AccessControl`, `OperationSpec` — `resource_id_path` addition), + [alknet-docker POC summary](../../research/alknet-docker/poc-summary.md) + §"Open Unknowns" #3 diff --git a/docs/architecture/questions/043-ttycontrol-as-a-clone-trait-object.md b/docs/architecture/questions/043-ttycontrol-as-a-clone-trait-object.md new file mode 100644 index 0000000..303104d --- /dev/null +++ b/docs/architecture/questions/043-ttycontrol-as-a-clone-trait-object.md @@ -0,0 +1,27 @@ +# OQ-43: `TtyControl` as a `Clone` trait object + +- **Origin**: [crates/tty/tty-backend.md](crates/tty/tty-backend.md) + (the `TtyHandle.control` field shape); + `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-01 (the trait- + shape open question the local-PTY POC resolved). +- **Status**: resolved +- **Door type**: One-way (the `TtyControl` trait shape is part of the + `TtyBackend` API surface — ADR-053), two-way (the concrete `Clone` + newtype mechanism) +- **Priority**: medium +- **Resolution**: `TtyHandle.control` is + `Option>`. The `Clone` + trait-object bound is satisfied via an `Arc`-backed `Clone` newtype: a + small struct holding `Arc` where `TtyControlInner: + Send + Sync` has the `resize`/`signal` methods, and the public + `TtyControl` newtype implements `Clone` by cloning the `Arc`. The + local-PTY POC used a concrete `PtyControl` struct (inherently `Clone` — + it held `Arc>` fields); the trait-object form generalizes it + so a backend can produce its own control type without the adapter + knowing the concrete shape. The `Clone` constraint exists because the + adapter's control-chunk dispatcher needs to be handed off to the + spawned pump task (the POC's `session::drive_session` clones + `pty.control` for the client→backend pump). See ADR-053 and + `tty-backend.md`. +- **Cross-references**: ADR-053, [tty-backend.md](crates/tty/tty-backend.md), + `/workspace/alknet-tty-poc/src/local_pty.rs` (`PtyControl`) diff --git a/docs/architecture/questions/044-terminal-modes-tty-modes.md b/docs/architecture/questions/044-terminal-modes-tty-modes.md new file mode 100644 index 0000000..e6ba06c --- /dev/null +++ b/docs/architecture/questions/044-terminal-modes-tty-modes.md @@ -0,0 +1,22 @@ +# OQ-44: Terminal Modes (TTY modes) + +- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-02; + [crates/tty/tty-backend.md](crates/tty/tty-backend.md) + (`TerminalParams.modes` field). +- **Status**: deferred(scope) +- **Door type**: Two-way +- **Priority**: low +- **Blocked on**: a concrete mode-control use case (a deployment that + needs to set echo/raw/canonical/etc. modes on a PTY, beyond the backend's + defaults). +- **Resolution**: Not yet decidable. SSH's `pty_request` carries TTY + modes (echo, raw, canonical, etc.) as a packed bitmask. The common + case is "default terminal modes" — the `modes` field in + `TerminalParams` is `serde_json::Value` (reserved as `{}` in v1) for + when a concrete use case requires mode control. The backends + (`portable_pty`, docker `tty: true`, russh `pty_request`) all have + defaults that work for the common terminal case. Adding mode control + is additive (extend the `modes` JSON shape) and does not break + downstream; the decision is deferred until a use case forces it. +- **Cross-references**: ADR-053, [tty-backend.md](crates/tty/tty-backend.md), + [tty-wire.md](crates/tty/tty-wire.md) diff --git a/docs/architecture/questions/045-flow-control-for-high-throughput-stdout.md b/docs/architecture/questions/045-flow-control-for-high-throughput-stdout.md new file mode 100644 index 0000000..19c4225 --- /dev/null +++ b/docs/architecture/questions/045-flow-control-for-high-throughput-stdout.md @@ -0,0 +1,22 @@ +# OQ-45: Flow Control for High-Throughput stdout + +- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-03; + [crates/tty/tty-wire.md](crates/tty/tty-wire.md) (no windowing in the + chunk format). +- **Status**: open (low risk) +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: The chunk format has no windowing — QUIC's per-stream + flow control handles backpressure. This is expected to suffice for + high-throughput stdout (e.g., `cargo build` output): the docker POC's + logs subscription handled multi-line output without issue, and QUIC's + bidi-stream flow control is the established backpressure mechanism. A + POC with real high-volume output (a deliberately large `cargo build` + or `find /` over a high-bandwidth connection) would confirm. If a + flow-control problem surfaces, the reversal is a per-stream + flow-control window in the chunk format (a two-way-door extension to + the wire format, additive — a new control message type for window + updates, not a header change). Not blocking; the default assumption is + QUIC flow control suffices. +- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md), + [tty-adapter.md](crates/tty/tty-adapter.md) diff --git a/docs/architecture/questions/046-runner-api-surface.md b/docs/architecture/questions/046-runner-api-surface.md new file mode 100644 index 0000000..5d2358e --- /dev/null +++ b/docs/architecture/questions/046-runner-api-surface.md @@ -0,0 +1,22 @@ +# OQ-46: Runner API Surface + +- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-05; + [crates/tty/tty-local.md](crates/tty/tty-local.md) (the runner pattern + in pipe mode). +- **Status**: deferred(scope) +- **Door type**: Two-way +- **Priority**: low +- **Blocked on**: a concrete runner-policy use case that forces the API + surface (job management, log persistence, task graph integration). +- **Resolution**: Not yet decidable. The runner *mechanism* (pipe mode — + `TtyParams.terminal = None` → `std::process::Command` with piped stdio + → framed byte stream + exit code) is in alknet-tty (ADR-054). The + runner *policy* (job management, log persistence, task graph + integration) is a downstream crate's job, not in scope for alknet-tty. + This OQ tracks whether a runner-policy crate (e.g., an + `alknet-runner` crate that builds on the pipe mode + the wire format + to provide job management) is needed, and what its API surface would + be. The decision is deferred until a concrete use case forces it; the + mechanism is preserved regardless. See ADR-054 and `tty-local.md` + §"The Runner Pattern". +- **Cross-references**: ADR-054, [tty-local.md](crates/tty/tty-local.md) diff --git a/docs/architecture/questions/047-stdin-closure-canonical-signal.md b/docs/architecture/questions/047-stdin-closure-canonical-signal.md new file mode 100644 index 0000000..59a0c49 --- /dev/null +++ b/docs/architecture/questions/047-stdin-closure-canonical-signal.md @@ -0,0 +1,21 @@ +# OQ-47: Stdin Closure Canonical Signal + +- **Origin**: `docs/research/alknet-docker/poc-summary.md` §"Open + Unknowns" #4 (stdin closure semantics for raw carriage); + [crates/tty/tty-wire.md](crates/tty/tty-wire.md) §"Stdin Closure". +- **Status**: resolved +- **Door type**: Two-way +- **Priority**: low +- **Resolution**: Either a zero-length stdin chunk (stream_type 0, + length 0 — the docker POC's sentinel) or a `{"type":"eof"}` + control chunk (stream_type 3 — the tty POC's explicit signal) closes + the client's stdin. Both are accepted by the adapter; the spec + recommends `eof` for explicitness (it's a control message, not a + data-length hack). The adapter handles both identically: signal EOF + to the backend's stdin (`ChildStdin::drop` / PTY writer close) and + keep pumping stdout until the exit resolves — the client may still + want to receive remaining output + the exit code. A third path + (client closes the write half of the bidi stream) is also accepted + and handled the same way. See ADR-052 and `tty-wire.md`. +- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md), + [tty-adapter.md](crates/tty/tty-adapter.md) diff --git a/tasks/architecture/open-questions-decompose.md b/tasks/architecture/open-questions-decompose.md new file mode 100644 index 0000000..90be345 --- /dev/null +++ b/tasks/architecture/open-questions-decompose.md @@ -0,0 +1,54 @@ +--- +id: architecture/open-questions-decompose +name: Decompose open-questions.md into per-OQ files under questions/ +status: completed +depends_on: [] +scope: moderate +risk: low +impact: project +level: architecture +--- + +## Description + +The single `docs/architecture/open-questions.md` file had grown to 1310 lines +across 47 OQs — large enough to be unmanageable, with high size variance +(OQ-42 at 220 lines next to OQ-06 at 8 lines). Decomposed into one file per +OQ under `docs/architecture/questions/` (`NNN-slug.md`, mirroring the ADR +convention), with `open-questions.md` retained as the index (theme-grouped +tables + a cross-theme "Deferred / Blocked" section for safe-exit visibility). + +## What changed + +- Created `docs/architecture/questions/` with 47 files (`001-...md` through + `047-...md`), content moved verbatim from the old monolithic file (heading + rewritten from `### OQ-NN` to `# OQ-NN` for standalone files). +- Rewrote `docs/architecture/open-questions.md` as the index: + - Status-values / door-type definitions preserved + - 15 theme sections, each with a table: `| OQ | Title | Status | Door | Pri |` + - A "Deferred / Blocked" cross-theme section surfacing the 6 deferred OQs + with their `Blocked on:` conditions inline (the safe-exit visibility + surface) +- Updated `docs/architecture/README.md`: dropped the 65-line curated OQ summary + section (replaced by the index tables), kept the doc-table row pointing at + `open-questions.md`. +- Original monolithic file preserved at `/tmp/opencode/open-questions.old.md` + for verification. + +## Verification + +- `python3 /tmp/opencode/verify_oqs.py` confirms all 47 per-OQ files match the + original content byte-for-byte (modulo the `### OQ` → `# OQ` heading rewrite). +- All 62 inbound links to `open-questions.md` across ~30 files remain valid + (none used `#oq-NN` anchors — they all point at the bare filename, which is + unchanged). + +## Out of scope + +- Re-resolving or editing any OQ content — verbatim move only. +- Fixing OQ-09/OQ-10's missing `Blocked on:` field — tracked as + `architecture/oq-09-10-blocking-conditions`. +- Establishing the `tasks/architecture/` blocker-task convention — tracked as + `architecture/safe-exit-blocker-task-mechanism`. +- Touching reviews/research docs that cite `open-questions.md` by line number + (those citations are already stale; reviews are historical artifacts). \ No newline at end of file diff --git a/tasks/architecture/oq-09-10-blocking-conditions.md b/tasks/architecture/oq-09-10-blocking-conditions.md new file mode 100644 index 0000000..49faedd --- /dev/null +++ b/tasks/architecture/oq-09-10-blocking-conditions.md @@ -0,0 +1,46 @@ +--- +id: architecture/oq-09-10-blocking-conditions +name: Add explicit Blocked on conditions to OQ-09 (WASM) and OQ-10 (Git Adapter) +status: pending +depends_on: [] +scope: small +risk: low +impact: local +level: architecture +--- + +## Description + +During the open-questions.md decompose (July 2026), I found that OQ-09 (WASM +Target Boundaries) and OQ-10 (Git Adapter Scope) use the legacy `deferred` +status without a structured `Blocked on:` field. They predate the formalized +`deferred(scope)` + blocking-condition pattern established by ADR-009's Safe +Exit protocol. + +The four newer deferred OQs (OQ-32, OQ-41, OQ-44, OQ-46) all carry an explicit +`Blocked on:` condition. OQ-09 and OQ-10 do not — their deferral reason lives +in the Resolution prose, not in a structured field. + +This matters because the new `open-questions.md` index has a "Deferred / Blocked" +section that surfaces `Blocked on:` inline as the safe-exit visibility surface. +OQ-09 and OQ-10 currently render as "_(no explicit blocking condition recorded +— see full file)_" in that section, which weakens the visibility guarantee. + +## Work + +Either: +- Add a `Blocked on:` field to each (e.g., OQ-09: "blocked on: a concrete + server-side WASM use case — currently a design constraint, not a + deliverable"; OQ-10: "blocked on: speccing alknet-git — resolve when that + crate is specified, not deferred past it"), or +- Reframe them as `deferred(scope)` if the original `deferred` status was + imprecise, and confirm the blocking condition from the Resolution text. + +The decision content stays unchanged — this is a metadata-structure fix, not a +re-resolution. + +## Verification + +The "Deferred / Blocked" section of `docs/architecture/open-questions.md` should +show a concrete blocking condition for OQ-09 and OQ-10 instead of the "no +explicit blocking condition recorded" placeholder. \ No newline at end of file diff --git a/tasks/architecture/safe-exit-blocker-task-mechanism.md b/tasks/architecture/safe-exit-blocker-task-mechanism.md new file mode 100644 index 0000000..362176d --- /dev/null +++ b/tasks/architecture/safe-exit-blocker-task-mechanism.md @@ -0,0 +1,73 @@ +--- +id: architecture/safe-exit-blocker-task-mechanism +name: Establish the tasks/architecture/ blocker-task half of the Safe Exit protocol +status: pending +depends_on: [] +scope: moderate +risk: low +impact: project +level: architecture +--- + +## Description + +During the open-questions.md decompose (July 2026), I found that the role +spec's Safe Exit protocol says "create a blocker task in `tasks/architecture/` +that names the dependency" when deferring a decision — but that directory has +never existed. The four `deferred(scope)` OQs in the current file (OQ-32, +OQ-41, OQ-44, OQ-46) carry a `Blocked on:` field but no corresponding blocker +task files anywhere in the repo. + +So the deferral *field* exists (the visibility half), but the *task* half of the +safe-exit mechanism is unenforced/unused. This task is about establishing the +convention so future deferrals create the corresponding blocker task, and +backfilling the existing deferred OQs. + +## Context + +The project's broader pattern (per the July 2026 discussion): questions often +lead to decisions which lead to tasks — a graph not unlike how implementation +tasks are modeled as a DAG. The `tasks/architecture/` directory is where the +"this decision is blocked on X" blocker tasks live, so that: + +1. A deferred OQ has a visible `Blocked on:` condition in the index (the + open-questions.md "Deferred / Blocked" section — already in place after the + decompose). +2. A deferred OQ has a corresponding blocker task that names the dependency and + can itself be depended on by downstream work that needs the decision + resolved. + +The two halves serve different audiences: the index section is for the +architect ("what's currently parked and why"), the blocker task is for the +implementation agent / planner ("what unblocks this, and what's waiting on +it"). + +## Work + +1. Confirm the task file format (YAML frontmatter: `id`, `name`, `status`, + `depends_on`, `scope`, `risk`, `impact`, `level`) is the right shape for + architecture blocker tasks, or adjust if the architecture workflow needs + different fields (e.g., a `blocks:` field pointing back at the OQ). +2. Backfill blocker tasks for the four existing `deferred(scope)` OQs: + - OQ-32 (Multi-Hop Federation) — blocked on a concrete multi-hop use case + - OQ-41 (Stream Operators Library) — blocked on a handler needing operators + beyond existing combinators + - OQ-44 (Terminal Modes) — blocked on a concrete mode-control use case + - OQ-46 (Runner API Surface) — blocked on a concrete runner-policy use case +3. Document the convention (one line in `docs/sdd_process.md` or the architect + role spec) so future deferrals create the blocker task as part of the Safe + Exit step. + +## Out of scope + +- The DB-backed backend (no manual links, vector/text search) — that's a future + evolution that this file-based pattern informs, not something to build now. +- Re-resolving any of the deferred OQs — this is about the tracking mechanism, + not the decisions themselves. + +## Verification + +- `tasks/architecture/` contains one blocker task per `deferred(scope)` OQ +- Each blocker task's `depends_on` names the concrete unblocking condition (or + a task that represents it) +- `docs/sdd_process.md` (or equivalent) references the convention \ No newline at end of file