Skip to content

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 02 Jul 18:16
v0.9.0
1f8133d

Breaking

  • astrid-uplink's kernel-request client API is now typed instead of anyhow. KernelClient::request and BusKernelClient::request return Result<KernelResponse, KernelClientError> instead of anyhow::Result; the public is_timeout() / RequestTimeout marker items are removed, and KernelClientError / TimeoutKind are added. Semver-major for this internal crate, but there are no external consumers and every in-workspace call site is updated (the gateway matches KernelClientError::Timeout directly for the new 504). This underpins the gateway↔kernel timeout change described under Changed (#1092).
  • Capsule loading is now principal-view aware while preserving content-addressed artifact reuse. The runtime registry keys loaded instances by (principal, wasm_hash) and records per-principal views as principal -> capsule id -> hash, so two principals that install the same capsule bytes reuse the same on-disk artifact while keeping separate in-memory runtime instances, capsule sets, and versions. Dispatcher selection now fail-closes user-facing execute/describe surfaces to the caller's view, grant-on-use only fires for capsules already in that view, and kernel install/reload/unload target the authenticated caller's view rather than mutating default. Per-invocation CPU/memory attribution remains keyed to the caller principal through the existing fuel/memory ledgers. Closes #1069.
  • The HTTP gateway's admin routes now enforce per-device capability scope (#999), closing a gap where a device-scoped bearer reached the kernel cap-gate as unattenuated full-principal authority. The KernelClient transport (astrid-uplink) behind GET /api/sys/status, the new GET /api/sys/readiness, POST /api/sys/capsules/reload, and the /api/capsules list/get/install routes stamped only the caller's principal on outbound requests, never the authenticating device's key_id — so the kernel's authorize_request saw device_key_id = None and skipped the DeviceScope attenuation floor. A use-only device whose scope denies capsule:list / capsule:reload / capsule:install but whose principal holds it could therefore drive those routes (e.g. read the full loaded-capsule inventory via /api/sys/readiness, or trigger a reload). KernelClient now carries an optional device_key_id (with_device_key_id) and stamps it on every request via a unit-tested message builder; all six gateway call sites thread CallerContext.device_key_id, mirroring the bus-direct BusAdminClient path that already did so. Full-authority bearers stamp None (unattenuated — single-tenant behaviour unchanged). No WIT / host-ABI change — uplink transport + gateway routes only. Found by the branch's own adversarial security review. Refs #999.
  • Capsule access is now per-principal, enforced kernel-side at dispatch — closing the gap where the capsule tool surface was global and any principal could invoke any installed capsule's tools. Previously find_matching_interceptors matched the entire global registry on topic alone, so a non-admin principal (a restricted/claude-group identity or a delegated sub-agent) could invoke security/identity-sensitive capsule tools (users.set_public_key, identity.save_identity, registry.set_active_model) that only the operator should reach — per-principal isolation existed only at the data/resource layer (home/KV/secrets/ledgers), never at the tool surface. Access is now a per-principal grant set: a new capsules: Vec<String> field on PrincipalProfile (#[serde(default)], empty by default, validated like a CapsuleId) names the capsule ids a principal may invoke, and the dispatcher filters topic-matched capsules to that set for the user-invocable surface onlytool.v1.execute.* and cli.v1.command.execute. Every other topic dispatches unchanged: the internal orchestration mesh (session.*, spark.*, registry.*, prompt_builder.*, context_engine.*, lifecycle/hooks, llm streams) is never gated, so the runtime does not wedge and a dual-role capsule (e.g. identity handling both tool.v1.execute.save_identity and spark.v1.request.build) has its tool gated while its orchestration role stays open — the gate is keyed on the topic, not the capsule. An admin holding * bypasses the filter entirely (resolved via the same CapabilityCheck machinery authorize_request uses), so single-tenant default and admins are unaffected with zero migration. The resolver reuses the kernel-owned, in-memory profile_cache (an RwLock-read on the hot path, no per-event write — the lock-free discipline the fuel/memory ledgers follow) and the live group config, cloned into the dispatcher exactly as the ledgers are. Fail-closed: an unknown / None / anonymous caller, or any profile-resolution error, yields an empty grant set → no tool capsules visible; new principals inherit no capsule access. The principal used for the check is the kernel-stamped value on the IPC message, never a caller-supplied claim, so it cannot be forged. Operators grant/revoke via astrid agent modify --add-capsule <id> / --remove-capsule <id> (and the HTTP management API's add_capsules/remove_capsules), mirroring the existing --add-group/--remove-group mechanism exactly (same agent:modify gating, same audit path, idempotent). No WIT / host-ABI change — kernel-internal dispatch + profile schema + admin contract only. Refs #992.
  • astrid-audit::AuditAction gains NetConnect / NetBind / ProcessSpawn variants and is now #[non_exhaustive], and the astrid-capsule context structs CapsuleContext, HostState, and LifecycleConfig gain a public audit_sink field. These land the per-action host-call audit coverage described under Security (#994). The changes are additive at the source level but semver-major for these internal crate APIs: downstream code matching AuditAction exhaustively must now carry a wildcard arm (the #[non_exhaustive] marker makes every future variant addition non-breaking), and any struct-literal construction of CapsuleContext / HostState / LifecycleConfig must include the new audit_sink: Option<Arc<dyn HostAuditSink>> field. There is no backwards-compatible form of these edits — adding a variant to an exhaustive enum and a field to an all-public struct are breaking by Rust's semver rules, and nothing is being superseded, so there is nothing to deprecate in their place. These are internal, unpublished workspace crates with no external consumers and every in-tree call site updated. Refs #994.

Fixed

  • astrid init now honours the global --principal flag, provisioning the named principal's home instead of always default. astrid init --principal <x> (or ASTRID_PRINCIPAL / the active-agent context) previously wrote the distro's per-capsule env files and the Distro.lock under default even though the capsule files themselves already landed under the resolved principal — so a scoped principal was left with an empty capsule view, an empty-hash lock, and a re-provision on every run (its lock lived under the wrong home and never satisfied the principal-aware auto-init freshness check). Init now threads the resolved principal — the same identity astrid capsule install uses — through the lock path, env files, per-provider onboarding, and every post-install read-back, both on the online path and the offline .shuttle path. Provisioning is also honest: a run where every capsule failed no longer prints "Installation complete" or writes a Distro.lock (a stale lock would wedge re-runs) and exits non-zero; a partial run writes NO lock and reports the true installed/total count so a re-run retries the missing capsules instead of short-circuiting at the version-only freshness gate; the shell-PATH edit is idempotent via a whole-path-component match (never a bare substring, so it neither appends a duplicate block nor silently skips setup when an unrelated …/bin_backup path is present); and GitHub release/source resolution now authenticates with GH_TOKEN / GITHUB_TOKEN when present — lifting the anonymous 60/hr API ceiling that a full distro can exhaust mid-provision — while degrading gracefully to anonymous when no token is set. No WIT / host-ABI change — CLI provisioning only. Closes #1095.
  • Restored shared-by-hash single-instance capsule loading, correcting the per-principal-instance regression that #1083 introduced against #1069's own spec. #1083 keyed loaded runtime instances by (principal, wasm_hash) and built a separate Arc<dyn Capsule> runtime (and WASM build) per principal, so a capsule referenced by N principals loaded N times — memory scaled principals × capsules. The registry now keys instances by content hash alone: a hash referenced by N principals loads exactly once (one runtime, one WASM build), with the per-principal name -> hash views layered on top. A per-hash refcount tracks how many principal views reference the shared runtime; it is cancelled/unloaded only when the last view releases it (unregister_for now returns whether the instance was torn down, so kernel unload/restart never cancels a runtime other principals still use). The kernel load path dedups by hash: before building, a loaded hash short-circuits to a view-add via register_existing/contains_hash (no second runtime). Kept from #1083: per-principal dispatch/visibility views (cloned_values_for scoping, admin bypass, fail-closed view-scoped describe fan-out), per-principal on-disk install sets, and per-invocation env/KV/secret/home overlays. Cancellation granularity now matches the shared-instance model too: each invocation waits on a per-principal child token of the instance token (installed with the other per-invocation overlays), and a non-last view release calls a new request_cancel_for(principal) that cancels exactly the departing principal's in-flight blocking host calls (approval/elicit waits, net/io/ipc waits) — so a capsule remove with a pending approval can no longer wedge the shared runtime for every other principal, while a full unload still cancels everything via the instance token cascade. Closes #1069; corrects #1083.
  • Closed the cross-principal host-state fallback that a shared runtime would otherwise open. A shared instance is loaded under no real principal's identity, so its load-time host state is a NEUTRAL, fail-closed placeholder: the kv is a physically-isolated in-memory store and the secret_store is deny-all — never default's (or anyone's) real KV, secrets, or home. The effective_kv / effective_secret_store / effective_home / effective_tmp / effective_capsule_log accessors therefore fall back to that neutral placeholder for a principal-less / load-time invocation (which denies rather than exposing any principal's state), while EVERY caller that carries a principal — the owner/default included — gets its own invocation_* overlay scoped to the invoking principal. A regression test constructs a shared instance and asserts a bob-stamped invocation reads/writes bob's KV and secret namespaces, an alice-stamped invocation lands in alice's, and a principal-less invocation hits only the neutral placeholder. Per-invocation env config is unaffected: it is read per call from the invoking principal's .config/env/{capsule}.env.json, not baked into the load-time instance. Relates to #977, #1069.
  • Made the shared capsule runtime present one stable, content-addressed IPC source_id to every principal, and kept the gateway's reply-trust derivation byte-identical. The WASM engine's capsule_uuid seed and the gateway's capsule_source_id_v1 both drop the principal segment (now {capsule_id}\0{content_hash}, same fixed namespace), so a shared runtime's replies carry one id all principals accept. Cross-principal reply routing is unaffected because it already keys on the principal-scoped routed subscription plus the body correlation id — the source_id is only an authenticity gate. A forged reply (wrong capsule/hash) is still rejected, and a new lockstep test pins the two derivations together so they cannot drift. Relates to #1069.

Added

  • Added a standalone fuzz/ package with initial structured fuzz targets for Tier 1 security predicates: IP block-set behavior, topic wildcard semantics, crypto wire encodings, capability resource patterns, and SSRF/local-egress helper predicates. The storage-heavy capability target and Wasmtime-heavy SSRF target are feature-gated so normal workspace builds stay unchanged. Closes #1086.
  • Runtime capsule commands can now use the existing elicit() host API, not only install/upgrade lifecycle hooks. The same host-generated request id, principal-stamped request stream, same-principal response filter, bounded timeout, and cancellation path apply. Runtime E2E now proves a normal capsule command elicit ignores a wrong-principal response before accepting the caller's answer, and crash recovery now kills the daemon while an elicit waiter is in flight to prove the command fails bounded instead of hanging. Closes #1068.
  • Daemon capsule discovery now includes the configured workspace root's .astrid/capsules directory in addition to the default principal install directory, so capsules installed by local workspace flows are picked up when the daemon starts or reloads from that workspace. Closes #1068.
  • Added runtime e2e coverage gates for the built-in CLI, gateway HTTP routes, and first-party capsule commands, plus a dedicated fake OpenAI-compatible runtime harness and CI workflow that installs current capsule artifacts in an isolated ASTRID_HOME. Closes #1068.
  • DevicePubkey<T> now exposes the same additive string/serde trait surface as the adjacent device-key newtypes. The public typed wrapper for canonical ed25519 device public keys now supports TryFrom<String>, FromStr, generic PartialEq comparisons, and serde serialization/deserialization while preserving the existing string-shaped profile and management API structs. Closes #1056.
  • Registered and implemented astrid:http@1.1.0 host-side — the per-request control surface for the HTTP client — alongside the frozen @1.0.0. Both versions are now registered in the wasmtime linker and backed by ONE shared implementation, so existing capsules are untouched and new ones opt in package-by-package. A capsule importing @1.1.0 gets caller-set controls via an all-optional request-options record (an empty value reproduces @1.0.0 exactly): four-phase timeouts (connect / first-byte / between-bytes / total), redirect policy (follow / error / manual) with mandatory per-hop SSRF re-validation, response and decompressed-body size caps, an auto-decompress toggle, https-only scheme enforcement, and subresource integrity (sha256 / sha384 / sha512); plus response-meta (final URL after redirects, redirect count, elapsed ms, wire bytes) on every buffered response. The @1.0.0 http-request / http-stream-start are thin shims that reproduce prior behaviour exactly (follow ≤10 redirects, 30 s timeout, 10 MB cap) and delegate to the same backend. Security: redirects on BOTH the buffered and streaming paths are now followed MANUALLY by the host, so every hop re-runs the full airlock — scheme check, the async per-capsule allow-list gate, and the egress airlock — with Authorization / Cookie stripped on a cross-origin hop and IP-literal targets blocked. This closes a redirect-SSRF gap on the streaming path where the per-capsule gate previously ran only on the initial URL (the IP airlock already ran per hop). astrid:http is the first host package to ship two versions, so this also threads the multi-version WIT staging (build.rs keys each staged deps/ dir by name@version) and the dual-trait registration. Deferred to follow-ups (honest stubs, not silent gaps): the http-upload streaming request body, response trailers extraction, and un-stubbing the stream pollable / body-stream. No @1.0.0 shape change — it is frozen and untouched. Closes #1049. Part of #1012.
  • Operators can now tune the astrid:http host's seven previously-hardcoded limits via a new [http] config section, defaulting to today's exact constants so an absent section changes nothing. The host's outbound HTTP ceilings — buffered-path default total timeout (default_timeout_secs, 30), streaming connect timeout (stream_connect_timeout_secs, 30), per-chunk streaming read timeout (stream_read_timeout_secs, 120), time-to-first-byte header-deadline floor (header_deadline_secs, 120), max redirect hops (max_redirects, 10), per-capsule concurrent-stream cap (max_concurrent_streams, 4), and the buffered-body default/ceiling (max_response_bytes, 10 MiB) — were fixed module constants. Each is now an operator knob resolved once by the daemon from [http] into a typed HttpLimits value (seconds pre-converted to Duration off the request hot path), stored on the kernel, and snapshotted onto every pooled HostState at load — a global value, the same for every capsule (it threads through the WasmEngine/loader like the runtime concurrency limits, not per-capsule like the local-egress allowlist). The request path reads its defaults and ceilings off HostState.http_limits instead of the constants: a per-request request-options value may still tighten a limit (a shorter timeout, fewer redirects, a smaller body cap) but a configured ceiling can only lower the bound, never let a caller exceed it — and max_response_bytes is additionally hard-clamped to the shared absolute MAX_GUEST_PAYLOAD_LEN host payload limit, so config can lower but never raise it above the host hard cap. Operator config only: like [security.capsule_local_egress], a project/workspace config layer cannot set or widen [http] (merge::restrict reverts the whole section to the operator baseline as a unit; regression-tested), so untrusted project config can never relax the host's HTTP limits. New unit + config-merge + host tests cover the defaults reproducing the constants, partial-section parsing, the operator-only merge guard, config show surfacing the section, and that a non-default config actually changes behaviour (max_concurrent_streams = 2 rejects the 3rd stream with Quota, default_timeout_secs = 5 drives the resolved default total, max_redirects = 3 clamps a larger caller value). No WIT / host-ABI change — kernel-internal threading + config only.

