Replace placeholder negotiation types with the full Phase 1 wire shape:
- NegotiateRequest/TerminalParamsWire with serde(flatten) capturing
backend-specific fields into backend_params
- NegotiationReader/Writer: self-contained 4-byte BE length-prefixed
framing on tokio AsyncRead/AsyncWrite, bounds-checked against MAX_CHUNK_LEN
- NegotiationError (Io, ConnectionClosed, FrameTooLarge, Json) via thiserror
- error_response_bytes helper for server-side JSON error frames
- into_inner reclaims the stream for raw-chunk use
- Error frames stay under 16 MiB so the high byte of the length prefix is
0x00, making the framing-disambiguation trick sound (ADR-052 §5)
Unit tests cover round-trip, serde(flatten) capture, FrameTooLarge,
ConnectionClosed on truncated header/body, error-response shape, and the
0x00-first-byte framing-disambiguation invariant. backend.rs's
From<NegotiateRequest> for TtyParams still compiles (same field shapes).
backend.rs defines its own BoxFuture type alias
(Pin<Box<dyn Future + Send>>) using std::future::Future + futures_core,
so the futures crate is unused. Removes the dead dependency.
Port the POC's control channel schema (alknet-tty-poc/src/control.rs)
into crates/alknet-tty/src/control.rs: the ControlMessage tagged enum
(Resize/Signal/Eof/Exit) with snake_case type tag, to_json/from_slice
helpers, and the Unix-only signal_from_name mapping the 9 supported
signal names to libc numbers. Unknown type tags return a serde_json
error; the adapter (later task) ignores that error per the wire spec's
extensibility policy. Adds libc as a target.'cfg(unix)' dependency.
Refs: docs/architecture/crates/tty/tty-wire.md §"Control Channel"
Refs: docs/architecture/decisions/055-exit-code-on-control-chunk.md
Create crates/alknet-tty with Cargo.toml (alknet-core path dep, tokio,
bytes, futures-core, tokio-stream, serde, serde_json, async-trait,
tracing, thiserror — no portable_pty/bollard/russh/alknet-call per
ADR-057), the `local` feature gate (dep wiring deferred to
tty/local-feature-reexport), src/lib.rs with the crate doc comment and
module declarations, and skeleton modules (wire, control, negotiation,
backend, adapter) with doc comments and TODO markers. Add the crate to
the workspace members list.
cargo check, clippy -D warnings, and fmt --check all pass.
Implementation verified yaml_serde 0.10.4 (and serde_yaml 0.9) implement the
YAML 1.2 core schema, where bare yes/no/on/off are strings, not booleans. The
original §2 rationale cited YAML 1.1 boolean-coercion as a present hazard
justifying JSON-first detection; it does not exist with this dependency
version.
The JSON-first decision is retained (Accepted ADR) — reframed as a defensive
default that locks the contract against a future YAML-parser swap, not a
guard against a present hazard. §2 heading, Consequences, and Assumption #2
amended; an in-place amendment note records what changed and when. The two
coercion-related tests already document yaml_serde 0.10.x's actual behavior
with explicit comments. Source doc-comments on from_yaml/from_str corrected
to stop propagating the flawed assumption. 256 tests pass; clippy clean.
Adds OpenAPISpec::from_yaml and OpenAPISpec::from_str alongside the
existing from_json. from_str is JSON-first, YAML-fallback (ADR-051 §2):
a valid JSON doc parses through serde_json (stricter grammar, immune to
YAML-specific coercion); only on JSON failure does it fall back to the
YAML parser. yaml_serde 0.10 (resolves to 0.10.4, latest stable) is the
maintained official-YAML-org fork of the deprecated serde_yaml; not
feature-gated per ADR-051 §3 (YAML OpenAPI is first-class).
Note: ADR-051 §2's stated YAML 1.1 boolean-coercion rationale does not
hold for yaml_serde 0.10 (YAML 1.2 core schema — yes/no/on/off are
strings). The JSON-first rule is retained per the Accepted ADR as a
defensive default; tests document the actual 0.10.x behavior with
explicit comments. The ADR rationale should be amended separately.
256 tests pass; clippy + fmt clean.
Implements the 4-task DAG for runtime-spawned resource ownership so
AccessControl::check can answer "does this identity own this specific
container/TTY/process" instead of relying only on static
Identity.resources grants.
1. core/ownership-store-trait: OwnershipProvider (sync read) +
OwnershipStore (async write) traits + InMemoryOwnershipStore +
OwnershipError in alknet-core. Fourth instance of the repo/adapter
pattern (ADR-033), mirroring CredentialStore/store.rs.
2. call/registry/operation-spec-resource-id-path: resource_id_path
field on OperationSpec — JSON pointer into input for resource ID
extraction. Single field addition, all ~40 construction sites across
alknet-call + alknet-http updated to pass None (no semantic change).
3. call/registry/access-control-ownership-check: check() signature
gains resource_id + Option<&dyn OwnershipProvider>. 3-case decision
tree: ownership Some + resource_id Some -> owns(); ownership Some +
resource_id None -> owns_any() (list scope-gate); ownership None ->
static Identity.resources fallback (backward compat). 7 call sites
updated to (None, None) — including a 7th in alknet-http/gateway_routes
not listed in the original spec.
4. call/registry/dispatch-resource-id-extraction: wire the dispatch
path. OperationContext gains ownership field; Dispatcher/CallAdapter
gain with_ownership_provider builders; invoke/invoke_streaming/
invoke_with_policy extract resource_id via spec.resource_id_path
and thread context.ownership to check(). extract_json_pointer helper
handles $.field syntax (graceful None on missing/non-string). 20
OperationContext literals updated across both crates.
Backward compatibility is load-bearing throughout: ownership=None
falls back to the existing static resource-check path. Deployments
without runtime-spawned resources wire nothing and behave identically.
821 tests pass workspace-wide (was ~770); clippy clean; fmt clean.
Task specs marked done.
Replace the one-event placeholder (subscribe_stream_from_envelope +
envelope_to_sse_stream, which called invoke() and wrapped the single
ResponseEnvelope) with the real streaming path: subscribe_handler now
calls GatewayDispatch::invoke_streaming() and pipes the
BoxStream<ResponseEnvelope> to SSE via subscribe_stream_from_envelope_stream
(futures::StreamExt::map). Each Ok(output) becomes a data: frame; each
Err becomes an event:error frame (terminal — stream ends after it);
natural stream end closes the SSE. Internal ops still return a single
NOT_FOUND error event via subscribe_stream_internal_error (kept). Client
disconnect drops the stream via Rust's Drop (abort cascade per ADR-016).
Add DispatchResult::Once|Stream enum and Dispatcher::dispatch() that branches
on the registered op_type (ADR-049 §6): Query/Mutation → invoke() (unchanged
Once path), Subscription → invoke_streaming() (Stream path). handle_stream
matches on DispatchResult: the Once path writes one call.responded/call.error
frame (no call.completed); the Stream path pumps each ResponseEnvelope to the
wire via pump_stream (ResponseEnvelope.into() → call.responded for Ok,
call.error for Err), then writes call.completed on natural stream end. An Err
envelope is terminal — last_was_error suppresses call.completed after an error.
The streaming branch clears context.deadline to None (subscriptions are
unbounded — ADR-049 §6, call-protocol Timeouts). Abort (ADR-016) needs no new
code: handle_abort removes the pending entry and dropping the pump task
releases handler resources via Drop. dispatch_requested delegates to dispatch
for backward compatibility with existing callers.
Add the streaming analogue of invoke() returning BoxStream<ResponseEnvelope>.
Security invariants are identical to invoke() (internal: false,
forwarded_for: None, same capabilities/scoped_env/ACL) — shared via a
build_root_context_inner helper with a bounded flag. The streaming path
sets deadline: None (unbounded subscriptions, ADR-049 §6). Calls
OperationRegistry::invoke_streaming() (already on develop). to_mcp is
unchanged (MCP excludes Subscription, ADR-041).
Tests cover: subscription dispatch, leading-slash strip, unknown op
NOT_FOUND, internal op NOT_FOUND (not leaked), None identity FORBIDDEN,
Query op INVALID_OPERATION_TYPE, invoke() on Subscription returns
INVALID_OPERATION_TYPE (guard holds through gateway), and
build_root_context_streaming sets deadline: None while carrying the
registration bundle.
build_registration now branches on op_type: Subscription ops register a
StreamingHandler (HandlerKind::Stream) via make_streaming_handler that
streams SSE response chunks as ResponseEnvelope::ok() items (one per
data: frame); Query/Mutation ops keep the existing Handler
(HandlerKind::Once) via forward(). Closes the gap where a from_openapi-
imported Subscription returned only the last SSE event.
- forward_stream(): non-async fn returning ResponseStream; sends the
request with Accept: text/event-stream, then streams SSE chunks via
stream::unfold over response.bytes_stream(), reusing parse_sse_frames
(multi-event, partial trailing, comments, multi-line data, BOM).
HTTP error (non-2xx) → single ResponseEnvelope::error(), stream ends;
SSE stream end → ResponseStream ends (→ call.completed on wire).
- Removed stream_subscription() (the collect-all placeholder that
truncated to the last event). parse_sse_frames stays (reused).
- Query/Mutation forwarding unchanged (existing forward() path).
- Tests: Subscription registration is HandlerKind::Stream; Query
registration is HandlerKind::Once; SSE subscription streams multiple
ResponseEnvelope::ok() (one per data: frame); HTTP error → single
error envelope; Query forwarding unchanged (single response).
Subscription ops discovered via services/list + services/schema now
register a StreamingHandler (HandlerKind::Stream) that calls
CallConnection::subscribe_with_payload and forwards the remote stream
end-to-end (ADR-049 §8). Query/Mutation ops keep the existing
make_forwarding_handler (HandlerKind::Once).
- Add CallConnection::subscribe_with_payload(payload) mirroring
call_with_payload so the forwarding handler can populate forwarded_for
(ADR-032) + auth_token on the subscription payload. subscribe() now
delegates to subscribe_with_payload.
- Add make_streaming_forwarding_handler() in from_call.rs using
make_streaming_handler + futures::stream::once(...).flatten() to await
subscribe_with_payload then forward its stream.
- Branch build_bundles on spec.op_type (already parsed by rebuild_spec_for).
- Reuse build_forwarded_payload — no new payload-construction code.
- composition_authority: None, scoped_env: None for FromCall streaming
leaves (same as Query/Mutation FromCall leaves).
- Abort cascade (ADR-016 §6) already wired via PendingRequestMap in
subscribe_with_payload.
Closes the gap where a from_call-imported Subscription truncated to the
first value.
Streaming dispatch path for Subscription operations — counterpart to
invoke(). Same visibility + ACL checks (internal → handler_identity,
external → identity), then dispatches to the StreamingHandler. Pre-handler
errors (not-found, forbidden, INVALID_OPERATION_TYPE for non-Subscription
ops) yield a single error ResponseEnvelope via stream::once and end the
stream. Adds 6 unit tests covering dispatch, not-found, wrong-kind,
internal-from-external, ACL denied, and internal-call handler_identity ACL.
Refs ADR-049 §3, §5.
Add the foundational types for ADR-049 streaming handlers:
- StreamingHandler, ResponseStream type aliases and HandlerKind enum
(Once | Stream) in registration.rs, with make_streaming_handler() helper
- CallError::invalid_operation_type() in wire.rs (sixth protocol code,
retryable: false)
- HandlerRegistration.handler flipped from Handler to HandlerKind;
HandlerRegistration::new() now takes HandlerKind
- OperationRegistryBuilder absorbs wrapping: with_local/with_leaf/
with_leaf_provenance wrap raw Handler in HandlerKind::Once for
Query/Mutation; new with_local_streaming/with_leaf_streaming take a
StreamingHandler and wrap in HandlerKind::Stream for Subscription.
Builder validates kind matches spec.op_type (mismatch = startup error)
- OperationRegistry::register() returns Result<(), String> with a clear
mismatch message; all call sites updated to handle the Result
- invoke() matches on HandlerKind: Once -> existing path; Stream ->
INVALID_OPERATION_TYPE error envelope (guards against silent
truncation; invoke_streaming() added in a downstream task)
- OverlayOperationEnv::invoke_with_policy matches on HandlerKind:
Once -> dispatch; Stream -> INVALID_OPERATION_TYPE (composition is
request/response-only)
- Migrated every HandlerRegistration::new() construction site (~95)
to wrap raw Handler in HandlerKind::Once(handler); the builder sites
are handled by the builder-absorbs-wrapping change
- Updated two websocket subscription tests that relied on Subscription
ops dispatching via invoke() to expect INVALID_OPERATION_TYPE
- Added unit tests for invoke/register validation and
make_streaming_handler
Refine to_openapi to project operation-level errors (with http_status)
onto /call and /subscribe responses via oneOf merge with protocol-level
errors, preserving HTTP_<status> prefix codes without collision. Fix
BTreeMap→serde_json::Map for Value::Object compatibility. Wire GET
/openapi.json route test. Apply cargo fmt across the crate.
Adds AccessControl::check to OverlayOperationEnv::invoke_with_policy in alknet-call
so hub's calls to browser-registered ops are gated by the browser's AccessControl.
Creates src/websocket/overlay.rs with 19 integration tests: overlay scoping (not
PeerCompositeEnv), no PeerId, register_imported/all, overlay_env() routing,
PeerRef::Specific→NOT_FOUND, AccessControl gating, overlay drop on WS close,
ADR-016 abort cascade, bidirectionality, no-ops use-case scoping.
Enforce AccessControl on overlay ops in OverlayOperationEnv::invoke_with_policy
(alknet-call) so the hub's calls to browser-registered ops are gated by the
browser's AccessControl — matching OperationRegistry::invoke semantics for
internal composition (caller identity = parent handler_identity.as_identity()).
Add src/websocket/overlay.rs with 19 integration tests covering the connection-
local overlay acceptance criteria: browser ops land in the per-CallConnection
overlay (not PeerCompositeEnv), no PeerId for the browser, register_imported()/
register_imported_all() populate the overlay, hub outgoing calls route through
overlay_env() (not PeerRef::Specific), PeerRef::Specific('browser-X') routes to
NOT_FOUND, AccessControl gates hub calls (allowed/forbidden/default), overlay is
per-connection isolated and dropped on WS close, WS close aborts in-flight calls
with ADR-016 cascade, bidirectionality, and browser-with-no-ops use-case scoping.
Implements src/server/gateway_routes.rs: POST /call, GET /search, GET /schema,
POST /batch, POST /subscribe (SSE). All delegate to GatewayDispatch::invoke; auth
via ResolvedIdentity extractor; errors mapped via call_error_to_http_response
(identity-aware 401/403 split). Internal ops → 404. /schema adds ACL pre-check.
/subscribe projects ResponseEnvelope as SSE. /batch loops over invoke. Wired real
handlers into adapter.rs replacing placeholder 501s. 157 tests pass.
Note: /subscribe SSE completes after single event (registry invoke returns single
ResponseEnvelope, no streaming handler yet — research §6 OQ#5).
# Conflicts:
# crates/alknet-http/src/server/adapter.rs
Implement the sole HTTP invoke path (ADR-042/047) in
src/server/gateway_routes.rs: POST /call reads { operation, input },
resolves identity via the shared ResolvedIdentity extractor, dispatches
via GatewayDispatch::invoke, and returns ResponseEnvelope as JSON with
errors mapped via call_error_to_http_response. GET /search dispatches
services/list (AccessControl-filtered); GET /schema dispatches
services/schema with an ACL pre-check (unauthorized -> 403). POST /batch
loops over invoke returning an array; POST /subscribe projects the
response as SSE (text/event-stream) with data frames for call.responded
and an error event for call.aborted. Internal ops return 404. Wire the
real handlers into adapter.rs router, replacing the placeholder 501s.
Add src/server/auth.rs with bearer_auth_middleware axum layer that
extracts the Authorization: Bearer header, resolves via
IdentityProvider::resolve_from_token, and stashes Option<Identity> in
request extensions. Shared by HTTP gateway routes and the to_mcp rmcp
service (research §4.4). No token, malformed header, or failed
resolution all yield None (unauthenticated, not an error) — Bearer-only
auth mechanism (ADR-004).
Includes ResolvedIdentity axum extractor reading from extensions, and
wires the middleware into the HttpAdapter router around the
gateway/openapi/mcp routes (excluding the raw /healthz route).
GET /healthz: raw route, no auth, no OperationContext, returns 200 OK
with plain-text 'ok' (ADR-036). Decoy fallback for unknown paths via
DecoyConfig: fake nginx 404 (default), static site serving, or redirect.
Decoy does not leak alknet presence (no alknet headers/format). Custom
routes take precedence over decoy (decoy is fallback only). Wire real
handlers into HttpAdapter router replacing placeholder 501s.
FromMCP (OperationAdapter, feature-gated behind mcp) discovers remote MCP
tools over streamable HTTP via rmcp's StreamableHttpClientTransport, calls
tools/list, and registers each as a HandlerRegistration bundle with a
forwarding handler that calls the remote tool via tools/call. Output
handling follows the structuredContent-preferred-over-content-blocks rule:
declared outputSchema + structuredContent is the composable result; absent
outputSchema falls back to the MCP ContentBlock union. isError:true maps to
a CallError with the error content. No-env-vars invariant: the handler reads
context.capabilities (injected at registration), never std::env::var (ADR-014).
Streamable HTTP only — stdio is not built (ADR-037). Provenance is FromMCP
(leaf: composition_authority None, scoped_env None, Internal by default,
ADR-015/022). Includes unit tests for schema/mapping logic and an integration
test that spins up a real rmcp streamable HTTP server and exercises the
forwarding handler end-to-end.
Parses OpenAPI 3.x documents into HandlerRegistration bundles with
reqwest-backed forwarding handlers that inject credentials from
OperationContext.capabilities (no-env-vars invariant, ADR-014).
Error codes are prefixed HTTP_<status> (ADR-023); ops are Internal
leaves with FromOpenAPI provenance (ADR-015/022). SSE subscriptions
are consumed via parseSSEFrames; JSON/text/binary response branching
mirrors the TS prior art.
Wires the axum Router (gateway endpoints + /healthz + /openapi.json + MCP +
custom routes via extra_routes merge ADR-046) and drives hyper's HTTP/1.1 or
HTTP/2 connection driver over a single QUIC bidirectional stream. The
QUIC-to-hyper bridge wraps the (SendStream, RecvStream) pair as a
TokioIo-compatible duplex and feeds it to hyper-util's auto Builder (which
auto-detects HTTP/1.1 vs HTTP/2). h3 ALPN is not registered (ADR-044).
Route handlers, healthz/decoy logic, openapi.json, the MCP route, and the WS
upgrade handler are wired as 501 Not Implemented placeholders for their
respective tasks. The router state holds Arc<OperationRegistry> +
Arc<dyn IdentityProvider>; the router is built once at construction and
cloned per connection (cheap Arc clone). DecoyConfig defaults to NotFound.
Adds hyper-util dependency (server, service, tokio features).
Implements SharedHttpClient (ArcSwap<ClientWithMiddleware>) with HttpClientConfig
(pool/timeout/retry/optional CA bundle+client cert), RetryTransientMiddleware from
reqwest-retry, and inlined RetryAfterMiddleware (~90 lines, bounded HashMap with LRU
eviction, parses Retry-After seconds + HTTP-date, sleeps on 429/503). reload() via
ArcSwap. No env-var reads; per-request credential injection only. 24 unit tests.
Adds SharedHttpClient wrapping ArcSwap<ClientWithMiddleware> with a
RetryTransientMiddleware + inlined RetryAfterMiddleware stack.
HttpClientConfig covers pool, timeout, retry policy, and optional CA
bundle/client cert. reload() rebuilds and swaps via ArcSwap. No env-var
reads; credential injection is per-request, not at construction.
Make Dispatcher::dispatch_requested pub and extract abort-cascade handling
into a pub handle_abort method so the WebSocket handler can feed deserialized
EventEnvelopes directly to the shared Dispatcher without a QUIC Connection.
CallConnection gains a new_overlay_only(identity) constructor (Option A) that
holds the Layer 2 overlay, PendingRequestMap, and resolved bearer Identity
without a QUIC Connection; identity() reads the stored field for the non-QUIC
case. compose_root_env uses the new identity() accessor for both paths.
The existing QUIC path (CallAdapter, CallClient, run_loop, handle_stream) is
unchanged — outgoing client methods guard on connection().is_none().
Implements call_error_to_http_status, call_error_to_http_status_with_identity,
and call_error_to_http_response in src/gateway/error.rs. Five protocol codes
map to fixed statuses (404/422/504/500 + 401/403 split for FORBIDDEN).
HTTP_<status>-prefixed operation-level codes parse status from prefix. Unknown
operation-level codes default to 500. Retry-After header for retryable 503/429.
21 unit tests.
# Conflicts:
# crates/alknet-http/src/gateway/mod.rs
Add gateway/error.rs with call_error_to_http_status,
call_error_to_http_status_with_identity, and call_error_to_http_response.
Maps the five protocol codes (NOT_FOUND/FORBIDDEN/INVALID_INPUT/TIMEOUT/
INTERNAL) to fixed HTTP statuses, splits FORBIDDEN into 401 (no identity) /
403 (identity present), maps HTTP_<status>-prefixed operation-level codes
to the status number (from_openapi fidelity), and defaults unknown
operation-level codes to 500. Retryable 503/429 errors carry a Retry-After
header when details.retry_after is present.
Thin concrete struct (not a trait) holding Arc<OperationRegistry> +
Arc<dyn IdentityProvider>. Exposes resolve_bearer() (delegates to
identity_provider.resolve_from_token) and invoke() which builds a root
OperationContext for wire-ingress (internal: false, forwarded_for: None,
fresh UUID v4 request_id, deadline now+30s) carrying the registration
bundle's composition_authority/capabilities/scoped_env, then calls
OperationRegistry::invoke. Dispatches services/list and services/schema
unchanged (registered ops); AccessControl filtering in services/list
sees the caller's resolved identity. Re-exported from lib.rs.
Duplicates Dispatcher::build_root_context construction (the alknet-call
version is pub(crate) and tangled with CallConnection peer/session
overlays); the invariants (internal: false, forwarded_for: None) are
the load-bearing part and identical to the wire-ingress path.