Changed

  • Gateway-to-kernel requests now survive legitimately slow kernel operations instead of failing them with a spurious timeout, and a genuine timeout now returns 504 Gateway Timeout rather than a generic 500. The IPC response wait was a blanket 15-second total deadline: a heavy operation such as installing a capsule (unpack, load, then run the capsule's install lifecycle hook, which instantiates and executes WASM) could legitimately exceed it under load, and when it did the gateway mapped the transport timeout to an opaque 500 — the source of intermittent "capsule install expected HTTP 200, got 500" flakiness. The kernel now emits a lightweight Working keepalive on a request's response channel every 5 seconds while a slow handler is still in flight (a fast operation finishes before the first keepalive and emits none, so no extra bus traffic on the common path), and every request-family uplink treats its timeout as an inactivity window — max silence between frames — that each keepalive resets, so the total wait is bounded only by how long the kernel keeps signalling liveness. An absolute 10-minute ceiling (checked against each read, so it cannot be overshot by a full inactivity window) still bounds a wedged-but-still-signalling kernel, and runaway guest execution remains bounded independently by the fuel ledger, the epoch-interrupt deadline, and the memory ledgers — the IPC timeout was never that guard. The kernel-request client paths now return a typed KernelClientError (preserving the underlying transport error as a source rather than a flattened string): a Timeout maps to 504 (retryable, request may still be completing) while connection loss, bus shutdown, and decode failures stay 500 and a real install/authorization failure stays 403, all unchanged. The keepalive is a liveness signal only: it is never a terminal response and is swallowed by the uplink, so it never reaches an HTTP client. Closes #1092.
  • Marked the public field-bearing structs and enums touched by the shared-by-hash capsule work #[non_exhaustive] so future additive changes (a new field or variant) are non-breaking. Covers astrid-capsule's HostState, NetStream, TcpStreamSlot, InterceptorHandle, PrincipalMount, ConnectionIdentity, Unregistered, and CapsuleRegistry, plus astrid-storage's SecretStoreError, ScopedKvStore, KvSecretStore, FileSecretStore, and (under the keychain feature) KeychainSecretStore / FallbackSecretStore. To keep HostState constructible from astrid-hooks after the marker (a cross-crate struct literal no longer compiles), added a HostState::for_hook(HookHostStateParams) constructor in astrid-capsule that fills every fail-closed hook default and takes only the pieces a hook varies; the hook handler now builds through it instead of naming ~50 fields. LifecyclePhase is deliberately left exhaustive: its unit variants are constructed by name in other crates (astrid-capsule-install, integration tests), and #[non_exhaustive] would forbid that cross-crate construction with no in-scope fix. Relates to #1069.
  • Restored HostState::principal_kv_namespace() as #[deprecated] rather than removed, with a corrected body. The method is superseded by effective_kv() and returns only a namespace string, so it cannot express the fail-closed/neutral fallback a shared runtime uses for principal-less contexts. Its body now anchors the INVOKING principal (effective_principal(), falling back to the load owner only when no caller is in scope) instead of the raw load owner, so a stray caller gets the current principal's namespace rather than the misleading owner-only value the shared-runtime model made incorrect. Deprecating (not deleting) keeps the public API backward-compatible. Relates to #1069.
  • Device key identifiers now have typed core wrappers while preserving string-shaped public API and wire formats. Added generic DeviceKeyId<T> and DevicePubkey<T> views for paired-device handles and canonical ed25519 public-key hex, then routed profile validation, lookup, pair-device redeem/revoke, and socket handshake verification through those typed paths. This removes duplicated normalization and makes key-id/pubkey mixups harder without changing serialized profile or management API structs. Closes #1047.
  • CI clippy checks updated to run with --all-targets and all pre-existing clippy lints resolved across test, example, and benchmark targets. Refactored test-only code to address dead code, field reassignments with default, misplaced imports, and excessively long functions workspace-wide. Closes #1004.
  • Added support for Copilot automated agents in CI checks. Added Copilot to .github/contributors.yml under maintainers and bypassed the contributor account age check in .github/workflows/pr-checks.yml for automated agent PRs to allow successful workflow runs.

Docs

  • Added docs/models.md -- user guide for LLM model selection (astrid models list/current/set/unset), provider discovery (openai and openai-compat capsules), install-time onboarding, and the no-model error path. Relates to #961.

Security

  • Closed the cross-principal host-state bleed a shared capsule runtime opened, replacing the load-time owner fallback with a neutral fail-closed placeholder (corrects the earlier #1069/#1083 restoration). A content-addressed runtime is shared across every principal viewing the same WASM hash and is loaded under PrincipalId::default(). default is an ordinary principal that merely holds admin, so the previous effective_kv/effective_secret_store/effective_home/effective_tmp/effective_capsule_log owner fallback meant a principal-less, owner, or overlay-construction-failed invocation resolved to default's real KV namespace / secrets / home — an "anything can read default" bleed. Two live paths were exploitable: (1) the guest-pulled ipc::recv path never installed invocation_secret_store, so a non-default publisher's has_secret / secret reads fell through to default's secrets; (2) both the interceptor and recv paths skipped installing an overlay when the caller equalled the load-owner, and the degrade path (overlay construction failing) left the overlay None and fell back to the owner. Fixes: the shared runtime's load-time kv/secret_store/home/tmp/capsule_log are now NEUTRAL placeholders holding no real principal's data — kv is a fresh, physically-isolated in-memory store on a reserved namespace (neutral.fail-closed:capsule, unproducible by any real principal since principal ids are [a-zA-Z0-9_-]), secret_store is a new deny-all DenySecretStore (exists→false, get→None, set/delete→error), and home/tmp/capsule_log are None. A new separate kv_backend handle carries the real shared backend used only to build per-invocation overlays. EVERY invocation carrying a present, parseable principal — the owner/default included — now installs its own {principal}:capsule:{id} overlay via one shared install_principal_overlays* helper (used by both the interceptor and recv paths so they cannot drift); a caller with no parseable principal (system/lifecycle events: watchdog.tick, capsules_loaded) installs no overlay and resolves to the neutral placeholder. The degrade path (overlay construction failure for principal B) now fails closed to the neutral placeholder, never the owner. The registry additionally REJECTS registering a hash already loaded under a different owner (register_for now errors, forcing cross-principal shares through register_existing), so no code path can create a shared instance owned by a real non-default principal whose fields would become a fallback. New tests pin: recv-path secret isolation (a non-default publisher resolves its OWN secret store, not default's), no-cross-principal fallback / degrade to neutral (not default, not B-reads-another), every caller (owner included) gets its own explicit overlay, overlay writes land on the real backend while the neutral store stays empty, and the deny-all secret store. No WIT / host-ABI change — kernel-internal host-state + storage placeholder + registry guard only. Corrects #1083; closes #1069. Relates to #977.

  • A shared failed capsule runtime is now restarted exactly once and rebuilt for every principal view, instead of firing N redundant restarts and leaving non-requesting principals with a dead view. The health monitor iterates cloned_values_with_principal(), which yields one (principal, Arc) pair PER VIEW — so a failed shared runtime (issue #1069) with N views produced N failure entries and N restart attempts, and the restart tracker keyed by principal/capsule_id tracked N independent restart budgets for one instance. Failures are now deduplicated by capsule id (one restart per shared runtime) and the restart tracker keys by capsule id alone. Separately, restart_capsule for a non-last view previously released only the requesting principal's view, left the still-failed runtime alive, and (because the hash was still loaded) merely re-attached that view via register_existing — a no-op that left a dead runtime. It now captures all viewing principals, tears the shared runtime down completely, then rebuilds it and re-attaches every view, so no principal is left pointing at a dead runtime. Kernel-internal only. Relates to #1069.

  • Updated the locked ammonia transitive dependency to 4.1.3, clearing RUSTSEC-2026-0193 / GHSA-9jh8-v38h-cvhr from the RustSec audit.

  • Hardened the public supply-chain evidence path for repository trust scanners. CI now commits the workspace Cargo.lock, runs dependency automation through Dependabot, pins GitHub Actions to immutable SHAs, declares least-privilege workflow token permissions, adds CodeQL scanning and public API compatibility checks, verifies the lockfile before RustSec audit, reviews dependency changes on PRs, and signs plus attests release tarballs/checksums with keyless Sigstore/GitHub provenance bundles.

  • Sensitive per-action capsule host calls (filesystem read/write/delete, outbound network connect/bind, process spawn) now land on the kernel's durable, signed, hash-chained audit log — allowed, failed, OR denied. Previously only kernel admin-API requests reached the chain; the fs/net/process host functions emitted off-by-default tracing observability lines (debug level) that never persisted, and a security-gate denial left no trace at all (the gate early-returned before any audit envelope). A new synchronous HostAuditSink seam closes this: the WASM host engine — which has no astrid-audit dependency and no custody of the runtime signing key — reports a neutral, primitives-only event (FileRead/FileWrite/FileDelete/NetConnect/NetBind/ProcessSpawn × Allowed/Failed/Denied) to the sink, stamped with the host-resolved effective_principal (never guest-supplied data); the kernel implements the sink (it holds both the log and the key), maps the event onto a signed AuditAction, and appends it synchronously on the host fn's existing blocking thread. The append is NOT routed over the broadcast event bus (a lag-droppable record is not provable) and a persistence failure is logged and swallowed — audit degrades to "continue + alert", never panicking or blocking the host call (mirroring the admin-audit path). New AuditAction::{NetConnect, NetBind, ProcessSpawn} variants (kernel-internal, not WIT). Denials are recorded exactly once: the gate records the Denied event before its early return, so the success-path envelope is never also reached. Producer-side (capsule) and consumer-side (kernel) tests pin the principal stamping, event/outcome mapping, the DeniedFailure+Denied-proof shape, and that the signed chain still verifies after high-frequency appends. Follow-ups (documented, not silent gaps): FileWrite content-hash fidelity at this seam (a zero placeholder is recorded today), KV host-op gating+audit, reserved-namespace hardening of the live audit bus topic, and the chain-as-spine + content-addressed value-store sink optimization for high-volume workloads. Review hardening: (1) the per-chain append is now atomic — AuditLog::append_inner reads the chain head, signs the entry against it, persists, and advances the head all under the chain_heads write lock, so two concurrent same-(session, principal) appends can no longer both read the same parent hash and fork the signed chain (which made verify_chain report valid = false under ordinary concurrent host-call load); signing under the lock serializes same-chain appends, which is correct because the hash chain's order is the product. (2) Guest-controlled strings (path / host / addr / command) are truncated to 1024 bytes (UTF-8 char-boundary safe) at the kernel sink before signing and persisting, closing a disk/CPU amplification path where a zero-capability capsule drives unbounded audit growth by passing multi-megabyte arguments to gate-denied host calls. (3) The fs/net/process error arms that previously returned via ? before any audit now record a Failed-outcome entry: a failed sandbox build or spawn/spawn-background exec, a resource-table push failure after a child has already forked (spawn-background), and a resource-table push failure after a TCP connect already succeeded (connect-tcp) — so a fork or a fork-then-abort never leaves the chain with no trace. Regression tests cover concurrent same-chain append integrity, the sink-boundary truncation, and a gate denial driven through the public connect-tcp host fn. Refs #994.

  • Made capsule-session's per-principal KV isolation an explicit, tested invariant (closes #977). capsule-session keys its store as the principal-less session.data.{id}, so cross-principal isolation comes entirely from the host KV namespace ({principal}:capsule:{capsule_id}), not from the capsule's keys — correct today, but previously implicit. HostState::effective_kv now documents this contract and the exact boundary of its owner-store fallback (correct only when no caller is in scope or the caller is the owner), and new regression tests pin it: two principals writing the identical session.data.default key resolve to different namespaces and never collide, the accessor prefers the per-invocation store and falls back only as specified, and the absent-principal in-scope case falls back to the owner store (the documented edge that holds safe because every producer of a principal-scoped topic stamps an authenticated principal). The debug_assert_invocation_field_set doc now records why a host-wide fail-closed assert on the absent-principal case is intentionally NOT added: principal-less system/lifecycle handlers (capsule-react's astrid.v1.watchdog.tick, capsule-registry's astrid.v1.capsules_loaded) legitimately fall back to the owner/global store, so such an assert would fire on sound paths. No WIT / host-ABI change — docs + tests only. Closes #977.

  • A capsule can now reach a loopback/private LLM endpoint (LM Studio on 127.0.0.1:1234, Ollama, a LAN IP) through operator consent instead of a hand-edited config.toml, without weakening the SSRF airlock (closes #1028). Two composed mechanisms ship together. (A) A host-stamped, capsule-unforgeable transport-origin marker plus runtime elicitation. A new host-only origin: MessageOrigin { System | LocalSocket | RemoteGateway } field on the kernel-internal IpcMessage records where a request entered the system — stamped exactly like device_key_id (host-derived internal-bus metadata, no WIT change; the guest-facing record ipc-message is unchanged and to_wit_message() keeps dropping it, so it stays invisible to capsules). The kernel stamps LocalSocket only for a handshake-bound Unix-socket connection (an unbound/unauthenticated local connection stays System — fail-closed, non-local — parallel to the anonymous principal), RemoteGateway at the gateway bus-publish sites, and propagates the originating request's origin through guest publish/fan-out and publish-as — so a RemoteGateway request flowing through a fan-out capsule (react → openai-compat) cannot be elevated to LocalSocket. Absent/unknown/legacy/CLI-proxy-wire frames decode to System via #[serde(default)] + #[serde(other)]. At the airlock-rejected egress arm for an IP-literal local endpoint, astrid:http now consults transport origin: a LocalSocket request that is not already pre-blessed/granted elicits one-shot operator consent via the existing request_approval primitive (ApprovalRequired on astrid.v1.approval, resource = host:port only); on approve/approve_session it caches a per-principal, per-capsule session grant (AllowanceStore; the grant carries the requesting capsule_id, so neither principal A's grant exempts principal B nor capsule A's grant exempts capsule B reaching the same host:port), and on approve_always it also persists to that principal's capsule-keyed profile.network.capsule_egress[<capsule>] map on disk (a flat per-principal list would have let a remembered grant for one capsule silently exempt every other capsule that principal runs). Because the in-memory AllowanceStore starts empty on every daemon boot, the egress fast path also consults that persisted capsule_egress map for the effective principal and current capsule before eliciting — so an approve_always grant actually survives a restart (the "remember across restarts" contract) instead of being silently forgotten and re-prompted. That persisted consult preserves both isolation axes (it reads only this principal's profile and only this capsule's bucket, so a remembered grant for capsule A / principal X never permits capsule B / principal Y) and is reached only after the upstream origin gate and SSRF airlock, so it short-circuits the elicitation, never the origin/airlock checks. Any non-LocalSocket origin (System/RemoteGateway/unbound) never elicits and fails closed, so a remote gateway caller driving the same react/openai-compat egress path can neither see nor answer a local-egress prompt. A consent-granted endpoint re-enters the exempt path, refusing redirects identically to a pre-blessed one. The consent round-trip reuses the shared astrid.v1.approval[.response.*] channel rather than a distinct egress namespace — a documented, low-severity residual (only the trusted operator uplink and MCP broker hold publish rights on that response topic; an arbitrary untrusted capsule cannot), deferred because a distinct namespace would require an out-of-repo operator-surface publish-ACL change in lockstep. V1 covers IP-literal local endpoints (127.0.0.1, [::1], 192.168.x, 10.x, 169.254.x); hostname endpoints (localhost:1234) still require a pre-bless. (B) A CLI guided pre-bless. astrid init / astrid capsule config / capsule install now detect when an entered provider base_url points at a loopback/private/link-local address and prompt the operator ([y/N]) to add the [security.capsule_local_egress] exemption keyed by capsule id — the operator is unambiguously local by construction in the CLI process, so this is guided pre-bless, not runtime elicitation. Free-text/non-resolving hosts are skipped. No WIT / host-ABI change — the marker is host-only, threaded through kernel-internal IPC, gateway routes, and CLI config. New unit + regression tests cover the origin serde floor, bound/unbound socket stamping, fan-out origin propagation (no elevation), pool-reset clearing, the per-principal/per-capsule consent fast path / elicit / approve-always disk persist, per-principal AND per-capsule grant isolation (a react grant does not exempt openai-compat reaching the same endpoint — in-memory session grant AND persisted capsule_egress), the persisted-grant fast path surviving an empty store on a fresh daemon while still eliciting for the wrong capsule or wrong principal, the fail-closed non-local paths, is_local_address detection, and the operator-config write. Hardening: the global SSRF escape hatch is no longer a bypass of the new consent gate. ASTRID_TEST_ALLOW_LOCAL_IP is now gated behind cfg(test) — the daemon's release binary never even reads it, so a stray test env var in production can no longer disable the airlock (and with it the consent gate) for every capsule. ASTRID_ALLOW_LOCAL_IPS stays recognized in production for operators/CI but is now deprecated, emitting a one-time warning that is honest about its blast radius (it disables the airlock for ALL capsules AND exposes loopback/private endpoints to REMOTE RemoteGateway API callers, bypassing the per-capsule consent gate) and directing operators to the per-capsule [security.capsule_local_egress] allowlist; it will be removed in a future release. A new end-to-end regression test locks the anti-forge propagation invariant: a RemoteGateway-stamped request driven through the real fan-out hop chain (caller_context origin → publishpublish_inner → republished bus message → downstream caller_context) is observed AS RemoteGateway at the egress site and consent_local_egress DECLINES — it can never be reset to System/LocalSocket across a republish. Revocation caveat (documented, no behaviour change): neither exemption source is revoked live — the operator [security.capsule_local_egress] allowlist is a load-time snapshot and a persisted approve_always grant lives in a process-lifetime profile cache with no TTL/watcher — so removing an entry or grant only takes effect after a daemon restart. This caveat is now stated at the consent gate (module doc) and printed by the CLI add-path so an operator editing config to revoke is not falsely reassured (a live-revocation verb is tracked separately). Closes #1028. Relates to #961, #1026; references the airlock work #965.

  • The host elicit() waiter now enforces that an answer comes from the same principal the input is being collected for, closing a cross-principal elicit hijack. A capsule's lifecycle elicit() mints a request_id and the prompt SSE stream forwards it verbatim to every connected client, so any authenticated caller who observed an in-flight request_id could publish an ElicitResponse on the derived reply topic and answer — or cancel — another principal's elicit (now also reachable from the new POST /api/agent/elicit-response endpoint). The waiter previously keyed only on the reply-topic suffix and the payload variant, never on who sent the reply. It now stamps the originating principal on the outgoing ElicitRequest and only unblocks on a reply carrying a matching principal; a mismatched (or unstamped) reply is logged and rejected, and the wait continues on its original deadline so a flood of forged replies cannot extend or DoS the legitimate one. The in-process CLI install answerer now echoes the request's principal back on its reply so interactive astrid capsule install onboarding keeps working; socket-driven answerers (TUI, chat) already carry the host-verified principal via the uplink's publish-as self-stamp.

  • The astrid:http SSRF airlock now covers IP-literal request URLs and redirect hops, and surfaces the typed airlock-rejected error instead of a generic connection failure. The host's SafeDnsResolver is consulted by reqwest only for hostnames, so a request whose authority is already an IP literal (http://127.0.0.1:1234, http://[::1], http://192.168.x) connected straight through with the airlock never running — a capsule holding net = ["*"] (e.g. openai-compat) could reach loopback/private/metadata addresses over HTTP. The same gap let a public, allow-listed host bounce a capsule onto an internal service via a 302 Location: pointing at an IP literal (a redirect-SSRF that returned the internal response body). Both http_request and http_stream_start now (a) run a pre-flight airlock on any IP-literal request URL before issuing it, and (b) install an airlock redirect policy that re-checks every hop, refusing IP-literal targets that fail the airlock and capping the hop count; hostname targets remain airlocked at DNS-resolution time. When the resolver or the redirect policy blocks a request, the error is now mapped to the existing astrid:http@1.0.0 airlock-rejected variant (previously a blocked hostname surfaced as an opaque connection-error, indistinguishable from "server down"), so a capsule can tell an SSRF rejection from a real connection failure. Separately, is_safe_ip now decodes IPv6 transition/translation forms that embed an IPv4 address — the NAT64 well-known prefix (64:ff9b::/96), 6to4 (2002::/16), and Teredo (2001:0::/32, server + obfuscated client) — and rejects any embedding a private/loopback/metadata IPv4 (e.g. 64:ff9b::a9fe:a9fe → 169.254.169.254), plus deprecated site-local (fec0::/10); transition addresses embedding a public IPv4 are unaffected. The net.connect-tcp path already airlocked resolved literals and is unchanged. The process-wide ASTRID_ALLOW_LOCAL_IPS / ASTRID_TEST_ALLOW_LOCAL_IP escape hatch still disables the airlock for all capsules (unchanged); a sanctioned per-capsule local-endpoint allowance remains open work on #965. Eleven new unit tests pin the literal pre-flight, the redirect classification, the resolver trip flag, and the IPv6 embedding cases. No WIT / host-ABI change — the airlock-rejected variant already existed. Closes #1020. Refs #965 (the local-LLM allowance follow-up).

  • Operators can now sanction a specific capsule reaching a loopback/private LLM endpoint via a per-capsule local-egress allowlist, instead of the all-or-nothing global SSRF bypass (closes #965). A new operator config map [security.capsule_local_egress] keys a capsule id to a list of host:port / host:* endpoints that are exempt from the astrid:http SSRF airlock for that capsule only — the sanctioned way to point e.g. openai-compat at LM Studio on 127.0.0.1:1234 or Ollama on a LAN address. Previously the only escape hatch was ASTRID_ALLOW_LOCAL_IPS, which disables SSRF protection for every loaded capsule. The allowlist is resolved once by the daemon from config, stored on the kernel, and each capsule is handed its own slice at load (snapshotted onto every pooled HostState, like capability_names). The exemption is decided at pre-flight, where the request's host and port are known, and the matched hostname is propagated to SafeDnsResolver as a scoped exempt_host so a hostname endpoint (localhost:1234) resolves through to its loopback address while staying port-specific (localhost:9999 is still airlocked) and host-scoped (redirect hops to other hostnames are still airlocked). This is operator config: a capsule's own (untrusted) Capsule.toml cannot set it (mirrors the EnvScope untrusted-manifest invariant), and a project/workspace config layer cannot introduce or widen it (merge::restrict block_workspace_override, with regression tests). It only exempts endpoints from the airlock; it never grants network access the capsule's manifest net allowlist doesn't already declare, and an empty/absent entry is fully fail-closed. Scope is the HTTP egress surface (http_request + http_stream_start); the net.connect-tcp path is unchanged and a parallel allowance there is a follow-up. Reuses the manifest net_connect host:port matcher so semantics are identical. New unit + config-merge regression tests cover host:port matching, port-specificity, the operator-only merge guard, and the resolver exempt-host behaviour. No WIT / host-ABI change — kernel-internal threading + config only. Closes #965.

Added

  • The HTTP gateway can now expose a principal's conversation threads over two authenticated routes: GET /api/agent/sessions (paginated thread list with metadata) and GET /api/agent/sessions/{id}/messages (one thread's full transcript). Previously the only agent-facing route was POST /api/agent/prompt (the SSE invocation stream); a dashboard or automation client had no way to enumerate a principal's past threads or read a transcript back. Both routes are a thin proxy to the capsule-session capsule over the in-process event bus, mirroring the request/reply-over-bus shape bus_admin.rs uses for kernel admin ops but targeting capsule topics: the gateway generates a fresh correlation_id, subscribes FIRST to a per-correlation scoped response topic (session.v1.response.{verb}.<correlation_id> — no request-id filtering needed, the scoped topic isolates the reply, with a defensive correlation_id re-check on the body), publishes the principal-stamped request (session.v1.request.list / the existing session.v1.request.get_messages verb), and awaits one reply with a 15s timeout. The principal stamp is the only authority: the caller's verified CallerContext.principal (plus device_key_id when the bearer is device-scoped) is stamped on every outbound request, the kernel scopes capsule-session's KV reads to that principal's namespace, and the path {id} / query params are payload data only — they never substitute for the principal and never reach a topic segment, so a malicious id can neither cross into another principal's threads nor hijack another request's reply. list takes limit (default 50, max 200; over-cap → 400), an opaque cursor, and include_archived (default false) and returns { sessions, next_cursor, total } (total = the principal's thread count when cheaply known, else null); transcript validates id (non-empty, ≤256 chars, no control chars → 400) and returns { session_id, messages } with messages passed through as opaque JSON. A non-existent thread returns an empty messages list (not a 404 — the capsule cannot distinguish "never existed" from "empty", so empty avoids leaking thread existence). A closed bus → 500; a capsule that doesn't reply in time → 502 (timeout, distinguishable from a gateway fault). To support a mixed 1.0/1.1 fleet, list first probes the loaded capsule set — via an in-process, capability-free CapsuleTopicProbe (a live-registry read, deliberately not the capability-gated GetCapsuleMetadata, which would 403 ordinary callers and leak the capsule inventory) — for a handler of session.v1.request.list and returns an immediate 501 Not Implemented when no session capsule implements the 1.1 list verb — rather than hanging to the bus timeout on a verb nobody handles; the transcript route needs no gate (its get_messages verb exists in session 1.0). Both routes are registered in the OpenAPI doc and each increments a count-only metric (no principal/session id in any label). The gateway adds no WIT or IPC-contract change itself (transport only); it consumes the new session.v1.request.list verb defined by the astrid-bus:session 1.1 contract, shipped separately in the wit + capsule-session changes. Closes #972.

  • The HTTP gateway now exposes full conversation-thread management plus a per-principal live conversation feed, completing the dashboard/multi-device surface over capsule-session. Four new thread routes — GET /api/agent/sessions/{id} (one thread's metadata; 404 when the thread is not in the caller's namespace), PATCH /api/agent/sessions/{id} (update title/archived/meta), DELETE /api/agent/sessions/{id} ({ deleted: bool }), and GET /api/agent/sessions/search?q=&limit=&cursor=&include_archived= (full-text search, q required → 400 on empty, limit default 20/max 100) — proxy to the capsule-session get_meta/update/delete/search 1.1 verbs over the same subscribe-first, per-correlation-scoped, principal-stamped request/reply-over-bus primitive the list/transcript routes use. PATCH is by-presence: the gateway reads the body as a raw JSON object and forwards only the keys the client actually sent (a minimal patch of correlation_id + session_id + whichever of title/archived/meta were present), so an absent key leaves the field unchanged, a present key sets it, and "" clears it; unknown body keys are dropped and session_id is always the gateway's path value (never the body), so a client cannot smuggle another field into the capsule's update. update/delete/search are gated on a 1.1 session capsule (ensure_list_supported501 when none is loaded); delete deliberately does not 404 on deleted:false (the principal-scoped delete is idempotent). The SessionSummary schema gains the frozen title/last_message_preview/archived/meta fields. GET /api/agent/stream (#973) is the new per-principal live SSE feed — the multi-device sync channel: it opens two routed subscriptions, both scoped to Some(Some(caller.principal))agent.v1.* (in-flight turn events) and session.v1.event.* (thread lifecycle created/updated/deleted) — and merges them into one stream (event: agent / event: session_event, each wrapping { topic, data } so a client can disambiguate sub-kinds), emitting an initial ready event with a 15s keep-alive and a 1-hour connection cap (clients reconnect). Cross-principal isolation is the load-bearing #973 property and is enforced structurally at the bus: the inner Some(principal) scope drops any foreign-principal event at enqueue, so a second device only ever sees its own principal's conversation — a co-resident (buggy or hostile) publisher on those topics cannot leak another principal's threads onto this feed. The feed is not session-scoped (it fans all of the principal's threads) and is not 501-gated (it gracefully carries whatever is published; agent.v1.* exists in session 1.0). All new routes are registered in the OpenAPI doc, principal-stamp every outbound request (the stamp is the only authority — {id}/query/body never substitute for it), and each increments a count-only metric (astrid_gateway_agent_sessions_{get_meta,update,delete,search}_total, astrid_gateway_agent_stream_total; no principal/session id in any label). Transport only — no WIT change, no IPC-contract change, no RFC; consumes the astrid-bus:session 1.1 verbs and the session.v1.event.* lifecycle events shipped separately. Closes #973, refs #972.

  • Headless and offline distro seeding: astrid init gains a non-interactive --yes path and a signed, self-contained .shuttle bundle, so a distro (including an LLM provider) can be seeded in a Docker build or an air-gapped environment with no stdin prompts and no GitHub access at build time. Previously init prompted on stdin (select_capsules/collect_variables) and fetched Distro.toml plus every capsule from GitHub, so it could not run in a container build. --yes takes group defaults (a new default = true marker on a distro capsule) and resolves variables from --var KEY=VALUE > ASTRID_VAR_<KEY> > the manifest default (hard error if a required one is unset; secrets never logged), with --offline/--allow-unsigned/--accept-new-key alongside. astrid distro seal --key <ed25519> produces a deterministic .shuttle (gzipped tar of Distro.toml + resolved Distro.lock + Distro.sig + pre-built capsules/<name>.capsule); astrid init ./x.shuttle unpacks it (traversal/symlink/size-hardened), verifies the ed25519 signature against a per-distro TOFU-pinned key (~/.astrid/trust/<id>.pub, with a compiled-in OFFICIAL_KEYS pre-pin and a hard fail on an unexpected key change), enforces the manifest_hash binding that ties Distro.toml to the signed lock (mandatory when signed), checks every capsule's BLAKE3 against the lock, and installs entirely offline. Capsule version pinning is now real: version/tag resolve to a concrete GitHub release tag (replacing the prior unconditional releases/latest that ignored the pin and silently built HEAD), the lock records the actually-resolved ref, and a pinned-but-unresolvable version is a hard error rather than a HEAD build; distros are release-only (branch/rev are rejected in a manifest — git-ref builds remain available via astrid capsule install). Adds astrid keypair pubkey <name> --format wire (emits ed25519:<base64> for [distro.signing]) and a docs/distro-signing.md runbook. Closes #964.

  • astrid init now multi-selects LLM providers and, for each, collects credentials and a default model picked from a live list — instead of typing a model id blind into one free-text field. Onboarding previously framed the model as a single free-text shared [variables] entry, so the operator had to already know a valid model id for their provider and type it exactly. Two additions close that: (1) a capsule [env] field may now declare options-from = { http, bearer, select, after }, marking it a dynamic SELECT whose option list is fetched live at prompt time — the native installer (which has network; the sandboxed capsule does not) substitutes already-entered values into the http/bearer templates, performs GET <http> (with Authorization: Bearer <bearer> attached only when the token is non-empty after trim and the resolved fetch URL has the same scheme, host, and port as the user-configured provider base_url the template is built from — so a capsule cannot point http at an attacker host (nor a cleartext http:// downgrade of an https:// base_url, even on the same explicit port 443) and exfiltrate the user's key; on a mismatch the bearer is withheld and a warning logged, and the unauthenticated request typically 401s into the free-text fallback), streams the response body under a hard 5 MB cap that aborts the transfer the moment the running total crosses the limit (generous for large aggregator /v1/models catalogs, still bounds the OOM vector even for a chunked / unknown-Content-Length response that a header-only check could not; an over-limit body errors into the same fallback), and parses the standard OpenAI data[].id shape into a deduped, server-ordered list; the after keys are collected first so the URL/credential placeholders resolve. Any discovery failure (network error, non-2xx, non-JSON, empty data) degrades gracefully to a free-text prompt with a hint — a discovery miss never aborts the install. (2) During distro install the group = "llm" capsules are presented as a provider multi-select ("Which LLM provider(s) do you want to set up?"), and each selected provider then runs its own [env] schema prompt after install so capsule-specific fields (api_key, base_url) and the dynamic model select resolve from the installed manifest; shared [variables] still template genuinely-cross-capsule values, and the prompt skips already-set keys. The end state is that every selected provider is installed and configured (creds + chosen default model), so the model registry spans them all. Manifest-schema + installer code only — no WIT change, no RFC. The discovery HTTP call reuses the CLI's existing reqwest dependency. Closes #1015.

  • New POST /api/agent/elicit-response gateway endpoint lets an HTTP client answer an agent's follow-up input request. The agent-prompt SSE stream already forwarded astrid.v1.elicit events (a capsule asking for follow-up input during, e.g., an agent-driven capsule install or onboarding), but there was no way for the HTTP client to send the answer back — so the capsule's host-side elicit() waiter blocked the full 120s timeout and the prompt SSE waited out its five-minute upper bound. The new endpoint publishes the answer onto the per-request reply topic the host waiter is subscribed to and returns 202 Accepted immediately; the agent's continuation then streams back over the still-open prompt SSE connection. The body carries the request_id from the forwarded elicit event plus value/values (omit both to cancel, matching the host cancel sentinel). The reply is stamped with the caller's bearer-verified principal, which the host waiter checks against the principal the elicit was collected for.

  • Agent-loop readiness introspection: a new GET /api/sys/readiness route, an astrid doctor check, and a boot warning surface whether the installed capsule set can actually serve an agent chat turn — and POST /api/agent/prompt now returns an immediate error instead of timing out when the loop is unconfigured. Readiness is computed name-agnostically from the loaded capsule manifests (no capsule name is hardcoded): some capsule's [subscribe] must match user.v1.prompt, some capsule's [publish] must match agent.v1.response, and every required (non-optional) [imports] entry must be satisfied by another loaded capsule's [exports]. Previously a fresh daemon — which hard-requires only the socket uplink — booted clean yet silently dropped every prompt: the publish no-oped, the client got ready + keep-alives + an empty close after a five-minute timeout, with no way to know what was missing. The shared astrid_capsule::readiness primitive is the single source of truth (the boot validator's required-import check now delegates to it); a new KernelRequest::GetAgentReadiness exposes the detailed view (gated capsule:list) for the route and astrid doctor. The POST /api/agent/prompt fail-fast instead reads an in-process readiness probe the daemon wires from the live registry — agent-loop serviceability is global daemon health, not per-principal authorization — so the fast-fail fires for every authenticated prompt caller (single- and multi-tenant alike), with no capability check and no socket round-trip, while the inventory-revealing detail stays behind the gated route. The boot warning is computed from the loaded registry after load completes (not the pre-load discovered set), so a manifest that fails to load is not mistaken for a working capability.

  • The HTTP gateway can now list and bind a principal's active LLM model over three thin, per-principal-scoped routes — closing the gap where the only model-selection surface was the TUI's interactive /models picker. A script, remote dashboard, or automation client talking to the gateway had no route to discover the available models for a principal or bind one; the route table wired principals/caps/quotas/groups/invites/capsules/env/audit/agent/system but nothing for the model registry. New routes: GET /api/models (list the caller's available provider-entries), GET /api/models/active (the bound model, or JSON null when nothing is bound — null is a valid 200, not an error), and PUT /api/models/active with { "id": "<capsule>:<model>" } (bind by id; returns the persisted active entry on success, 400 surfacing the registry's unknown model: … / ambiguity message verbatim on a bad id, and 400 on an empty id before the bus is even touched). The gateway is a thin transport: it publishes the existing registry IPC (registry.v1.{get_providers,get_active_model,set_active_model}) stamped with the caller's verified principal, awaits the reply on a principal-scoped routed subscription (subscribe_topic_routed_scoped(.., Some(Some(principal)))), and relays the registry's own success/error unchanged — it never resolves ids, enumerates models, or persists selection (that stays the registry capsule's job). Per-principal isolation is the core security property and is enforced structurally: the principal is the verified CallerContext.principal from the signed bearer (never a body/query field), the request is stamped with it (routing it into the caller's registry KV scope and making it the value the reply is matched against), and the reply is awaited on a route scoped to that principal so a foreign-principal reply is dropped at enqueue — a co-resident principal can neither read nor set another's active model. The three handlers and the SetActiveModelRequest schema are registered in the OpenAPI doc (GET /api/openapi.json lists /api/models and /api/models/active). A per-request model override on POST /api/agent/prompt is deliberately excluded (no capsule consumes a per-prompt model today; shipping it would forward a silently-dropped value). No WIT change, no IPC contract change, no RFC. Closes #1009.

  • Capsule CLI verbs are now reachable at the root: astrid <verb> [args…] resolves the same daemon-backed capsule verb as astrid capsule <verb>, with a typo guard that preserves clap's "did you mean …?" hint for mistyped built-ins. Previously the only way to invoke a capsule-contributed kind = "cli" verb was the namespaced astrid capsule <verb>; the root Commands enum had no external-subcommand catch-all, so an unrecognised first token was a clap parse error. A capsule verb (e.g. identity-export) can now be run directly as astrid identity-export …, the same shorthand gh/git/kubectl give their plugin verbs, while astrid capsule <verb> stays the canonical, unshadowable form. The shorthand is a second entry point into the unchanged resolution path (commands::capsule_verb::run_external): same provider resolution, same cli.v1.command.run.<provider> dispatch, same per-principal RBAC scoping, same exit-code mapping — it adds no new authority. Built-in root verbs always win because clap matches every declared Commands variant before the external_subcommand catch-all, so a capsule can never shadow status, mcp serve, version, etc. Adding the catch-all intentionally disables clap's built-in "did you mean …?" engine for root tokens, so a new pure typo guard restores it: commands::verb_suggest::nearest_builtin (Levenshtein ≤ max(2, len/3), exact matches and non-near-misses excluded, ties broken alphabetically for deterministic output) flags an unrecognised token that is a near-miss of a built-in (astrid statuss → "did you mean 'status'?") and exits 2 without booting the daemon — so a typo storm or a tab-completion misfire cannot repeatedly auto-start the daemon, and a capsule cannot register a near-miss verb to harvest fat-fingered admin commands. Only a token that is not a near-miss of any built-in (astrid identity-export, astrid models) is forwarded to capsule resolution. The known residual tradeoff: a future core built-in added to Commands will begin shadowing a capsule's root shorthand of the same name — acceptable because astrid capsule <verb> remains the stable, unshadowable fallback. No change to RESERVED_CAPSULE_VERBS, no WIT change, no IPC contract change, no RFC. Closes #1008.

  • Pair-device tokens now carry a per-device capability scope, so a device added to an existing principal can hold a subset of the principal's capabilities instead of its full authority — and the scope is enforced on BOTH auth transports. Previously AuthConfig.public_keys was a flat Vec<String> of ed25519 keys, every paired device was capability-identical to the principal, and a guest/use-only device could not be expressed; the only revocation granularity was whole-principal agent delete/disable. Each entry is now a DeviceKey { key_id, pubkey, scope, label, created_at } carrying a DeviceScope of Full (no attenuation) or Scoped { allow, deny } (deny wins, same grammar as grants/revokes). Legacy bare ed25519:<hex> strings migrate transparently to Full (no behaviour change for existing principals). At pair-device issue the operator supplies the scope (--scope use-only|full, or explicit --allow/--deny, or a friendly HTTP scope/allow/deny body); the kernel validates requested ⊆ issuer effective caps against the issuer's attenuated effective set, a scoped child inherits the issuer's denies (monotonic narrowing), and minting an unattenuated Full device requires the issuer to itself be unattenuated AND hold the new self:auth:pair:admin capability. The authenticating device's key_id reaches the kernel cap-gate tamper-evidently on both paths — host-derived from the per-connection registry for the socket/CLI keypair handshake (it rides the existing ingress/publish-as enforcement, no WIT change), and bound inside a signed 5-segment bearer for the gateway HTTP path — where authorize_request resolves the device's DeviceKey from the principal's own profile and intersects principal_caps ∩ device_caps, failing closed if the key_id resolves to no registered device. So a use-only device can drive POST /api/agent/prompt but its PairDeviceIssue returns 403 even though the principal holds self:auth:pair, regardless of whether it connects over HTTP or the socket. New pair-device list/revoke admin ops (CLI astrid pair-device list|revoke <key_id>, HTTP GET/DELETE /api/sys/principals/{id}/devices[/{key_id}]) make individual devices inspectable and evictable: revoke removes the DeviceKey so the handshake can no longer match it and the kernel gate fails closed, and the gateway evicts a live device-scoped bearer immediately via a per-key_id revocation watcher. Audit rows for issue/redeem/list/revoke carry the non-secret key_id and scope, never a raw key or token. No WIT change, no RFC, no capsule-cli change. Closes #947.

  • The astrid mcp serve shim now elicits interactive consent on a grant-on-use signal and, on Grant, re-sends the original tool call — instead of returning a bare access error. Consumer half of grant-on-first-use (#998/#1001): the kernel access-gate DROPS an ungranted tool.call and the broker replies a grant_required signal ({ request_id, capsule_id, principal, tool_name, call_id }) rather than a terminal result. The shim turns that into a binary Grant / Deny elicitation ("Grant this capsule to the identity?") — binary, not the four approve-verbs, because the kernel grant is always persistent (profile.capsules), so "allow once" is not expressible. This MIRRORS the ingress flow (gate → consent → re-send), NOT capability-approval (park → consent → resume): the original call was dropped, so on approve the shim re-sends it with a fresh req_id and the identical name+arguments (now passing the gate), and the re-sent reply still flows into the capability-approval gate so a tool that is both ungranted and capability-gated resolves in sequence. On approve the shim publishes { req_id, request_id, decision:"approve" } on the new astrid.v1.request.mcp.grant.respond front door and reads granted from the { kind:"grant.respond" } ack; the kernel persists the capsule on the principal. Marker discipline (the divergence from ingress): the broker holds a per-(principal, capsule) pending marker consumed on EVERY respond, so the shim ALWAYS responds — a deny is published on decline / cancel / no-elicitation-capability / elicit error too, or the marker would stick and a later call would get a benign "already pending" terminal instead of a fresh prompt. Fail-secure: any non-accept path publishes deny and returns a clean "Capsule access was not granted for this tool." MCP error; an accept the broker could not persist (ack granted:false) also returns the error rather than re-sending a call that would just trip the gate again; no panic/unwrap on the parse or elicit paths. astrid.v1.request.mcp.grant.respond is added to the non-retriable arm of the shim's connection-loss auto-retry allow-set — it mutates state (persists a capsule), so it must never be transparently re-issued. The grant target (principal, capsule) is never a respond body field — the kernel derives it from its own observed signal — so it is structurally un-spoofable from the shim. CLI/shim-internal, no WIT change; paired with the sage-mcp broker half. Refs #1002.

  • The astrid mcp serve shim now elicits interactive consent on a grant-on-use signal and, on Grant, re-sends the original tool call — instead of returning a bare access error. Consumer half of grant-on-first-use (#998/#1001): the kernel access-gate DROPS an ungranted tool.call and the broker replies a grant_required signal ({ request_id, capsule_id, principal, tool_name, call_id }) rather than a terminal result. The shim turns that into a binary Grant / Deny elicitation ("Grant this capsule to the identity?") — binary, not the four approve-verbs, because the kernel grant is always persistent (profile.capsules), so "allow once" is not expressible. This MIRRORS the ingress flow (gate → consent → re-send), NOT capability-approval (park → consent → resume): the original call was dropped, so on approve the shim re-sends it with a fresh req_id and the identical name+arguments (now passing the gate), and the re-sent reply still flows into the capability-approval gate so a tool that is both ungranted and capability-gated resolves in sequence. On approve the shim publishes { req_id, request_id, decision:"approve", capsule_id } on the new astrid.v1.request.mcp.grant.respond front door (the capsule_id is echoed back so the broker can clear its per-(principal, capsule) dedup marker; it is display/routing only and never a grant target) and reads granted from the { kind:"grant.respond" } ack; the kernel persists the capsule on the principal. A grant_required signal missing a non-empty request_id or capsule_id, or otherwise unparseable, is treated as malformed and surfaces a terminal MCP error rather than letting the dropped call masquerade as an empty success. Marker discipline (the divergence from ingress): the broker holds a per-(principal, capsule) pending marker consumed on EVERY respond, so the shim ALWAYS responds — a deny is published on decline / cancel / no-elicitation-capability / elicit error too, or the marker would stick and a later call would get a benign "already pending" terminal instead of a fresh prompt. Fail-secure: any non-accept path publishes deny and returns a clean "Capsule access was not granted for this tool." MCP error; an accept the broker could not persist (ack granted:false) also returns the error rather than re-sending a call that would just trip the gate again; no panic/unwrap on the parse or elicit paths. astrid.v1.request.mcp.grant.respond is added to the non-retriable arm of the shim's connection-loss auto-retry allow-set — it mutates state (persists a capsule), so it must never be transparently re-issued. The grant target (principal, capsule) is never a respond body field — the kernel derives it from its own observed signal — so it is structurally un-spoofable from the shim. CLI/shim-internal, no WIT change; paired with the sage-mcp broker half. Refs #1002.

  • astrid capsule build now bakes each capsule's tool descriptors into the installed meta.json, and astrid capsule show lists them — the tool surface is a static, offline-inspectable artifact instead of a runtime-only describe fan-out. Previously the only way to learn a capsule's tools (names, descriptions, input JSON schemas) was to load it into a running daemon and collect the tool.v1.request.describe fan-out at runtime; an installed-but-not-running capsule was opaque, and the fan-out was the single source of truth. The build command now, after the lean astrid-build companion compiles the component and packs the .capsule, instantiates the freshly-built component exactly as the kernel would at load time (a new astrid_capsule::describe_capsule_tools harness: same loader path, an in-memory KV store, a fresh event bus, a no-op MCP client — no socket, no daemon, no identity/profile wiring), drives its #[astrid::tool]-generated tool_describe interceptor, and writes the captured descriptors to tools.json inside the archive. At install, tools.json is read into a new CapsuleMeta.tools field (absent ⇒ empty, never a failure — install itself never instantiates WASM and stays daemon-free). astrid capsule show then lists each tool's name + first-line description and the count (Tools (8)), or Tools (none captured at build). The capture preserves the WASM engine's fail-secure integrity check: it stages a transient meta.json carrying the BLAKE3 hash of the very bytes being loaded (the check is satisfied honestly — the wasm matches what its meta claims — not bypassed), and removes it afterward. A capsule with no tools, a non-Rust/MCP-import build, or a capsule built before this capability all yield an empty tool list rather than an error. This is the foundation for later retiring the runtime describe fan-out; the sage-mcp broker is unchanged (a separate follow-up). Closes #978.

  • astrid capsule new <name> scaffolds a complete, first-try-compiling tool capsule — with a toolchain preflight and a self-contained authoring guide. Previously a new capsule author (human or a fresh agent) had to hand-assemble the skeleton from memory or by copying an existing capsule, and the most common first-build failure was a missing .cargo/config.toml getrandom backend cfg — without --cfg=getrandom_backend="custom", anything pulling getrandom (uuid v4, the HashMap RandomState) fails to link on wasm32-unknown-unknown with an opaque error. The new command writes .cargo/config.toml (with that fix), a pinned rust-toolchain.toml, a Cargo.toml (cdylib + size-optimised release profile), a Capsule.toml (component block + the mandatory tool-bus [publish]/[subscribe] ACL), a working src/lib.rs (a hello #[astrid::tool] example), a README.md, and an AUTHORING.md. The AUTHORING.md is a from-zero, self-contained guide — a Claude reading only it can author the capsule without touching Astrid's source: the #[capsule] + #[astrid::tool] pattern with a worked example, the Capsule.toml sections (including [capabilities] and the [publish]/[subscribe] ACL, with the fail-closed "empty table = deny all" rule), the getrandom footgun (already wired by the scaffold), the tool-bus topic conventions and WIT references a tool capsule needs, and the build → install → call dev loop. Before generating, the command runs a fail-friendly Rust-toolchain preflight: it checks that cargo/rustc are on PATH and the wasm32-unknown-unknown target is installed, and if anything is missing it prints actionable guidance (the rustup install one-liner and rustup target add wasm32-unknown-unknown) — and, when rustup is present, offers to add the target interactively (or non-interactively via --install-target). A missing toolchain never aborts: the skeleton is still written so the author has something to build once they install what they need. The command validates the name as a Rust package name, refuses a non-empty target directory (unless --force), accepts --path <dir> and --kind tool (only tool is supported in v1), and prints next-steps. The wit/ directory is intentionally not generated — it is produced at build time. Closes #912.

  • Grant-on-first-use: an ungranted tool call now elicits consent and, on approve, grants the capsule — instead of silently dropping the call and timing the caller out. Building on the per-principal capsule-access gate (#992/#993), an authenticated non-admin principal that invokes a capsule tool it does not yet hold previously had its call silent-dropped at dispatch, so the caller simply hung until its ~50s timeout with no signal. The dispatcher now, at that access-gate miss, publishes a new IpcPayload::GrantRequired { request_id, principal, capsule_id } on astrid.v1.approval (a distinct variant from ApprovalRequired, so a broker can tell a grant-on-use from a plain capability approval), carrying the kernel-stamped caller principal and the missing capsule id with an unguessable UUID correlation id — a synchronous fire-and-forget publish that adds no lock, .await, or blocking call to the dispatch hot path. A new kernel consent handler observes those signals on a single permanent astrid.v1.approval subscriber, and for each spawns a short-lived, timeout-bounded awaiter (with a fixed in-flight cap, fail-closed dropping at saturation) that subscribes to the per-request astrid.v1.approval.response.<id> topic; on an APPROVE response (the exact approve/approve_session/approve_always set the host approval flow uses) it grants the capsule to the principal by reusing the #993 admin grant machinery (load → set-delta → validate → save → cache-invalidate) under the same admin_write_lock, so a grant-on-use and a concurrent agent modify cannot race the same profile. Security: the grant target (principal, capsule_id) comes only from the kernel-observed GrantRequired (captured by value into the awaiter); the response payload conveys only a decision and is never read for a target, so the grantee is structurally un-spoofable. Response authenticity rides on the existing publish-ACL for astrid.v1.approval.response.* (only the uplink and broker may publish it; a tool capsule cannot forge an approve), consumed without a separate provenance check exactly as the host approval flow does. Every failure path — invalid principal, missing/unloadable profile, validation/save error, deny, unknown decision, timeout, uncorrelated response — is a fail-closed warn!(security_event = true) no-op that never creates a phantom principal or default-allows. The external consent shim/broker is out of scope here (kernel-side only). Refs #998.

Fixed

  • Rate-limited kernel management requests now receive their error reply on the response topic instead of timing out. When a management request (astrid.v1.request.<op>.<correlation>) tripped the ManagementRateLimiter, the kernel router derived the error's reply topic with a no-op replace("kernel.request.", "kernel.response.") — a substring that never appears in the real astrid.v1.request.* topics — so the Rate limited error was published back on the request topic and the client, waiting on astrid.v1.response.<op>.<correlation>, hung until its own timeout. The rate-limit path now uses the same astrid.v1.request.astrid.v1.response. derivation as the normal request path (extracted into a shared response_topic_for helper), so the rejection reaches the caller immediately. Kernel-internal, no WIT change. Found in PR review.

  • astrid init / astrid distro apply now enforce a distro's [distro].astrid-version floor against the running CLI before any prompting or install, instead of silently proceeding into a broken onboarding on an outdated client. A distro manifest declares a CLI-version requirement (e.g. astrid-version = ">=0.6.0"), but the CLI only validated it as well-formed semver (VersionReq::parse(...).is_err()) and never compared it to the running binary — so a manifest that bumped its floor, fetched from the repo main tip (raw.githubusercontent.com/.../main/Distro.toml), would pass validation and break the install mid-flight on an older CLI with a confusing error instead of an actionable one. run_init (the path both astrid init and astrid distro apply take) now calls a new enforce_astrid_version immediately after fetch_and_parse_manifest and before lock-freshness / select / install; if the running CLI (CARGO_PKG_VERSION, the same source astrid version prints) does not satisfy the floor it fails fast with This distro requires astrid {req}, but you are running {version}. Run \astrid update` to upgrade.. For a plain release floor the comparison strips the running version's prerelease/build metadata and matches on the release triple, so a locally-built / dev / prerelease CLI at or above the floor (e.g. 0.6.0-dev.3vs>=0.6.0) is **not** falsely rejected by the default semverprerelease rule, while a CLI whose release triple is genuinely below the floor still fails. When the requirement *itself* names a prerelease (e.g.>=0.6.0-rc.3) the triple-strip is dropped and the running version is compared as-is under exact semver semantics, so a lower prerelease of the same triple (0.6.0-rc.2) is correctly rejected rather than being lifted to 0.6.0and over-accepted. A manifest with noastrid-versionimposes no requirement, and a malformedastrid-versionremains a manifest error. Scope is the[distro].astrid-versionCLI gate only — the[distro.requires.astrid]` interface version reqs are untouched. CLI-internal, no WIT change. Closes #1023.

  • astrid-mcp compiles against rmcp 1.8.0, and rmcp is pinned to its patch range. rmcp 1.8.0 (released 2026-06-23) changed Peer::peer_info() to return Option<Arc> instead of Option<&InitializeResult>, which broke the ServerInfo::from_rmcp(&InitializeResult, ...) call in the MCP client and failed the whole workspace build. Because the repo intentionally commits no Cargo.lock, every PR's CI resolves the latest compatible deps.

  • Build-time tool capture no longer fails for pure-interceptor capsules (e.g. the broker), so they are marked captured instead of unknown. describe_capsule_tools treated a capsule with no #[astrid::tool] as "no tools" only on a NotSupported error, but a capsule that has other interceptors and no #[astrid::tool] — notably the sage-mcp broker itself — has its generated dispatch deny the unknown action with Deny("unknown hook action: tool_describe"), which surfaced as a hard error. The capsule then installed with meta.json tools: None (uncaptured). That broke the deterministic tool surface end-to-end: the broker's all-or-nothing static-surface check requires every loaded capsule captured (including the broker), so with the broker uncaptured the static path never fired and every daemon fell back to the describe fan-out. tool_describe now treats a Deny("unknown hook action") as "no tools" (Ok(vec![])), exactly like NotSupported; any other deny reason is still a genuine refusal and surfaces as an error. Caught by a live end-to-end run. Closes #985.

  • astrid mcp serve now fails loud instead of silently serving the MCP bridge as the no-capability anonymous identity. When the shim is asked to serve as a principal whose keypair is missing (keys/<principal>.key absent — e.g. a stale running binary that predates the signed handshake, or a principal that was never minted), the handshake falls to the legacy single-frame path and the daemon binds the connection anonymous. The bridge then came up "successfully" yet every tools/call failed the ingress-trust and capability checks and, to the client, simply hung/timed out with no diagnostic (this cost real debugging time tracing a governed-Claude tool timeout back to an anonymous-bound shim). SocketClient now records whether the handshake authenticated as the requested principal (took the signed path) versus fell to the legacy anonymous path (exposed as is_authenticated()), and astrid mcp serve refuses to start when a non-anonymous principal did not authenticate — emitting an actionable error (run astrid agent create <principal> to mint its keypair, or pass --principal anonymous to serve unauthenticated on purpose). Requesting anonymous explicitly still serves. CLI/uplink-internal, no WIT change. Closes #950.

  • Client-connection lifecycle events are now emitted by the host, not the capsule-cli proxy, fixing an unbounded per-principal connection-count leak. The kernel connection tracker keys its per-principal active_connections count off the principal stamped on client.v1.connect / client.v1.disconnect bus events (+1 / -1, driving the ephemeral idle-shutdown gate and astrid who). The proxy emitted both via ipc::publish-as, but the host publish-as deliberately derives the effective principal from the connection's handshake-verified identity (the anti-forge boundary, #45/#852) and clears that identity on close. The proxy's client.v1.disconnect fired after the connection — and its verified identity — was already gone, so the host stamped it the reserved no-capability anonymous: connect incremented the real principal, disconnect decremented anonymous (a saturating no-op), and the real principal's count leaked +1 per connection, unbounded. (This reproduced on current main and was not a capsule-cli version issue — the root cause is the emission site.) The emission moves into the host, which holds the verified principal for the whole connection lifetime: net.unix-listener.{accept,poll-accept} record the verified principal (the reserved anonymous for a legacy/unauthenticated peer, so the pair still balances) in a new shared client_connections registry keyed by the stream resource rep and emit client.v1.connect; the tcp-stream drop path emits client.v1.disconnect stamped with the same stored principal and removes the entry — so connect and disconnect for one connection always pair on the identical host-verified principal and the counter balances. Outbound TCP (connect-tcp) is never registered, so a capsule-dialed socket never moves the connection counter; only inbound uplink connections do. The emitted principal is always the host-verified one, never a guest/capsule-supplied value, preserving the anti-forge boundary. Requires the capsule-cli proxy to stop emitting the same events (capsule-cli#32) in lockstep, or connections double-count. Kernel/host-internal, no WIT change. Closes #948.

  • agent create now backfills a missing per-principal keypair onto an existing keyless principal, so upgraders auto-heal instead of staying stuck as anonymous. Per-connection auth (#45/#852) makes agent create mint+register a per-principal ed25519 keypair so the principal can sign the socket handshake and be bound to its own scoped identity; a principal with no keypair gets stamped the no-capability anonymous, which masks its furnished home and every capability-scoped tool. Principals created BEFORE that feature landed are keyless (auth.methods = [], auth.public_keys = [], no keys/<principal>.key), and agent create used to hard-error "already exists" on any existing profile — so an upgrader's pre-feature principal (e.g. the plugin's claude-code) never got a keypair and stayed permanently anonymous, even though the plugin's astrid-up re-runs agent create <p> on every boot. A bare agent create <existing-principal> now surgically backfills the missing credential: it loads the existing profile, and if it carries no ed25519 credential (no Keypair method and no ed25519: public key) mints the keypair via the same path a fresh create uses (private key to keys/<principal>.key 0600 in system custody, public key + Keypair method registered on the profile) and persists it — so astrid-up's per-boot re-run auto-heals upgraders. The backfill is security-positive and strictly additive: it never widens groups/grants and never touches network/process/quotas/home/state — it only adds the missing auth credential. A principal that already has a keypair is left untouched (still errors "already exists", never re-minted), and a re-create carrying any profile-shaping input (--clone/--inherit-from/--group/--grant) still errors rather than being silently dropped — a backfill is not a re-create. Kernel-internal, no WIT change. #45/#852.

  • Local per-connection principal authentication: the socket handshake cryptographically proves which principal a connection acts as, and the kernel now binds that verified identity to the connection's outbound traffic (kills the self-stamp). Each principal already gets a per-principal ed25519 keypair on agent create (private key written to keys/<principal>.key 0600, public key registered as ed25519:<hex> on the profile); this extends the kernel-side socket handshake to verify that key over a challenge-response, and records the verified identity per connection. The handshake gains an OPTIONAL second frame: when a client sets claimed_principal on its first request, the daemon replies with a random 32-byte challenge nonce, the client returns an ed25519 signature over astrid-principal-auth:v1:{principal}:{nonce_hex}, and the daemon verifies it against a key registered in that principal's AuthConfig.public_keys. A valid signature yields a verified PrincipalId; an invalid one fails the handshake closed (a bad signature is an attack, never a fallback to unauthenticated). A request WITHOUT claimed_principal completes in the legacy single round trip with no verified principal, so legacy clients and legacy daemons keep interoperating (the new wire fields are #[serde(default)], no WIT change — the handshake is kernel-internal plain Rust). The verified principal is stored in a per-connection registry on HostState (connection_principals, keyed by the stream resource rep, shared across the capsule's pooled instances and torn down when the stream drops). The uplink client signs the challenge automatically when keys/<principal>.key exists for the connecting principal, falling back to the single-frame flow otherwise. The bootstrap default principal now also gets a keypair minted at boot so the operator can authenticate as default. Enforcement (the self-stamp fix): when the uplink (capsule-cli) forwards a client frame to the bus via publish-as, the host now stamps the connection's verified principal in place of the capsule-supplied name — so a socket client can no longer name a principal it has not proven, which was the whole point of #45/#852. The framed tcp-stream.read that pulls a frame off a kernel-bound connection records that connection's verified principal on HostState (ingress_principal); the immediately-following publish-as consumes it (the uplink's run loop reads a frame then forwards it before reading the next, so the binding lives exactly as long as the in-flight frame, and a non-data read clears it). A connection with no kernel binding is stamped the reserved no-capability anonymous identity (see the Security entry "Unauthenticated socket connections..." below — this closes the unbound self-stamp); the operator and every agent authenticate via their auto-minted keypair, so they are bound and act as themselves. capsule-cli is unchanged (the host derives the principal from the connection, ignoring any capsule-supplied name), and there is no WIT change — the derivation is entirely host-side. This makes the crypto-authenticated client path enforce end-to-end. #45/#852.

  • Capability-token lifecycle management: human "Always Allow" is now permanent, plus admin verbs to mint / revoke / list mcp:// tokens. "Always Allow" previously minted a capability token with a hard 1-hour TTL — so a decision the user expressed as always silently expired after an hour and the agent re-prompted. handle_allow_always now mints a permanent token (ttl: None, valid until explicitly revoked); the bounded-grant case is served instead by the new admin mint --ttl. New admin verbs let an operator pre-grant tool access so an agent never hits a per-use approval elicitation: astrid caps token mint <principal> <resource> [--permission <p>] [--ttl <duration>], astrid caps token revoke <token-id>, and astrid caps token list <principal>. Tokens are signed by the runtime ed25519 key — the same key the approval interceptor's validator trusts as issuer — so a minted token authorizes immediately on the secure path and survives daemon restarts (persistent scope). To reach the signing key from the admin handlers without loading it from disk twice, the runtime key is now loaded once at boot, shared (Arc) with the audit log, and exposed as Kernel::runtime_key. Minted tokens are revocable (revocation is global and final), principal-scoped (carry the principal in the signed payload per #668 — a token minted for Alice never authorizes Bob), admin-gated (new caps:token:{mint,revoke,list} capabilities, covered by the admin group's *; a scoped agent principal must not hold them), and audited (every verb records an admin.caps.token.* audit entry through the existing admin-audit path). permission defaults to invoke; an unknown permission string or a non-existent principal is rejected with a bad-input error and no token is created. Tokens are kernel-internal — no WIT change, no RFC. Closes #929.

  • astrid agent create --clone <source> replicates an existing agent's full profile and state in one atomic admin call. --inherit-from copies state only (env/KV/secrets) and deliberately leaves the capability profile empty, so there was no first-class way to replicate an agent — duplicating one meant rebuilding its groups, grants, revokes, egress, process allow-list, and quotas by hand via caps grant / agent modify / quota set. AgentCreate gains clone_from: Option<PrincipalId> and allow_admin_clone: bool (both #[serde(default)], so older serialized requests deserialize unchanged). When clone_from is set, the kernel builds the new principal as a full replica of the source — groups + grants + revokes + network egress + process-spawn allow-list + quotas — and then performs the same env/KV/secret state copy inherit_from does. Deliberately NOT copied: the source's auth (each principal keeps its own keys/authenticators — cloning is profile+state, never credentials) and its enabled flag (a fresh clone is always enabled, even if the source was disabled). --clone is mutually exclusive with the profile/quota-shaping flags (--group, --egress, --process-allow, --inherit-from, --memory/--timeout/--storage/--processes): the CLI enforces this via clap conflicts_with and the kernel rejects a request that sets both — defense in depth against a hand-built request. Admin-source guard: cloning a source that resolves to the universal * (the built-in admin group, a * grant, or a custom unsafe_admin group — resolved through the live GroupConfig, not a literal scan) is rejected unless the operator passes --unsafe-admin, mirroring the acknowledgement on caps grant '*' and group create --caps '*' — so --clone default cannot silently mint a second admin. The source is validated to exist and not be the new principal itself before any write runs. The HTTP management API constructs clone_from: None (an API-level clone is a follow-up). Closes #927.

  • Gateway POST /api/capsules now resolves GitHub sources (@org/repo, github.com/org/repo, https://github.com/org/repo) to a local archive in the uplink before install; the daemon still never fetches URLs. Previously the gateway forwarded source verbatim to the kernel's path-only InstallCapsule handler, which rejects every remote-shaped source — so an integrator that only speaks gateway HTTP could not install a registry/GitHub capsule even though the CLI could (the CLI resolves GitHub sources client-side and hands the kernel a local path). The gateway, an uplink like the CLI, now does the same: for a GitHub-shaped source it fetches the repo's latest release, selects the matching .capsule asset (the lone one, or the one named via a new optional capsule request field for a multi-capsule release), streams it to a temp file with a hard 50 MB cap, and calls the existing InstallCapsule with that LOCAL path (workspace forced false). The kernel install path is unchanged and still rejects URLs — the fetch lives ONLY in the gateway. The five pure GitHub source-parsing/asset-selection helpers move into astrid-capsule-install::github_source (a network-free module the CLI and gateway share); the kernel and the install crate gain no network dependency. The gateway implements a narrower path than the CLI on purpose: no clone-and-build fallback, no local-Cargo auto-build, no multi-asset "install all" — it requires resolving to exactly one .capsule. New 404 (no release / no .capsule asset) and 400 (ambiguous multi-capsule release, or archive over 50 MB) responses. Closes #960.

  • The astrid mcp serve shim now elicits interactive user consent when the sage-mcp broker reports an untrusted ingress, replacing the static operator allow-list. Previously the broker gated its state-mutating tools/call front door on an operator-maintained trusted_ingress_ids list; an ingress that was not pre-pinned was hard-denied. The broker now replies an ingress_approval_required signal for a not-yet-trusted ingress, and the shim turns it into a user consent prompt via the MCP elicitation capability ("Allow Astrid tool calls from this session?"). On accept the shim forwards astrid.v1.request.mcp.ingress.respond { req_id, accept }, the broker records trust keyed on the kernel-stamped caller source_id (never a body field), and the shim re-sends the original call. Fail-secure: a client without elicitation, a decline / cancel, an elicit error, or a broker that could not persist the grant all return an MCP error to the client and record no trust. Manual end-to-end verification of the live elicit flow is pending (needs an elicitation-capable client, Claude Code ≥2.1.76). Paired with the sage-mcp broker change. Closes #917.

Removed

  • Build-time tool baking (tools.json) is removed — it is superseded by the kernel's load-time describe (#987), which needs no static artifact and no rebuild. With every capsule's tools now reaching a consumer via the live tool_describe probe at load, the build-time machinery was redundant. Removed: astrid capsule build instantiating the compiled component to capture descriptors and inject a tools.json into the archive (it reverts to plain compile + pack — no wasmtime, no integrity-meta staging, no MCP-hybrid guard); the build-time describe_capsule_tools and its ephemeral-instantiation helpers (the shared describe_loaded_capsule the kernel uses is kept); CapsuleMeta.tools and the install tools.json read; astrid capsule show's tool listing; the kernel's now-vestigial baked-tools check (it always probes); and the build-only astrid-audit dependency on astrid-capsule. The one capability given up — offline / pre-install tool inspection (listing a capsule's tools without a running daemon) — is deferrable and re-derivable later if the consent/disclosure plane needs it. Verified live: with no tools.json produced or installed anywhere, a daemon still serves a tool capsule's full surface via the broker's static path. Closes #989.

Changed

  • Principal profile field validation now uses typed domain wrappers internally while preserving the public string-shaped profile API. GroupName, CapabilityPattern, and CapsuleGrant distinguish group memberships, direct capability patterns, and capsule grants at validation and authorization edges, with a borrowed ValidatedProfileFields view for policy checks. PrincipalProfile remains TOML/JSON/admin-API compatible (Vec<String> fields), so callers keep the existing wire shape while kernel/admin paths validate incoming strings before persistence and capability checks fail closed on malformed profile fields. Closes #1033.

  • IPC topics are now a typed Topic newtype with per-family constructors, replacing scattered format! strings and duplicated const *_TOPIC/*_PREFIX literals. astrid_types::Topic is a #[serde(transparent)] newtype over String (so the JSON shape and the guest string ABI are byte-identical to before — no wire-format change, no WIT change, no version bump). IpcMessage.topic and SelectionRequired.callback_topic are now Topic; IpcMessage::new takes impl Into<Topic>. Typed constructors (Topic::elicit_response, approval_response, kernel_request/kernel_response, admin_request/admin_response, reload_capsule_request/reload_capsule_response, session_response, cli_command_run/cli_command_result, user_prompt, agent_response, client_connect/client_disconnect, audit_entry, …) are the single source of truth for each family's exact wire string; a from_raw escape hatch covers the WIT bindgen boundary and genuinely dynamic topics. Construction sites across the kernel router, uplink clients, capsule host fns, gateway, CLI, and astrid-emit were migrated, and the duplicated topic-prefix consts (REQUEST_PREFIX, RESPONSE_PREFIX, ADMIN_INPUT_PREFIX/ADMIN_RESPONSE_PREFIX, APPROVAL_TOPIC, CLIENT_*_TOPIC, the mcp shim's RESPONSE_PREFIX) were collapsed onto the constructors. Read-side match/subscribe-pattern literals (including wildcard patterns and the deliberately capsule-local AUDIT_TOPIC pinning anchor) are unchanged. Unit tests pin every constructor's output and prove serde transparency, guarding the byte-identical-on-the-wire invariant at build time. Internal refactor only.

  • Group config validation now uses typed domain wrappers internally while preserving the public string-shaped groups API. GroupConfig and Group remain TOML/JSON/admin-API compatible (HashMap<String, Group> and Vec<String> fields), but load, mutation, and capability-check edges now convert group names and group capability entries into borrowed GroupName / CapabilityPattern views. This rejects invalid quoted group keys such as groups."ops/team" before persistence and makes group inheritance fail closed if an invalid in-memory config is constructed manually. Closes #1043.

  • The deterministic tool surface is now automatic — the kernel describes any un-baked capsule at load, so no rebuild is needed to retire the describe fan-out. Build-time tool baking (#978) only takes effect once a capsule is rebuilt and re-released; until then it is uncaptured and the broker falls back to the racy fan-out, so activation was gated on a fleet rebuild. Now publish_capsules_loaded probes each loaded capsule once — invoking its existing tool_describe interceptor (the same hook the dispatcher already routes) and injecting the captured descriptors into the capsules_loaded payload. The kernel invokes-and-forwards — it never interprets the descriptors; the broker still owns all policy. A describe failure leaves tools absent for that capsule this cycle (the consumer falls back to its fan-out). Net: every loaded capsule — including un-rebuilt and third-party ones — contributes a complete, deterministic tool surface from the signal the broker already consumes, with no rebuild, no SDK change, and no broker change. This shifts the kernel from a pure opaque-forward (#980) to a probe-at-load (PCI/driver-probe-style: invoke a known hook, forward the opaque result). (With this in place, the build-time tools.json baking became redundant and is removed — see the Removed entry above.) Closes #987.

  • A capsule's baked tool surface now records whether it was captured at build, distinguishing "authoritatively no tools" from "not captured yet". #978 baked tool descriptors into meta.json, but an empty tools was ambiguous — a capsule that genuinely has no tools (the cli socket-owner, run-loop capsules) looked identical to one built before tool-baking. A consumer (the sage-mcp broker, next) can't retire the runtime describe fan-out without telling these apart, or it would silently drop tools from any un-rebuilt capsule. CapsuleMeta.tools is now Option<Vec<ToolDescriptor>>: None = surface not captured (discover at runtime), Some([]) = captured and authoritatively no tools, Some([..]) = the captured tools. astrid capsule build writes tools.json whenever the WASM capture succeeds — even for a zero-tool capsule, so the file's presence is the marker — except when the capsule declares an [[mcp_server]] (its tools come from the MCP engine, not #[astrid::tool], so the WASM capture is incomplete and it is left unmarked → None → fan-out). Install reads tools.json into Some (absent/corrupt → None); astrid capsule show now distinguishes "not captured at build" from "captured, no tools". Backward-compatible (old meta.json with no tools key → None). Closes #983.

  • The astrid.v1.capsules_loaded broadcast now carries each loaded capsule's installed metadata, so a sandboxed consumer can derive a deterministic tool surface from the change signal instead of a racy describe fan-out. A WASM-sandboxed capsule (e.g. the sage-mcp broker) has no channel to learn the loaded-capsule set or its tools from the kernel — ListCapsules/GetCapsuleMetadata are CLI-only KernelRequests and it cannot read the install dir's meta.json from inside the sandbox — so tool discovery has had to go through a tool.v1.request.describe broadcast that races a timeout (the first-prompt-after-boot non-determinism). Now that tool descriptors are baked into each capsule's meta.json at build time (#978), the kernel surfaces that static surface over the signal consumers already receive: the capsules_loaded payload keeps status: "ready" (so existing subscribers — the astrid mcp serve shim, the TUI — that treat it as a bare signal are unaffected) and additively gains capsules: [{ name, meta }], where meta is the capsule's meta.json forwarded as an opaque JSON value. The kernel does not parse or interpret that metadata (no tool awareness, no typed tool field on any kernel type) — it surfaces metadata about the capsules it owns, the way a Linux uevent carries a device's attributes and leaves all interpretation to userspace; the consumer extracts whatever it needs. This gives a deterministic tool source without the kernel gaining tool knowledge and without widening the consumer's own capabilities. The per-capsule meta.json is read off the registry lock and is best-effort (a missing/unreadable/non-JSON file contributes a null meta and never blocks the broadcast). Kernel-internal (the event payload is opaque RawJson); no WIT change, no RFC. Foundation for the sage-mcp broker switch that retires the fan-out. Closes #980.

  • astrid capsule remove now unloads the capsule from a running daemon — a removal takes effect live, no restart needed. Install (#958) and upgrade (#968) already went live, but remove was disk-only: it deleted the capsule from disk yet left it registered in the running kernel, so the removed capsule kept receiving events and its tools lingered in the astrid mcp serve tool surface (callable, erroring) until a restart. A new targeted KernelRequest::UnloadCapsule { id } unregisters a single capsule from the running registry and explicitly unloads its WASM component (there is no async Drop, so this is done eagerly to avoid leaking the component and any MCP subprocesses), then publishes astrid.v1.capsules_loaded so the shim's MCP notifications/tools/list_changed fires and a connected agent sees the tools disappear live. astrid capsule remove sends this best-effort after its existing removal: the on-disk removal stays authoritative and dependency-checked (the sole-provider guard is unchanged) and the live unload only mirrors it into the running daemon — non-fatal as with install/upgrade (no reachable daemon is silent; a capsule that was never loaded live is a quiet no-op rather than a false "unloaded"; a declined or timed-out unload only prints a note; the remove's exit status is never affected). The verb is gated behind a new, distinct capsule:remove capability — removal is destructive and globally-scoped, so it is separately grantable/revocable rather than reusing capsule:reload; it is held by the admin group via * and deliberately not granted to the agent group (unlike the restorative self:capsule:reload). The kernel verb is dependency-blind by design — the CLI runs the sole-provider check before nudging, and kernel-side dep-safety is deferred to any future direct/recursive caller. The client-supplied id is validated (CapsuleId::new) before it is used as a registry key, the call is rate-limited, and it is kernel-internal (no WIT change). Paired with the sage-mcp broker cache-eviction so a removed (or upgraded) capsule's tools leave the surface immediately instead of lingering for the descriptor-cache TTL. Closes #970.

  • astrid capsule install / update now hot-swap an already-loaded capsule in a running daemon — a reinstall or update goes live without a restart, not just a fresh add. #959 hot-added a freshly-installed capsule via a best-effort broadcast ReloadCapsules, but load_all_capsules skips already-registered capsules, so iterating on a capsule (rebuild → reinstall) or astrid capsule update still needed a restart to take effect. A new targeted KernelRequest::ReloadCapsule { id } resolves as add-or-restart: a registered capsule is hot-swapped to the new on-disk bytes (Kernel::restart_capsule unregisters and re-loads from the source dir, and the loader reads the content-addressed WASM by the hash a reinstall has bumped — so unregister-then-reload picks up the new bytes), while an unregistered one is loaded fresh. The install nudge now targets this verb per just-installed capsule id (superseding the broadcast) and moves into install_capsule, so the update and TUI install paths inherit live hot-swap too. Either branch publishes astrid.v1.capsules_loaded, so the astrid mcp serve shim's MCP notifications/tools/list_changed fires and a connected agent sees the change live. Best-effort and non-fatal as before — no reachable daemon leaves the standalone install untouched (the capsule loads on the next daemon start), a declined or timed-out reload only prints a note, and the install's exit status is never affected. ReloadCapsule is capability-gated (capsule:reload, the same capability as ReloadCapsules, so no new authority), rate-limited, and kernel-internal (no WIT change). Live remove (unload) lands in #970. Closes #968.

  • astrid capsule install now hot-loads the new capsule into a running daemon, so a freshly-installed capsule is usable without a restart. The on-disk install is deliberately standalone and daemon-independent (it must work with no daemon running) and stays that way — but it never told a live daemon about the new capsule, so the capsule's tools weren't available until the daemon was restarted (friction for the build-a-capsule → use-it loop). After a successful install the CLI now sends a best-effort KernelRequest::ReloadCapsules to a reachable daemon: the daemon re-scans the install dir and loads the newly-present capsule, and the resulting astrid.v1.capsules_loaded is already turned by the astrid mcp serve shim into an MCP notifications/tools/list_changed, so a connected agent sees the new tools live. The nudge is best-effort and non-fatal — no daemon (socket absent or unreachable) leaves the standalone install untouched (the capsule loads on the next daemon start), and a daemon that declines or times out only prints a note; the install's success and exit status are never affected. It is wired into the manual astrid capsule install dispatch arm only (not update, remove, the distro-batch install, or the TUI, which has its own /refresh), and ReloadCapsules stays capability-gated (capsule:reload) kernel-side, so this adds no new authority. Scope: loads a newly-installed capsule — re-installing a capsule that is already loaded (same id) is skipped by the loader's already-registered guard, so swapping an already-loaded capsule's code still needs a restart (in-place live upgrade is a separate, later change). CLI-only, no WIT change. Closes #958.

  • Extracted the inline #[cfg(test)] mod tests blocks from dirs.rs, secret.rs, kernel_router/mod.rs, manifest_gate.rs, and policy.rs into sibling test files to keep each production file under the 1000-line file-size cap; pure mechanical move, no behaviour change. Closes #956.

  • CI Test job strips debug info so linking the astrid-capsule test binary no longer exhausts the runner. The astrid-capsule test binary statically links the entire dependency tree (wasmtime, aws-lc, ring, surrealkv, …); after a GitHub ubuntu-latest runner-image rotation the final link began dying with collect2: fatal error: ld terminated with signal 7 [Bus error] (an OOM/disk-pressure failure), turning every Test (ubuntu-latest) run red and cancelling macos-latest via matrix fail-fast — independent of the change under test (it reproduced on a main commit that touched neither capsule code). The Test job now sets CARGO_PROFILE_DEV_DEBUG=0 / CARGO_PROFILE_TEST_DEBUG=0, dropping debug info (the dominant contributor to both linker peak memory and target/ size) for the test build only. CI-scoped via Cargo profile env overrides — local and dev builds keep full debug info. Closes #954.

  • Manual astrid capsule install @org/repo against a multi-capsule release now installs every capsule the release ships by default, with --capsule <name> to pick one. #921/#922 threaded the distro capsule name into pick_capsule so a monorepo release could select the right archive, but left the manual install path with no selector: a release shipping several .capsule assets passed no hint and hit pick_capsule's "no capsule name to pick one" hard error, so there was no way to install such a release by hand at all. The manual path now defaults to installing all capsules in the release — install_from_github collects every .capsule asset (via a new pure capsule_assets helper over the release JSON) and, when no selector is given, downloads and unpacks each one. Installation is best-effort: it prints Installing <name>... per archive, reports any that fail and keeps going, then returns an error naming all failures if any did — never silently swallowing a failed install. A new --capsule <NAME> flag opts back into single-capsule selection (<name>.capsule via the unchanged pick_capsule), and a single-asset release installs that one capsule exactly as before. The distro path (install_capsule_batch) is unchanged — it keeps selecting by the distro capsule name. The update path now passes the capsule being updated as the selector so a monorepo source refreshes only that one capsule, not the whole release. The clone+build fallback (no release assets) stays single-capsule; making astrid-build handle a virtual-workspace root is a separate gap. Closes #925.

  • Guest ERROR-level logs now surface in the daemon log, not only the per-capsule log file. When a run-loop capsule's run() returns Err before signaling ready, the daemon log showed only a contextless Capsule run loop exited before signaling ready — for the sole-socket-owner uplink (the cli proxy) that takes the whole daemon down, and diagnosing it meant hours of guessing. The reason was never actually lost: the SDK #[astrid::run] macro logs run loop exited with error: {e} at ERROR level before returning, but sys::log writes guest logs to a per-capsule file (effective_capsule_log()) and only fell back to the daemon's tracing subscriber when there was no per-capsule file — so the reason landed in a separate file operators don't check during a crash. ERROR-level guest logs are now emitted to the daemon's tracing subscriber in addition to the per-capsule file (lower levels stay file-only when captured, preserving the daemon log's signal-to-noise). The silent run-loop crash is now a one-line diagnosis (ERROR …host::sys: run loop exited with error: … plugin=<capsule>) sitting right next to the kernel's message. No ABI/WIT change. Refs #884.

  • Publish/subscribe ACL wildcard matching now uses the route-layer subtree semantics — a declared trailing * covers the whole namespace. ACL authorization used the strict topic::topic_matches (a * matches exactly one segment) while event delivery uses astrid_events::TopicMatcher (a trailing * is a subtree wildcard matching one or more segments at any depth). That divergence forced capsule manifests to enumerate wildcard depth (astrid.v1.admin.* / *.* / *.*.*) merely to authorize publishing topics whose depth varies or is unknown (uuid suffixes, variable sub-paths) — the same matcher asymmetry behind the cli run-loop subscribe confusion, now on the publish side. The two ACL checks (publish_inner, check_subscribe_acl) now authorize via a single allocation-free astrid_events::topic_pattern_matches — extracted as the one source of truth now used by routed delivery (TopicMatcher), broadcast delivery (EventReceiver), and ACL authorization alike — so a declared astrid.v1.admin.* authorizes every admin topic at any depth and the three paths can never silently diverge again (this also folds away a duplicated matcher and fixes a latent equal-segment-count bug in the broadcast path's hand-rolled iterator form). Scope is the two ACL sites only — interceptor dispatch keeps strict matching, and the runtime "wildcard must be terminal" subscribe gate is unchanged. The change is permissive (authorizes more, denies nothing previously allowed; delivery was already subtree); breadth is the operator's decision at install, not something the matcher enforces by forcing enumeration. Refs #882.

  • The astrid:process WIT now documents the id-keyed persistent write-stdin / close-stdin as implemented. These landed in #867 — the persistent-process registry retains the child's stdin pipe host-side across pooled-instance resets (1 MiB-capped, backpressured, ownership-checked, audited) — but the WIT still tagged them (NOT YET IMPLEMENTED) and carried two stale PERSISTENT TIER "stubbed until the registry lands" banners. The tags are dropped and the banners corrected; function signatures are unchanged (doc-only). The ephemeral ProcessHandle form, attach, and watch / unwatch remain genuinely deferred and stay tagged. An acceptance test now locks the id-keyed path: deliver bytes → a by-id re-write (needing only registry + id, exactly what a post-reset instance holds) reaches the same child → close-stdin yields a clean EOF exit → over-cap returns too-large → wrong-owner returns no-such-process → post-close returns closed. Refs #870.

  • Host-call concurrency is split into separate blocking vs async-I/O semaphores — the LLM-path throughput gate. A single host_semaphore sized at cores - 2 previously gated every host call: both the blocking ones that block_in_place + block_on and pin a tokio worker for the whole permit-held wait (KV, identity, sys, fs, the net/process security gates, DNS, sockets) and the async-I/O ones that .await real I/O and free the worker (HTTP request/stream, ipc::recv). The cores - 2 cap is right for the blocking class — it must not approach the worker-pool size or blocking host work starves the scheduler — but it throttles outbound I/O far below what the host sustains, capping the LLM/HTTP path. HostState now carries two gates: blocking_semaphore (host-derived cores - 2, the same ceiling — but no longer contended by I/O calls, so strictly more headroom) and io_semaphore (host-derived, cores-scaled and clamped by half the process RLIMIT_NOFILE — floored at 1, so on an fd-scarce host the descriptor budget wins and the ceiling can fall below the IO_MIN floor, preventing EMFILE — since each in-flight async call may hold a socket fd). The four generic bounded_* host helpers are unchanged; each call site passes the semaphore matching its class (the block_on helpers take blocking, the await helpers take I/O). Net stays on the blocking semaphore for now — reclassifying its socket/accept/DNS paths, which do real I/O but currently use the blocking block_on helpers, to the async semaphore is a follow-up. Refs #816.

  • Per-Store memory limiting now also meters per-principal peak usage. The plain wasmtime::StoreLimits that capped each capsule instance's linear memory is replaced by a StoreMemoryMeter that keeps the same enforcement (the per-invocation max_memory_bytes ceiling) and records, into a kernel-owned shared MemoryLedger, the high-water linear-memory size each invoking principal grows a Store to — the RAM analogue of the per-principal FuelLedger. It is keyed by the same invoking principal (caller → owner → default) the fuel ledger charges, and re-targeted per invocation since a pooled Store is leased across principals. Sharded + atomic like the fuel ledger, so recording stays off the lock — and unlike the fuel ledger it is bounded (capped at a max principal count, evicting the lowest-peak entry when full) so a flood of ephemeral sub-agent principals cannot grow it without limit (astrid#827). Telemetry only — no deny path. Refs #816.

  • The per-capsule instance pool is now dynamic and host-sized — the fixed INSTANCE_POOL_SIZE = 16 is gone. Each non-run-loop capsule used to eagerly instantiate exactly 16 (Store, Instance) pairs at load regardless of machine size or actual concurrency — 16× the linear-memory footprint up front even for an idle capsule, and a hard concurrency ceiling of 16 on a large host. The pool now warm-starts at a small min_idle, grows lazily (a checkout that finds no warm instance mints a fresh one while holding a permit, so the total never exceeds the max) toward a host-derived max (cores-scaled, replacing the magic 16), and an idle-eviction timer trims the warm set back down to min_idle after a burst subsides, reclaiming the memory of instances built under load. Net effect: less resting memory for idle capsules, more peak concurrency on big hosts. The max is operator-overridable via [capsule].instance_pool_size, ASTRID_CAPSULE_INSTANCE_POOL_SIZE, or astrid-daemon --instance-pool-size. Run-loop and host_process capsules stay pinned to a single Store — the host_process carve-out can never lease a second one (enforced by allow_grow = false, belt-and-suspenders over its always-warm single instance). Free-checkout soundness is unchanged: a lazily-grown instance is built by the same HostState factory as an eager one and reset identically on return. A RAM-budget-derived max lands with the per-principal memory ledger. Refs #816.

  • Per-principal interceptor CPU is now summed cross-capsule into one kernel-owned FuelLedger. Each capsule's WasmEngine previously kept its own per-principal fuel HashMap, fragmenting a principal that drove N capsules into N independent sub-totals; the ledger is now a single kernel-owned Arc<DashMap<PrincipalId, AtomicU64>> cloned into every engine, summing a principal's interceptor CPU across all capsules. Sharded +