# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Changelog tracking starts with 0.2.0. Prior versions were not tracked.

## [Unreleased]

## [0.8.0] - 2026-06-10

A large release — roughly fifty PRs since v0.7.0 — landing three bodies of work plus a security-hardening pass:

- **HTTP admin gateway.** The new `astrid-gateway` crate fronts the kernel admin/request IPC surfaces over HTTP: principals, caps, quotas, groups, invites, per-principal capsule env, audit SSE + historical audit queries, agent-prompt SSE, OpenAPI spec emission, native rustls TLS, CORS, Prometheus metrics — with a bus-direct admin path (285× admin throughput) and invite/keypair CLI verbs for operator parity.
- **Runtime concurrency overhaul.** The orchestration concurrency cliff is closed end-to-end: per-(capsule, topic, principal) routed IPC with DRR fairness and byte budgets, async Wasmtime (guest calls no longer pin tokio workers), dynamic host-sized per-capsule instance pools, split blocking/async-I/O host-call semaphores, and per-principal CPU-fuel + peak-memory ledgers with rate enforcement and usage reporting.
- **Host process + introspection surface.** The `astrid:process@1.0.0` persistent-process tier is implemented (background children that outlive the pooled WASM instance, id-keyed reattach/stdin/log-cursor ops, operator `allow_persistent` opt-in), capability introspection lands (`enumerate-capabilities` + a completed `check-capsule-capability`), `astrid mcp serve` exposes capsule tools + capability consent to any MCP client, and `astrid-emit` gives agent hook processes an agent-agnostic stdio→bus pipe.
- **Security.** The macOS 15+ native-subprocess sandbox is no longer silently disabled; capsule audit-feed subscriptions are principal-scoped by default; failed token redeems now audit as failures; `self:agent:list` no longer leaks the principal roster; both unauthenticated redeem routes share per-IP rate limiting; bearers gain revocation-on-principal-delete (wire format v2 — in-flight v0.7.0 bearers must re-redeem).

Breaking changes to note: `Capsule.toml` moves to `[publish]` / `[subscribe]` tables as the only IPC-intent surface (`[[interceptor]]`, the `ipc_publish` / `ipc_subscribe` arrays, and `[[topic]]` are removed), the bearer wire format is v2, MSRV is 1.95, and the `astrid-openclaw` build path is removed.

### Breaking

- **`Capsule.toml`: the `[[interceptor]]` block is removed — interceptor bindings are now `[subscribe]` entries with a `handler` and an optional `priority`.** A capsule used to bind an interceptor either via a `[[interceptor]]` block (`event` + `action` + optional `priority`) or a `[subscribe]` entry with a `handler` — the two overlapped, and only the legacy block could set a `priority` (the `[subscribe]` form hardcoded the default 100). `[subscribe]` now carries an optional `priority` (lower fires first; default 100; a `priority` on a handler-less ACL-only entry is rejected at parse time), making it the single interceptor-binding form, and `[[interceptor]]` is gone. A manifest still declaring `[[interceptor]]` no longer binds those handlers — the block is ignored. Migrate each block to a `[subscribe]` entry: `"<event>" = { wit = "<typed-payload>", handler = "<action>", priority = <n> }`. Note `[subscribe]` requires a typed `wit` payload reference that `[[interceptor]]` did not, so each migrated binding declares its payload type (typed IPC everywhere). Closes #858.
- **`Capsule.toml`: the `capabilities.ipc_publish` / `ipc_subscribe` string arrays are removed — `[publish]` / `[subscribe]` tables are the only IPC ACL.** The tables already superseded the arrays (their keys are the ACL when present); the arrays are now gone, so `[publish]` / `[subscribe]` is the single way to declare what a capsule may publish or subscribe to (an empty table = may not publish/subscribe, fail-closed). A capsule still declaring `ipc_publish = [...]` / `ipc_subscribe = [...]` under `[capabilities]` no longer grants any IPC ACL — the unknown keys are ignored. Migrate each pattern to a table entry: a publish pattern becomes `[publish] "<topic>" = { wit = "<typed-payload>" }`; an ACL-only subscribe pattern (no handler, e.g. an uplink proxy) becomes `[subscribe] "<topic>" = { wit = "opaque" }`. Closes #864.
- **`Capsule.toml`: the `[[topic]]` table is removed; topic schemas are sourced from the `[publish]` / `[subscribe]` `wit` refs.** The `[[topic]]` block let a capsule self-describe a topic with an inline JSON Schema file, baked into `meta.json` at install. That self-description (flaky to keep in sync) is dropped in favour of the typed `wit` payload ref already on every `[publish]` / `[subscribe]` entry: the A2UI schema catalog (`SchemaCatalog`) now registers each topic from those tables and records its `wit` ref for the A2UI bridge to resolve to a schema + description via the WIT registry. Removed: the `[[topic]]` table (`TopicDef` / `TopicDirection`), the install-time `bake_topics` path, the `meta.topics` / `BakedTopic` install metadata, and the `astrid capsule list` baked-topic display. No capsule declared `[[topic]]`, so there is nothing to migrate. Closes #865.
- **Bearer wire format bumped to v2 (4 segments, was 3).** The token now carries an `iat` (issued-at epoch) claim alongside `principal` and `exp`. Required by the new revocation machinery: without `iat` the only revocation semantics available would be "blanket reject forever," which surprises an operator who later re-creates a principal with the same id. Dashboard sessions issued by the v0.7.0 gateway no longer verify after upgrade — clients must re-redeem. CLI `astrid invite redeem`, the existing pair-device flow, and every browser session that goes through `/api/auth/redeem` mint the new shape automatically; only pre-existing in-flight bearers are affected. Format spec: `b64url(principal) "." b64url(iat) "." b64url(exp) "." hex(sig)`, with sig over `principal:iat:exp`. Closes #772.
- **MSRV bumped to 1.95.0.** `surrealdb 3.0.0-beta.3`'s `kv-mem` feature pulled in `surrealmx v0.21.0` → `ferntree v0.7.0`, which uses `std::hint::cold_path` stabilised in Rust 1.95. Upstream declared no `rust-version`, so cargo's resolver silently picks 0.7 even though the workspace MSRV says 1.94. Bumping our MSRV is the smallest fix that keeps CI deterministic without committing `Cargo.lock` (which is intentionally gitignored). Affects `cargo install astrid` consumers — installers on 1.94 will see a clear "requires rustc 1.95" error rather than the cryptic `cold_path` failure.

### Added

- **`astrid mcp serve` — an MCP *server* surface exposing Astrid capsule tools + capability consent to any MCP client.** Astrid already had an MCP *client* (`astrid-mcp` manages external tool servers) and a tool broker (`sage-mcp` discovers capsule tools and shapes MCP descriptors), but no way for an MCP client — the managed `claude -p`, or Codex/Gemini/any client — to *consume* Astrid's capsule tools over the standard MCP wire protocol. `astrid mcp serve` (a **subcommand of the `astrid` CLI**, not a separate binary) is a thin rmcp stdio `ServerHandler` that delegates to the existing `sage-mcp` broker over the already-allowlisted `astrid.v1.request.mcp.*` / `astrid.v1.response.<req_id>` topics — the crypto/audit/enforcement stay in Astrid; the shim only terminates the MCP wire protocol. `get_info` advertises tools + `tools.list_changed`; `list_tools` / `call_tool` publish on an uplink and await the single-segment reply (one-response invariant, so the shim never hangs); the calling principal is stamped from `--principal` (default = active/default principal). Capability approvals are relayed into the client's own UI via MCP elicitation (`ctx.peer.elicit::<ApprovalChoice>{ApproveOnce,ApproveSession,ApproveAlways,Deny}`, gated on the client advertising the elicitation capability; decline/cancel → Deny; never elicits secrets). Hot-reload subscribes `astrid.v1.capsules_loaded`, re-enumerates, diffs, and `peer.notify_tool_list_changed()` (no-op notifications suppressed). `mcp serve` owns stdout for the JSON-RPC stream, so logging is forced off stdout (to file, else stderr) for this command regardless of operator config, so a stray frame cannot corrupt the protocol stream. This is the foundation for registering a named `sage` server in the managed `claude -p` (so `mcp__sage__*` resolves natively and `mcp_tool` hooks / channels can bind by name) and for the agent-neutral backplane. rmcp `0.15 → 1.7.0` workspace-wide; the existing `astrid-mcp` client (`ClientHandler` / `RoleClient`) is migrated to the 1.7 API. No kernel / WIT / allowlist change. Closes #879.
- **`astrid:process@1.0.0` persistent-process tier — implemented.** A capsule can now spawn a background child that **outlives the pooled, stateless WASM instance** that started it. Previously an ephemeral `process-handle` is reaped when its instance resets on return to the dynamic pool, so a process started in one tool invocation could not survive to the next — the split `spawn → read → stop` pattern was impossible. A new host-owned `PersistentProcessRegistry` — cloned into every pooled `HostState` exactly like the cancellation `ProcessTracker`, so a `process-id` survives instance churn — owns the child (spawned on the daemon runtime under the same `bwrap`/Seatbelt sandbox as the ephemeral tier), its per-stream log rings, and its stdin pipe. **Implemented:** `spawn-persistent` (returns a 256-bit host-minted CSPRNG `process-id`, lowercase base32 so it doubles as an IPC topic suffix; the registry stores only a keyed BLAKE3 hash, never the raw token), `status` / `status-many` / `list-processes`, `read-logs` (drain) + `read-since` (non-draining, cursor-addressed, byte-faithful `list<u8>`), `signal` (incl. `stop`/`cont`), bounded `wait`, `stop` (SIGTERM→grace→SIGKILL, frees the slot), `release-process`, and `write-stdin` / `close-stdin` (via `keep-stdin-open` capture). Every id-keyed call re-resolves the live `(principal, capsule)` and checks it against the recorded creator, so a leaked id is inert across the principal/capsule boundary — unknown / wrong-owner / wrong-capsule / reaped all collapse to `no-such-process` with no oracle; `spawn-persistent` refuses the owner-fallback principal (`persist-unsupported`) so tenants never share a `default` namespace. Lifecycle is enforced by a per-capsule reaper task: per-principal concurrent + retained-id caps, idle / max-lifetime / exit-retention TTLs (guest values clamped DOWN to host ceilings), and a kill-all on capsule unload / daemon graceful shutdown. Works on Linux and macOS (the macOS caveat — a daemon *hard crash*, not a graceful shutdown, can orphan a still-sandboxed child because Seatbelt has no `die-with-parent` — is a weaker cleanup guarantee, not a containment gap). **Still deferred, honestly:** `attach` (the resource-handle composition sugar; the id-keyed ops are its documented `attach(id)?.method()` equivalent), `watch` / `unwatch` (host-published lifecycle events — an OPEN publish-authority question in RFC host_abi, with `status` + bounded `wait` polling as the working alternative), and the WIT's own `(NOT YET …)` items (resource-limit enforcement, `cpu-ms` / `mem-bytes-peak`, instance-local pollables). Contract: unicity-astrid/wit#12. Design: unicity-astrid/rfcs#22. Closes #866.
- **`astrid:sys@1.0.0` capability introspection — `enumerate-capabilities` implemented, `check-capsule-capability` completed.** A capsule can now read its OWN held capability names via the new infallible `enumerate-capabilities() -> list<string>`: the capability categories declared in its `[capabilities]` manifest block (`host_process`, `net_connect`, `fs_read`, …) — the names, not the scoped arguments within them (allowlists, `host:port`, paths). This lets a reusable supervisor binary deployed under different manifests ground its behaviour in what it can actually do instead of hard-coding it, and lets any capsule avoid code-vs-manifest drift. Because the WIT is infallible (a bare `list<string>`, no `result`), it reads an owned, lock-free snapshot taken once at load (`CapabilitiesDef::held_names`) and stored on `HostState` rather than the `capsule_registry` — there is no `registry-unavailable` failure mode to surface, and an empty list is the valid "no capabilities" answer; capsule capabilities are fixed at load (the grant/revoke model is principal-scoped, a separate axis), so the snapshot is correct for the capsule's whole lifetime and across pooled instances. In the same change, `check-capsule-capability` — previously a stub that answered only `allow_prompt_injection` and returned `false` for every other capability — is completed onto the same namespace: both host fns (`held_names()` the list, `has(name)` the per-name dual) are DERIVED from the struct's serialized fields rather than a hand-maintained list, so a capability added to `CapabilitiesDef` flows through both automatically and the two cannot drift — `n` appears in `held_names()` iff `has(n)`, and unknown names fail closed. Both are ungated, read-only, and audit-not-recorded: capability posture is structural metadata, not a secret (enforce-don't-conceal) — knowing a capability conveys no ability to use it. Lifecycle hooks and the `astrid-hooks` host, which run outside the capsule manifest/security-gate lifecycle, report an empty set (fail-closed). Contract: unicity-astrid/wit#13. Closes #868.
- **Per-principal peak memory in usage reporting.** `ResourceUsage` gains `memory_bytes_peak_total: Option<u64>` — the cross-capsule high-water linear memory a principal has driven (max across every capsule it invokes), read from the shared memory ledger and surfaced by the admin `UsageGet`, `GET /api/sys/principals/{id}/usage` (a new field on the OpenAPI `ResourceUsageView`), and `astrid quota show` (a new "memory peak" row). This fills the memory side of per-principal usage, which previously reported only the per-instance ceiling. Under pooled, shared Stores a live "current" total is not cleanly attributable, so the **peak** is the reported signal — the principal that grows a Store owns the peak; `memory_bytes_current_total` stays `None`. Refs #816.
- **Operator overrides for capsule runtime sizing (config + env + CLI).** New `[capsule]` config section with `host_blocking_concurrency`, `host_io_concurrency`, and `instance_pool_size` (all optional; unset → the host-derived default), the matching `ASTRID_CAPSULE_HOST_BLOCKING_CONCURRENCY` / `ASTRID_CAPSULE_HOST_IO_CONCURRENCY` / `ASTRID_CAPSULE_INSTANCE_POOL_SIZE` env vars, and `astrid-daemon --host-blocking-concurrency` / `--host-io-concurrency` / `--instance-pool-size` flags. Precedence is CLI flag > config file > env > host-derived default; the daemon resolves the values once at boot and the kernel forwards them, unmodified, to every capsule's `WasmEngine` (the same handle-plumbing shape as `FuelLedger`). A zero override is rejected at config-validation time (it would wedge a host-call class or leave a capsule with no instance to lease, rather than throttle). Refs #816.
- **Admin contract for per-principal resource-usage reporting.** Adds `AdminRequestKind::UsageGet { principal }`, `AdminResponseBody::Usage(ResourceUsage)`, and the `ResourceUsage` payload (cross-capsule CPU total + configured ceilings + an `exempt` flag), scoped exactly like `QuotaGet` (`self:quota:get` / `quota:get`) so a principal reads its own usage and only an admin reads another's. The quota/usage admin handlers move into a new `admin/quota.rs` submodule. Refs #820.
- **Per-principal CPU-rate budget is now enforced.** A reactive 1-second windowed throttle (`FuelRateLimiter`) denies a principal's interceptor calls once it overruns its `max_cpu_fuel_per_sec` budget, until the window rolls — never permanently bricking it. Keyed on the invoking principal, sharded + per-principal-locked (no global lock), fail-secure (exemption fails closed, the window math fails open); admin / `system:resources:unbounded` / `net_bind` / `uplink` holders are exempt. Refs #819.
- **`UsageGet` now reports live per-principal usage.** The stub is replaced with the real cross-capsule CPU total (read from the shared `FuelLedger`) and a real `exempt` flag computed with the same capability predicate enforcement uses, so displayed-exempt always matches enforced-exempt. Refs #820.
- **`GET /api/sys/principals/{id}/usage`.** Exposes per-principal resource usage over HTTP, mirroring the quotas route's self/admin scope and bearer-verified caller. Also fixes the `QuotasView` OpenAPI mirror, which omitted `max_cpu_fuel_per_sec`. Refs #820.
- **`astrid quota show` now reports usage vs budget.** Adds a usage-vs-budget section (consumed CPU, ceiling, exempt flag) and the previously-missing CPU-rate ceiling line. Refs #820.
- **`astrid-emit` — an agent-agnostic stdio→bus pipe.** New companion binary (its own `crates/astrid-emit` crate, co-installed alongside `astrid` exactly like `astrid-daemon` / `astrid-build`) that any agent's hook process can shell out to. It reads stdin to EOF as a UTF-8 string, wraps it in a fixed six-field, sage-validated envelope (`hook`, `payload`, `correlation_id` (always `null`), `principal_id`, `session_id`, `token`), connects to the daemon as an uplink, and publishes the envelope via `publish-as` on the topic it was handed — accepted **both** positionally (`astrid-emit sage.v1.hook.before_tool_call`, what shipped sage writes into `settings.local.json`) and via `--topic <name>` (exactly one required). The `hook` field is derived as the topic's trailing dot-segment so it matches sage's validator, and `payload` is forwarded as a verbatim string (never base64) so canonical subscribers receive the original hook JSON. The three transport identifiers are read from the required `ASTRID_PRINCIPAL_ID` / `ASTRID_SESSION_ID` / `ASTRID_HOOK_TOKEN` environment variables (set on the agent child at spawn); any missing-or-empty value is a soft failure. The binary is deliberately tiny and carries **zero** agent-protocol knowledge — no hook-name map, no stdin parsing, no verdict shaping, no merge, no await/response, no fail-closed behaviour: sage is the trust anchor, validator, and republisher, and the canonical hook-name map lives there. `astrid-emit` always writes `{"continue":true}` to stdout (success **and** failure alike) and exits `0` on a successful publish or `1` on any failure (missing env, connect failure, send failure) — it **never** exits `2`. A new `astrid --emit-path` discovery flag (handled before banner/config so it works on a half-configured host) prints the absolute path to the co-installed `astrid-emit` so hook-bridge installers can wire commands without guessing the install layout. Refs #814.
- **Process-level + build-provenance metrics on the gateway `/metrics` endpoint.** The Prometheus exposition now ships the standard `process_*` family (`process_cpu_seconds_total`, `process_resident_memory_bytes`, `process_threads`, `process_open_fds`, `process_start_time_seconds`) via the `metrics-process` collector, plus an `astrid_build_info{version,git_sha,rustc}` gauge for build-provenance joins. `rate(process_cpu_seconds_total)` is the direct, graphable detector for the idle 200–300% CPU class of incident that previously could only be inferred from `top`. The collector is pull-based — the `/metrics` handler calls `collect()` once per scrape, deliberately adding **no** background thread (you don't observe a background-CPU bug by spawning a polling thread to watch it). `git_sha` (short) and `rustc` are captured at compile time by a new `crates/astrid-gateway/build.rs`, each falling back to `"unknown"` so a source-tarball build still populates the metric; build constants are exposed on the unauthenticated endpoint deliberately, since they carry no principal, path, or secret. This is the foundation slice of the host-internal operational-metrics layer; the full design, catalogue, and phased roadmap — and its division of responsibility from the #705 capsule-telemetry layer — land in `docs/metrics.md`. Refs #791.
- **Event-bus + daemon saturation metrics.** The runtime's missing saturation signal, recorded into the daemon-wide Prometheus recorder the gateway exports: `astrid_bus_events_published_total{event_kind}` (publish throughput, labelled by the bounded `AstridEvent::event_type` set — IPC traffic collapses to `ipc`), `astrid_bus_receiver_lagged_total{subscriber}` (events a subscriber dropped by falling behind — a non-zero `rate()` is the signature of a feedback storm), `astrid_daemon_active_connections` (gauge) + `astrid_daemon_connections_opened_total`/`_closed_total`, and `astrid_daemon_background_ticks_total{loop}` across the idle / capsule-health / react-watchdog / bus-monitor loops (a flat `rate()` is a parked loop, a runaway `rate()` is a spin loop — the direct signal for the idle 200–300% CPU class of incident, complementing #787's storm log and `process_cpu_seconds_total`). The `subscriber` label is a fixed, code-assigned set: the long-lived daemon consumers (`kernel_router`, `admin_router`, `connection_tracker`, `capsule_dispatcher`, `bus_monitor`, `revocation_watcher`) are tagged via the new `EventBus::subscribe_as`/`subscribe_topic_as`; everything else (incl. dynamic capsule-supplied topic subscriptions) collapses to `untagged`, so a buggy capsule can't inflate cardinality. Adds the `metrics` facade to `astrid-events` and `astrid-kernel`. The kernel-side uplink socket-accept counter is deferred to a follow-up (the accept happens inside a capsule via the `astrid:net` host shim, a separate crate). Resolves the `astrid_bus_*` vs `astrid_router_*` naming question (§7.9) in `docs/metrics.md`. Refs #791.
- **`astrid build` injects the getrandom custom-backend cfg for `wasm32-unknown-unknown` capsules.** Every capsule on that target must compile with `--cfg=getrandom_backend="custom"` so `uuid` v4 / `HashMap` seeding link against `astrid-sys`'s host-routed RNG (`astrid:sys/host.random-bytes`); getrandom 0.4 requires the cfg on the final binary crate, so a capsule whose `.cargo/config.toml` omitted it failed to build with a cryptic getrandom `compile_error!`. `astrid-build` now injects the cfg itself via a merged `CARGO_ENCODED_RUSTFLAGS` — because host ≠ wasm this is a cross-compile, so the flag reaches only the wasm artifacts, never host build scripts / proc-macros. The injection merges rather than clobbers — it concatenates inherited `RUSTFLAGS`/`CARGO_ENCODED_RUSTFLAGS`, the capsule's own config `rustflags` (array or string form), and the getrandom cfg, de-duplicating only the getrandom cfg so multi-token flags like `-C opt-level=3` stay intact — so a capsule already carrying the flag is unaffected. This is a safety net for the canonical build tool; capsules still keep the flag in config because a plain `cargo build` / `cargo test` never runs through `astrid-build`. Closes #800.
- **Passive event-bus storm diagnostics.** A new bus-activity monitor (`astrid-kernel/src/bus_monitor.rs`) subscribes to all events on its own subscriber, aggregates publish counts per topic over a rolling 5s window, and logs a `WARN` naming the hottest topics whenever the sustained rate crosses 100 events/s (`DEBUG` otherwise); events dropped to broadcast lag are folded into the tally via `drain_lagged` so an overflow spike still surfaces. A truly idle daemon publishes well under one event per second, so a sustained triple-digit rate with no client attached is the signature of a feedback loop / event storm — the failure mode that pegs CPU by waking every broadcast subscriber and re-invoking WASM interceptors. The monitor is a pure observer (it counts on its own subscriber, not inside `EventBus::publish`), so it adds zero hot-path overhead and keeps reporting even while a storm saturates the dispatcher; this makes the next such incident self-diagnosing instead of requiring a live profiler attach. Bumps `INTERNAL_SUBSCRIBER_COUNT` 4 → 5. Closes #786.
- **Typed OpenAPI schemas for gateway responses sourced from kernel types.** Response payloads that flow through `astrid-core` types (`AgentSummary`, `CapabilityInfo`, `Quotas`, `GroupSummary`, `InviteIssued`, `InviteSummary`) previously surfaced in the OpenAPI spec as opaque `serde_json::Value` (`any`), so generated clients got no field-level types. Each now has a gateway-local `*View` schema mirror (`astrid-core` stays utoipa-free) that the `value_type` resolves to, giving codegen real typed shapes. Fixing this also surfaced and corrected drift in the old hand-written shape comments: `InviteIssued` carries no `fingerprint`, `InviteSummary`'s field is `token_fingerprint` (plus an always-present `issued_at_epoch`), and `GroupSummary`'s flag is `builtin` (not `built_in`). A regression test pins the mirrors so a payload can't silently revert to `any`. `env.default` and `distribution.branding` stay `Value` — they're legitimately polymorphic. Closes #783.
- **Gateway default listen port changed `7777` → `2787` ("ASTR").** `7777` collides in practice with Terraria and assorted dev tooling; `2787` is the phone-keypad mnemonic for ASTR (A=2, S=7, T=8, R=7) and is effectively free in the wild. The gateway is opt-in and the runbook has operators set `listen` explicitly, so impact is limited to anyone relying on the default. Closes #784.
- **Gateway API client generation guide.** New `docs/gateway-client.md` documents how to build a client against the gateway HTTP admin API: generate from the OpenAPI spec at `GET /api/openapi.json` (openapi-typescript / progenitor), with hand-written layers only where the spec can't help — the two SSE streams (`POST /api/agent/prompt`, `GET /api/events`), the `#[schema(value_type = String)]` stand-in fields whose real types live in `astrid-core`, and the redeem/refresh auth lifecycle (the bearer is treated as opaque — no client-side signature verification). Records the placement decision: a gateway client is native/browser code and does not belong in the wasm-targeted capsule SDKs (`sdk-rust` / `sdk-js`); it earns its own repo when it graduates to a maintained library. Cross-linked from `core/README.md` under "Operator documentation".
- **Gateway deployment runbook.** New `docs/gateway-deployment.md` covers the full operator surface: quickstart, behind a reverse proxy (nginx + Caddy snippets, `trust-forwarded-from` discipline), native TLS via the `[tls]` block, monitoring (every counter / histogram explained, sample alert PromQL), authentication flow + bearer lifecycle, CORS allowlist grammar, key rotation, backup + restore, and a troubleshooting section for the common failure modes (401 cascade, rate-limiter lockout, CORS preflight mismatch, audit-history 502). Cross-linked from `core/README.md` under a new "Operator documentation" section. Closes #777.
- **`GET /api/sys/audit` — historical-query endpoint over the persistent audit log.** Companion to the live `GET /api/events` SSE feed: SSE only delivers events from the moment the connection opens, so a dashboard rendering "the last 24 h of admin activity" had no way to backfill. Now it does. Paginated with `?since`/`?until` (epoch seconds), `?method=AgentDelete`, `?principal=alice`, `?limit` (default 100, cap 1000), `?cursor` (opaque). Same trust shape as the SSE handler — `audit:read_all` holders see the firehose; everyone else is silently scoped to their own principal regardless of what they pass in `?principal`. Reads via `tokio::task::spawn_blocking` so the underlying `SurrealKV` query doesn't stall the runtime worker. The new endpoint is plumbed through `astrid-daemon::spawn_gateway` alongside `event_bus` so the gateway gets an `Arc<AuditLog>` + `SessionId` at boot; standalone-builder `GatewayState::new` returns 502 honestly when those handles are absent. OpenAPI annotation + drift test pin the new route. Closes #779.
- **Latency histograms + per-request structured logs for the gateway, backed by the `metrics` facade.** The `/metrics` exposition now ships an `astrid_gateway_request_duration_seconds` Prometheus histogram per `(method, route, status)` with the standard HTTP buckets (5 ms → 10 s + `+Inf`), and `astrid_gateway_requests_total` carries a new `status` label so a 4xx/5xx spike decomposes separately from the 2xx traffic on the same route. Every request also emits one structured `tracing` event with `method`, `route` (matched template, never the raw URL — keeps cardinality bounded), `status`, and `duration_ms`; `/healthz` and `/metrics` demote to DEBUG so the high-frequency liveness probes don't drown the INFO stream. **Recording uses the `metrics` crate facade + `metrics-exporter-prometheus` instead of a hand-rolled counter/histogram pair** — decouples recording from export format so kernel-side or capsule-side observability can land later via the same `counter!()` / `histogram!()` macros without each subsystem reinventing a metrics layer. `astrid_gateway_auth_failures_total`, `astrid_gateway_redeem_attempts_total`, and `astrid_gateway_redeem_rate_limited_total` are registered at boot and render at `0` so dashboards have a stable series shape before the corresponding instrumentation lands. Closes #778.
- **Native TLS termination in the gateway via rustls.** New optional `[tls]` block in `etc/gateway-http.toml` (`cert-path`, `key-path`) flips the gateway from plain HTTP to rustls-terminated HTTPS — useful for single-box installs, Tailscale-fronted deployments, and anyone running without a reverse-proxy ops layer. Without the block, the daemon behaves exactly as v0.7.0 (plain HTTP, TLS-upstream expected). Backed by `axum-server 0.7` + `aws-lc-rs` (no openssl). New `GatewayConfig::validate()` runs at daemon boot: missing cert/key paths produce a clear "refusing to boot" error so misconfig fails fast instead of returning malformed TLS handshakes at runtime. Group/world-readable key files generate a `WARN` log at boot suggesting `chmod 0600`. `rustls::crypto::CryptoProvider::install_default()` is called idempotently in `tls::load_rustls_config` so rustls 0.23's multi-provider deferral doesn't panic the first handshake. **HSTS** (`Strict-Transport-Security: max-age=63072000; includeSubDomains`) is layered onto every TLS response — only on the TLS dispatch path, since RFC 6797 forbids HSTS over plain HTTP. Binding a non-loopback address without a `[tls]` block now logs a `WARN` at boot suggesting the operator either enable TLS or confirm a reverse proxy is fronting the listener. Three new integration tests in `crates/astrid-integration-tests/tests/gateway_tls.rs` mint a self-signed cert at test time with `rcgen` and prove the round-trip end-to-end including HSTS presence on TLS / absence on plain HTTP. ACME / Let's Encrypt automation, mTLS client-cert auth, and HTTP/2 / h2 ALPN remain out of scope for v0.7 and tracked as follow-ups on the closed issue. Closes #773.
- **HTTP admin gateway (`astrid-gateway`).** New crate that fronts the kernel's existing `astrid.v1.admin.*` + `astrid.v1.request.*` IPC surfaces over HTTP for browser dashboards. Reads `~/.astrid/run/system.token`, handshakes with the daemon over the same Unix socket the CLI uses, and stamps `IpcMessage.principal` from an ed25519-signed bearer it verifies against its boot-time public key — never from the request body. Full route surface, sufficient for a dashboard to provision principals, set quotas, manage groups + caps + invites, and configure capsule env:
  - **Discovery (unauthenticated):** `GET /api/distribution`, `GET /api/distribution/onboarding`
  - **Auth:** `POST /api/auth/redeem`, `GET /api/auth/me`, `POST /api/auth/refresh` (extends an existing session without forcing a re-redeem), `POST /api/auth/pair-device` (authenticated — mint a pair-token for the caller's own principal), `POST /api/auth/pair-device/redeem` (unauthenticated — the new device sends its ed25519 public key; the kernel appends it to the principal's `AuthConfig.public_keys` and the gateway returns a fresh bearer for that principal)
  - **Principals (agent CRUD):** `GET/POST /api/sys/principals`, `GET/PATCH/DELETE /api/sys/principals/{id}`, `POST .../enable`, `POST .../disable`
  - **Caps:** `POST /api/sys/principals/{id}/caps` (grant), `DELETE /api/sys/principals/{id}/caps` (revoke)
  - **Quotas:** `GET/PUT /api/sys/principals/{id}/quotas`
  - **Groups:** `GET/POST /api/sys/groups`, `PATCH/DELETE /api/sys/groups/{name}`
  - **Invites:** `POST/GET /api/sys/invites`, `DELETE /api/sys/invites/{fingerprint}`
  - **Capabilities catalog:** `GET /api/sys/capabilities` (drift-checked against the kernel-side tables at test time) returns structured entries — each with `id`, `label`, `description`, `category` (agent / caps / quota / group / invite / capsule / system / approval), `scope` (`self` / `global`), and `danger` (`safe` / `normal` / `elevated` / `extreme`) — so dashboards can render Discord-style permissions panels with confirmation prompts on dangerous toggles, without hardcoding per-capability metadata client-side. Source of truth is `astrid_core::capability_grammar::CAPABILITY_CATALOG`; the kernel's drift tests pin every static cap-string against the catalog at test time.
  - **Capsules:** `GET /api/capsules`, `POST /api/capsules` (cap-gated by `capsule:install`; kernel handler is a stub today but the route is forward-compatible), `GET /api/capsules/{id}`, `GET /api/capsules/{id}/topics`
  - **Capsule env (per-principal config):** `GET /api/capsules/{id}/env` (schema from `Capsule.toml`), `POST /api/capsules/{id}/env/{field}` (writes to `FileSecretStore` for `secret`-typed fields, env JSON under the principal's home for `text`/`select`/`array`)
  - **Audit stream:** `GET /api/events` — Server-Sent Events feed of audit entries. Operators with `audit:read_all` see the firehose; everyone else sees only entries whose principal matches their own. Kernel publishes a flat JSON event (ts_epoch, method, required_capability, principal, target_principal, params, outcome) to topic `astrid.v1.audit.entry` on every `record_admin_audit` call. 15s SSE keep-alive so NAT/proxy state survives idle stretches.
  - **System:** `GET /api/sys/status`, `POST /api/sys/capsules/reload`
  - **Ops probes (unauthenticated):** `GET /healthz` (200 iff daemon socket reachable; ~zero IPC cost so safe for high-frequency liveness probes) and `GET /metrics` (Prometheus text-exposition format — `astrid_gateway_requests_total{method,route}`, `astrid_gateway_auth_failures_total`, `astrid_gateway_redeem_attempts_total`, `astrid_gateway_redeem_rate_limited_total`). Restrict access via reverse proxy / firewall.
  
  localhost-bound by default; TLS expected upstream. Spawned by `astrid-daemon` when `etc/gateway-http.toml` has `enabled = true`; missing/disabled = no-op so single-tenant deployments are unaffected. The `POST /api/capsules` route is wired through to `KernelRequest::InstallCapsule` and the kernel-side handler ships in this release — see the `astrid-capsule-install` entry below. `ApprovalRequired` responses from the kernel are passed through structurally so dashboards can render approval prompts when capability gating triggers them. (#756)
- **Invite-token primitives in the kernel.** Four new `AdminRequestKind` variants — `InviteIssue`, `InviteRedeem`, `InviteList`, `InviteRevoke` — plus matching `AdminResponseBody::{Invite, InviteRedeemed, InviteList}`. Backed by a file-at-`etc/invites.toml` store that persists only `hex(sha256(token))` (never the raw token) with 0600 perms and atomic write-then-rename. `InviteRedeem` bypasses the cap-gate by explicit match in the dispatcher (the token IS the auth) and reuses the existing `AgentCreate` machinery under `admin_write_lock` so concurrent redeems can't double-spend. The audit sanitiser redacts both the raw token and the ed25519 public key from persisted audit rows, replacing each with its SHA-256 fingerprint. New `invite:issue` / `invite:list` / `invite:revoke` capabilities; the built-in `admin` group's `*` covers them. (#756)
- **`astrid invite {issue, list, revoke, redeem}` CLI verbs.** Operator parity with the gateway HTTP routes — useful for scripting and for end-to-end testing the invite flow without the HTTP front. `redeem` connects as `PrincipalId::default()` so a fresh-machine onboarding flow doesn't need a pre-existing `cli-context.toml`. `redeem` accepts either `--public-key <hex>` or `--keypair <name>` (reading the pubkey from the local keystore) and an optional `--switch` flag that auto-updates `cli-context.toml` to the freshly-minted principal.
- **`astrid keypair {generate, list, show, pubkey, delete}` CLI verbs.** Multi-key local ed25519 keystore at `~/.astrid/keys/local/<name>.{ed25519, pub.hex, meta.toml}` — 0700 parent dir, 0600 private file, 0644 public, atomic write-then-rename throughout, secret bytes zeroized on drop via `ed25519-dalek`'s zeroize feature. Each keypair carries a TOML metadata sidecar (schema_version, fingerprint, created_at, optional note, optional `bound_principal` set on a successful invite redeem). `pubkey --format openssh` emits `ssh-ed25519 AAAA…` so the same key can be reused with SSH-style tooling. Forward-compatible with a future `AuthMethod::HardwareKey` backend — only the meta `backend` field changes.
- **`astrid_core::capability_grammar::KNOWN_CAPABILITIES`.** Canonical static slice of every capability identifier the kernel recognises. The HTTP gateway's `/api/sys/capabilities` route references it; new kernel-side tests in `kernel_router/capability_catalog_tests.rs` enumerate every string returned by `required_capability` / `required_capability_for_admin_request` and assert each appears in the catalog — so adding a kernel cap without updating the catalog now fails CI. Compile-time `KNOWN_CAPABILITIES_COUNT` pin catches off-by-one omissions.
- **`astrid-capsule-install` crate — kernel-side `KernelRequest::InstallCapsule` handler.** Install machinery (file layout, content-addressing of WASM/WIT into `bin/<hash>.wasm` / `wit/<hash>.wit`, lifecycle hooks, topic baking, `meta.json` writes, `.capsule` archive unpacking with traversal/symlink defense) extracted from `astrid-cli` so the daemon and the CLI reach disk through the same code path. The kernel handler is **path-only** by construction: network sources (`@org/repo`, GitHub URLs, `openclaw:…`, `gh:`, raw HTTPS) are rejected with a structured error pointing callers at the gateway's registry route — the daemon never fetches arbitrary bytes during install. On success the handler triggers `load_all_capsules` so the new capsule is live without a daemon restart, and returns a flat JSON `InstallOutput` (target_dir, phase, installed_version, previous_version, wasm_hash, env_path, env_needs_prompt, missing_imports, export_conflicts) the dashboard can render directly. CLI behaviour is unchanged — `astrid capsule install` still handles source resolution (GitHub release-asset download with clone+build fallback, OpenClaw transpile, archive auto-detect, Rust-source auto-build), and the post-resolution install delegates to the new crate. (#756)
- **Bus-direct admin path — 285× admin throughput increase, 1000s-of-agents ready.** Gateway admin routes used to dial the kernel over the Unix socket through the `astrid-capsule-cli` proxy capsule, which capped at ~19 RPS regardless of concurrency (`MAX_ACTIVE_STREAMS = 8` + per-stream 50ms poll budget). For a deployment hosting thousands of agents that was a hard wall. New `astrid_gateway::bus_admin::BusAdminClient` publishes admin requests directly onto the in-process `kernel.event_bus` (which the gateway already holds for the SSE audit + agent routes) and subscribes to the response topic locally. Same envelope shape, same `request_id` correlation, same kernel-side dispatcher — three fewer hops. Every admin route now goes through `state.admin_client(caller)` rather than `AdminClient::connect(...).await` over the socket. Measured ceiling: **5,400 RPS reads at c=50 (p99=15ms)** vs the previous 19 RPS / 1054ms ceiling. Writes also serialised through the admin dispatcher's single-task loop — parallelised that too (`tokio::spawn` per request in `spawn_admin_router`) so reads no longer block behind writes; the existing `admin_write_lock` continues to serialise the actual write handlers. Writes now ~46 RPS peak (admin_write_lock + TOML rewrite bound — future scalability item, file an issue for an LSM-backed invite/profile store when it bites). All 34 e2e stories continue to pass on the new path; the socket-based `AdminClient` is unchanged and still serves the CLI's external uplink use case. (#756)
- **`POST /api/agent/prompt` — agent invocation over HTTP, SSE response stream.** Dashboard clients can now talk to the AI through the gateway, not only via the CLI's `user.v1.prompt` socket path. The route publishes `IpcPayload::UserInput { text, session_id, context }` directly onto the in-process kernel event bus (no proxy round-trip — sidesteps the capsule-cli stability issue tracked at unicity-astrid/capsule-cli#18) and returns a Server-Sent Events stream emitting `event: ready` (correlation handshake), `event: delta` (incremental tokens from `agent.v1.stream.delta`), `event: response` (final reply on `agent.v1.response`, closes the stream), `event: session_changed` (`agent.v1.session_changed`), and `event: elicit` (forwarded from `astrid.v1.elicit.*` for follow-up-input prompts). Per-session filtering at the consumer end so multiple concurrent dashboards don't see each other's chunks. 5-minute upper bound on the stream — dashboards re-POST to continue. New `agent` tag in the OpenAPI spec; new `PromptRequest` + `PromptReady` schemas. Verified end-to-end against the live daemon: `ready` event arrives with the caller's principal echoed back. Full LLM round-trip requires the react/identity/LLM-provider capsules to be installed. (#756)
- **Path-parameter routes fix — every `{id}`-style route was silently broken.** Gateway is built on `axum 0.7.9` which uses `:id` syntax for path captures; `{id}` is `axum 0.8`+. The routes module registered every parameterised path with `{id}` literally, so requests to `/api/sys/principals/alice`, `/api/sys/principals/alice/quotas`, `/api/capsules/foo/env/api_key`, `/api/sys/groups/ops-team`, `/api/sys/invites/<fp>`, and every other parameterised admin route returned a 404 — only the no-params routes worked. The OpenAPI spec uses `{id}` (correct, that's the OpenAPI standard) so the discrepancy didn't surface in the drift test. Caught by running the multi-story e2e against a live daemon; every parameterised route now routes correctly. The OpenAPI spec is unchanged (it's already correct for that format). (#756)
- **End-to-end multi-perspective story test plan.** 34-assertion script (`scripts/e2e-stories.sh`, gated on a live daemon — not part of the cargo test matrix) walks the full admin gateway from three perspectives: bootstrap admin (issue invites, create custom groups, grant caps), team operator (redeems into a custom group, can issue invites but not delete principals, can't pair-device because the team group lacks `self:auth:pair`), and regular agent (redeems into the built-in `agent` group, can pair devices, sees only themselves in `/api/sys/principals`, can't issue invites or read system status until admin grants the cap, can read their own quota after admin sets it). Also covers `/api/openapi.json` (29 paths + 32 schemas), `/api/sys/capabilities` (34 caps, 8 categories), bearer refresh, unauthenticated 401 paths, and the agent SSE handshake. All 34 stories pass against the live daemon as of this commit. (#756)
- **Gateway end-to-end smoke test + `ConnectInfo` fix.** New `astrid-integration-tests/tests/gateway_e2e.rs` boots a real `Kernel` and `GatewayState` against a tempdir `$ASTRID_HOME` and proves the boot artefacts (Unix socket, persistent KV directory, gateway signing key at `keys/gateway.ed25519`) land on disk, the unauthenticated routes (`/api/distribution`, `/api/openapi.json`, `/healthz`) return 200 against the live state, bearers round-trip via the same signing material the middleware verifies, and the kernel-bus → SSE wiring delivers audit events to subscribers. The full `/api/auth/redeem` socket loop is gated on the `astrid-capsule-cli` proxy capsule (out of this crate's scope to load) and was exercised manually against a built daemon. **Bug found by manual exercise**: `axum::serve(listener, router)` without `into_make_service_with_connect_info::<SocketAddr>()` means the `ConnectInfo<SocketAddr>` request extension is missing, so `POST /api/auth/redeem` (which extracts it for per-IP rate limiting) returned 500 `Missing request extension`. Fixed by switching the serve shape — every tower-style in-process test still works because `oneshot` doesn't require a real connection, but the production daemon does. (#756)
- **`GET /api/openapi.json` — OpenAPI 3.x spec emission via `utoipa`.** Every gateway handler now carries a `#[utoipa::path(...)]` annotation and every request/response type derives `ToSchema`. The aggregated `ApiDoc` lists all 35 routes under `paths(...)` and 32 schemas under `components(schemas(...))`. The spec declares a `bearerAuth` HTTP security scheme (description spells out the verification posture) as the default requirement; unauthenticated routes (`/api/distribution`, `/api/distribution/onboarding`, `/api/auth/redeem`, `/api/auth/pair-device/redeem`, `/healthz`, `/metrics`, `/api/openapi.json` itself) clear it via `security(())`. Twelve `tags(...)` group routes by family (auth, principals, caps, quotas, groups, invites, capsules, env, audit, system, discovery, ops) so Swagger UI / Redoc / Scalar render coherent sections. Type-system boundary: a handful of response types originate in `astrid-core` (`PrincipalId`, `Quotas`, `AgentSummary`, `GroupSummary`, `InviteIssued`, `InviteSummary`, `DaemonStatus`, `CapabilityInfo`) and don't carry `ToSchema` — pulling utoipa across the kernel-side dep graph would balloon the build for one observability concern. Those fields use `#[schema(value_type = serde_json::Value)]` to render as generic objects in the spec with prose describing the exact field shape in the surrounding text. New drift test in `tests/router.rs` enumerates every registered route in `routes::build` and asserts each appears in the spec — adding a route without an annotation fails CI. New `ErrorBody` struct in `error.rs` documents the unified failure shape every status code emits (`{ error, reason?, retry_after_secs? }`); the IntoResponse impl still writes the same wire format via `json!()` macros so no body changes for existing clients. Drop the URL into `openapi-typescript` / `openapi-generator` / `kiota` to get a typed client; drop it into Swagger UI / Redoc / Scalar for browsable docs. (#756)
- **Source-direct content-addressing during install.** Previously the install copied the entire capsule tree into the target directory, then read the `.wasm` back out, BLAKE3-hashed it, wrote `bin/<hash>.wasm`, and deleted the per-capsule copy — same dance for `wit/`. The new lib hashes WASM and WIT from the **source** directly, writes to the content store once (atomic temp+rename so concurrent writers on identical bytes converge harmlessly), and the per-capsule directory copy excludes `*.wasm` and the top-level `wit/` by construction. No transient staging copy. The runtime contract is unchanged — loader still reads via `resolve_content_addressed_wasm` — only the install path is cleaner. Pre-flight hashing also means content-addressing failures (bad source, hash collision, disk full on `bin/`) leave the existing install intact: no rollback needed because `target_dir` hasn't been touched yet. (#756)
- **`Distro.toml` `[invites]` and `[branding]` sections (additive).** `[invites] { issuers, default-group, default-expires, max-principals }` declares the deployment's onboarding policy (empty `issuers` = single-tenant, no registration UI). `[branding] { icon, primary-color, accent-color }` carries dashboard visual hints. Parser-validated: non-empty `issuers` requires `default-group`; durations parse as `Ns`/`Nm`/`Nh`/`Nd`; colours match `#RGB` or `#RRGGBB`; icons are capped at 64 KiB on parse.

### Changed

- **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 + atomic (never a global mutex), so it does not reserialise the hot interceptor path. Telemetry only — no read/deny path in this change. Refs #819.
- **Per-capsule WASM instance pool: principals' interceptors now run concurrently instead of serialising through one `Store`.** Each non-run-loop capsule was a single `Store<HostState>` behind one mutex, so `invoke_interceptor` processed exactly one invocation at a time per capsule — the throughput floor that kept the #813 orchestration cliff open even after the async-Wasmtime work removed worker-pinning (one LLM turn every ~3 s, invariant to concurrency across a 50× range, measured directly as a 2000+ deep invocation backlog on one `Store`). `WasmEngine` now holds a `pool::CapsuleInstancePool` of N `(Store, Instance)` pairs built from one shared `InstancePre`; `invoke_interceptor` leases a free instance (semaphore-gated `checkout().await`) so up to N principals execute on independent `Store`s at once. Lease lifecycle is one RAII guard (`PoolCheckout`) whose `Drop` folds the Phase-3 CLEAR (resetting every `invocation_*` field) **and** the return-to-pool onto every exit path — normal return, `?`, panic-unwind, and future-drop on caller cancellation — replacing the old inline `ClearOnDrop`. Free checkout is sound because the per-capsule pool-safety audit confirmed no pooled capsule relies on in-WASM-memory state surviving across invocations (interceptor capsules use wasmtime resources within a single invocation). Pool size is a host-derived dynamic max (the originally-fixed `INSTANCE_POOL_SIZE = 16` is superseded by the dynamic, host-sized pool — see the dedicated entry above), or **1** for run-loop capsules (they keep their dedicated `Store`, owned by the run loop) and for `host_process` capsules — a fail-secure carve-out for `astrid-capsule-shell`, whose background-process handles live across invocations and must stay single-`Store`; keyed off the existing capability, so no manifest/contract change. `ipc_limiter` is now a shared `Arc` across a capsule's instances so the per-capsule IPC throughput budget is not multiplied by pool size; the resource-table-mirror counters (`net_stream_count`, `subscription_count`) stay per-`Store` (correctly scoped to each instance's table). Refs #816.
- **Async Wasmtime: guest invocations no longer pin tokio workers (`block_in_place` removed from the WASM hot path).** Component-Model async is now on across the kernel — every guest entry goes through `Linker::instantiate_async` / `TypedFunc::call_async`, and the per-capsule `Store` is now wrapped in a `tokio::sync::Mutex` instead of a `std::sync::Mutex`. The orchestration cliff fix from #813 protected the per-(capsule, topic, principal) routing layer; this finishes the job at the wasmtime layer by ensuring a parallel `invoke_interceptor` waiter `.await`s on the lock rather than holding a worker via `block_in_place` for the full lifetime of the currently-running guest call (#816). Per-capsule serialisation is preserved (one guest call at a time per capsule, same as before), but the executor is free to schedule other capsules while one is queued. `ExecutionEngine::invoke_interceptor` and `Capsule::invoke_interceptor` are now `async fn`; the dispatcher / kernel callers `.await` accordingly. `engine::wasm::run_lifecycle` is `async fn` too, with `astrid-capsule-install::lifecycle` driving it through the available runtime handle. Cancellation safety: the existing `ClearOnDrop` RAII guard already runs on the future-drop path, so a cancelled interceptor still clears `caller_context`, `interceptor_active`, and every `invocation_*` field on `HostState` before releasing the store lock — `tokio::sync::Mutex` has no poisoning to recover from, so the previous "poisoned lock" branches are gone. Host trait impls remain synchronous: this lands the runtime infrastructure for async hosts but does not yet flip the bindgen-side `imports: { default: async }` flag — the slow-blocking host fns (`ipc::recv`, `elicit`, `net.read`/`write`, `http::request`) still use `bounded_block_on_cancellable` internally, scoped to a single host call rather than the entire guest invocation. Migrating those to truly async host fns is a follow-up. Refs #816.
- **`SocketClient` and `AdminClient` moved out of `astrid-cli` into a new `astrid-uplink` crate.** Both the CLI and the new HTTP gateway are kernel uplinks with the same trust shape (read `system.token`, handshake, stamp `IpcMessage.principal`); the framing / handshake / admin-request correlation logic now lives in one place. CLI keeps thin shims at `crate::{socket,admin}_client::*` so verb modules don't change. Behaviour-preserving refactor. `SocketClient::send_input` now takes an explicit `caller: &PrincipalId` instead of looking up the CLI's active-agent context internally — the CLI passes its context-resolved principal through a `send_input_as_active_agent` helper.
- **`astrid-uplink::KernelClient`.** Sibling of `AdminClient` for the `KernelRequest` / `KernelResponse` family that flows over `astrid.v1.request.*` (capsule list, status, reload, etc.). Embeds a per-call UUID in the topic suffix so concurrent HTTP requests can't pick up each other's responses on the shared bus.

### Deprecated

- **`astrid_capsule_pending_tail_overflow_total` — removed.** Replaced by `astrid_capsule_route_byte_evictions_total{capsule, principal_class}` and `astrid_capsule_route_quantum_starved_total{capsule, principal_class}` from the new publish-side routing demux. Operator dashboards alerting on the old counter must migrate.
- **`astrid_capsule_interceptor_permit_wait_seconds_total` — removed.** The per-capsule interceptor semaphore is gone (see Removed above); there is no permit wait to measure. Operator dashboards alerting on the histogram must drop the series.

### Removed

- **`astrid-openclaw` crate and the entire OpenClaw plugin build path (#829).** The TypeScript/JavaScript-to-WASM compiler that absorbed the OpenClaw plugin ecosystem is gone — it was broken three ways and was the last extism vestige in `core`. (1) The Tier 1 QuickJS kernel was built from a personal fork of extism's js-pdk (`github.com/nicholasgasior/extism-js` @ `v1.6.0`) that no longer exists (HTTP 404), while `build.rs` separately pointed at upstream `extism/js-pdk` — the two build paths already disagreed. (2) `kernel/engine.wasm` was never committed (only its BLAKE3 hash), and the committed hash corresponded to a fork build that can't be reproduced from upstream, so a clean checkout had no path back to a working kernel. (3) The compiler emitted extism-PDK **core-module** exports (`__invoke_i32`, named exports) while the capsule host migrated off Extism to the wasmtime **Component Model / WIT** — so its output could no longer load. It was also a CI breaker (the QuickJS auto-build + wasi-sdk exhausted the runner disk → SIGBUS). Removed alongside the crate: `astrid-build`'s `openclaw` builder, the hidden `--wizer-internal` subcommand, the `openclaw` project type and `openclaw.plugin.json` autodetection; the CLI `openclaw:` install source; the kernel-router `openclaw:` remote-source rejection; the capsule watcher's `openclaw.plugin.json` detection; `packages/openclaw-mcp-bridge` (the Tier 2 Node bridge); `scripts/build-quickjs-kernel.sh` and `scripts/compile-test-plugin.sh`; and the now-orphaned `oxc` / `oxc_allocator` / `wizer` workspace dependencies. The live channel-registration infrastructure that carried the legacy name is renamed on this branch: `UplinkSource::OpenClaw` → `UplinkSource::Bridge` (constructor `new_openclaw` → `new_bridge`, serde wire tag `open_claw` → `bridge`, `Display` `openclaw(…)` → `bridge(…)`). The serde wire-tag rename stays backward-compatible via a `#[serde(alias = "open_claw")]` on the `Bridge` variant: new writes serialize as `bridge`, but any `UplinkDescriptor` persisted or transmitted under the legacy `open_claw` tag still deserializes. (`UplinkSource`/`UplinkDescriptor` derive `Serialize`/`Deserialize` and `UplinkDescriptor` is documented as deserializable for trusted persistence, so the alias is the safe path rather than assuming the value is in-memory-only.) **Breaking:** `astrid build --type openclaw`, `astrid capsule install openclaw:…`, and installing a bare `openclaw.plugin.json` directory are no longer supported.
- **Per-capsule interceptor semaphore (`MAX_CONCURRENT_INTERCEPTORS`) removed.** The Wasmtime `Store` mutex at `engine/wasm/mod.rs:1286` already serializes invocations inside `tokio::task::block_in_place`, so the per-capsule cap-of-4 could never produce more than 1 concurrent guest execution — it was a redundant ceiling left over from the pre-#813 SET/CALL/CLEAR race, which is now closed by `ClearOnDrop` (Layer 1) and the per-(capsule, principal) chain mutex (Layer 3). Run-loop capsules don't construct a `Store` at all, so for them the semaphore was pure overhead on every interceptor call. The `Capsule::interceptor_semaphore` trait method, the `CompositeCapsule.interceptor_semaphore` field, and the two `acquire_owned().await` sites in `dispatcher.rs` are gone. `astrid_capsule_interceptor_permit_wait_seconds_total` is removed with no replacement — there is no permit wait to measure. Closes #813.

### Fixed

- **Capsule tools reach the LLM again on the OpenAI-compatible path (`react` → `prompt-builder` → provider).** The pipeline was handing the model **zero tools**. `prompt-builder`'s `collect_tool_schemas()` fans out `tool.v1.request.describe` and drains `tool.v1.response.describe.*`, but its manifest granted neither topic, so the fan-out failed `CapabilityDenied` before it ever fired — a code/manifest mismatch left by the per-domain-WIT migration. And even once unblocked, the KV-persisted `__tool_schema_cache` was never invalidated: its only trigger is a `prompt_builder.v1.invalidate_tool_cache` event that nothing publishes, so a stale (or empty) tool list survived across daemon restarts and masked freshly installed tool capsules. Fixed in `capsule-prompt-builder` (unicity-astrid/capsule-prompt-builder#19) by declaring the two describe-fan-out ACLs the code already uses and invalidating the cache on the kernel's `astrid.v1.capsules_loaded` broadcast. This is the consumer half of the tool-discovery restoration; the producer half — the `#[capsule]`-generated `tool_describe` arm publishing its descriptor instead of returning it (an interceptor return is not fanned out under the current ABI) — ships in astrid-sdk 0.7.1. All three (ACL grant + cache invalidation + a tool-capsule rebuild against 0.7.1) are required for tools to reach the model; verified end-to-end with a fake-LLM harness (0 → 16 tools across system/fs/http/skills). Refs #892, #625.
- **Per-principal quotas now apply on the `ipc::recv` path, not just the interceptor path.** A run+recv capsule resolved the invoking principal's `PrincipalProfile` only on the dispatcher-driven interceptor path (`invoke_interceptor`); the guest-pulled `ipc::recv` path (`install_recv_invocation_context`) left `invocation_profile = None`, so `effective_profile()` fell back to the process-global **default** profile. Every principal driving a run-loop capsule therefore ran under the default quota (`max_background_processes`, IPC throughput, HTTP streams) regardless of its operator-configured per-principal profile — a documented gap (`host_state.rs`: "quotas remain the capsule owner's … move to a real lookup when per-principal quota enforcement is needed"). The recv path now resolves the publishing principal's profile through the shared `PrincipalProfileCache` (threaded into `HostState`) and installs it, so per-principal ceilings apply on both invocation paths. The profile is resolved for **every** publisher, the capsule owner included — `effective_profile()`'s fallback is the process-global default, never the owner's configured profile, so (matching the interceptor path) an owner-published message must resolve the owner's profile or its on-disk quotas would be silently ignored. Best-effort on the recv path, which has no deny channel: a failed load logs and falls back to the default. The pooled-interceptor and lifecycle-hook constructions receive the cache handle; the one-shot `run_lifecycle` path passes `None` (it runs no recv loop). Fail-safe and bounded before this fix (the default quota is a real ceiling, never unbounded), but the per-principal value was not honoured. Closes #877.
- **Dispatcher no longer drops events when a per-topic consumer is reclaimed under a burst.** Each `(capsule, topic, principal)` route has its own idle-evicting mpsc consumer. Under a concurrent burst the consumer could leave a *closed* sender in the queue map — the idle-evict TOCTOU (a stale clone outliving the `sender_strong_count == 1` check) or a consumer task that ended. `get_or_spawn_consumer` then handed that dead sender back, so every subsequent `try_send` failed `Closed` and the event was dropped **permanently**, stalling all delivery for that route (observed as a `react`/`user.v1.prompt` prompt stall) even though the capsule itself stayed healthy. The fix never returns a closed sender: `get_or_spawn_consumer` **removes** a mapped entry whose receiver is gone (`is_closed()`) and re-spawns a fresh consumer — the explicit remove also closes a leak in the degrade-to-shared path, where the re-keyed insert would otherwise never overwrite the stale `(capsule, Some(principal))` entry. `dispatch_single` keeps a re-spawn-and-retry backstop for the narrow window where a sender closes between return and send; `Full` (a live consumer with a saturated bounded queue) stays an intentional, per-principal-bounded shed-load drop — recoverable via the requester's IPC/SSE timeout — and is distinguished from `Closed` (a bug, never dropped, now flagged as a `security_event` if it ever recurs post-respawn). A live 100-wide concurrent-prompt smoke that previously delivered 4/100 with 39 silent channel-closed drops now delivers 95/100 with zero drops. Closes #837.
- **Dispatcher mpsc partitioned per-(CapsuleId, PrincipalKey).** Layer 1's bus-side routing fix narrowed the publish surface to `PrincipalKey` granularity, but the capsule dispatcher continued to key its mpsc consumer queues and chain mutexes on the 3-bucket `PrincipalClass` enum — so 1000 distinct user-class principals still collapsed onto a single 256-slot queue and the cliff persisted at the dispatcher layer. Each queue is now keyed on the full `PrincipalKey` (`Option<String>`) with capacity 64, a 60-second idle-eviction grace that returns the queue map to the working set, and a `MAX_DISPATCHER_QUEUES_PER_CAPSULE = 10_000` cap that degrades to a single shared `(capsule, None)` queue (with audit-logged eviction) on pathological fan-in. `chain_locks` widens to the same key so chains for distinct principals on the same capsule run concurrently. Closes #813.
- **Gateway SSE migrated to `subscribe_topic_routed`.** The four agent SSE topics (`agent.v1.response`, `agent.v1.stream.delta`, `agent.v1.session_changed`, `astrid.v1.elicit`) and the audit firehose (`astrid.v1.audit.entry`) now subscribe via the bus's routed surface (publish-side per-(topic, principal) DRR fairness + byte-budget eviction) instead of the broadcast channel. Eliminates the ~4 s disconnect under N=100 fan-in that surfaced as `event:ready` followed by silence: a routed subscription naturally fans across every principal that matches the topic — the per-principal DRR machinery provides fairness within the route, so no wildcard-principal API is added or needed. The post-receive `session_id` filter in `agent.rs` is retained — session is a payload concern, not addressing. A new `GatewayState.gateway_route_uuid` (fresh per boot) pairs all gateway SSE routes under `capsule="gateway"` for telemetry. Accepted regression: there's no explicit `Lagged` signal from a routed receiver, so silent publish-side eviction replaces the broadcast-channel lag-disconnect; the 5-minute `STREAM_TIMEOUT` remains the only stalled-stream catch (open follow-up: expose a publish-side eviction signal via `drain_lagged()`). Closes #813.
- **Native per-(capsule, topic, principal) IPC routing on the kernel event bus.** Closes the structural root cause of #813's "concurrency cliff" rather than relying on the per-recv pending-bucket workaround. `EventBus` gains an internal `routes: Arc<parking_lot::RwLock<HashMap<RouteKey, Mutex<RouteEntry>>>>` (keyed on `(capsule_uuid, topic_pattern, subscription_rep)`) populated by a new `EventBus::subscribe_topic_routed` entrypoint that capsule guests use in place of the broadcast-shaped `subscribe_topic`. Each `RouteEntry` owns a demand-allocated `HashMap<PrincipalKey, PrincipalQueue>` so a bus with 5000 idle principals costs zero per-principal entries; only active principals materialise a sub-queue, ~96 bytes each plus payload bytes. Publish-side fan-out happens AFTER `broadcast::send` (so a slow routed enqueue can never delay untargeted consumers — `kernel_router`, `admin_router`, `bus_monitor`, `connection_tracker` stay on the broadcast subscribe path) and applies deficit-round-robin (DRR) drain with a 1 MiB per-subscription byte budget and a 4 KiB quantum floor — 5000 active principals still make per-round progress. Under sustained byte pressure the bucket whose head was enqueued earliest gives up its head message until the new payload fits; streaming response terminators are preserved by construction (they're always the tail of their principal's queue, head-eviction trims the prefix not the tail). Each eviction emits `tracing::error!(target: "astrid.audit.ipc", security_event = true, capsule, principal, evicted_topic, …)` and bumps `astrid_capsule_route_byte_evictions_total{capsule, principal_class}`; sustained per-round back pressure surfaces via `astrid_capsule_route_quantum_starved_total{capsule, principal_class}`. The legacy `SubscriptionEntry::pending`/`principal_order` requeue path in `crates/astrid-capsule/src/engine/wasm/host/ipc.rs` is deleted — routed receivers never see mixed-principal batches in the first place because the demux happens publish-side, not consumer-side. Dispatcher gains a sibling `chain_locks: HashMap<(CapsuleId, PrincipalClass), Arc<tokio::sync::Mutex<()>>>` held across each chain step so cross-class invocations on the same capsule run concurrently while same-class invocations serialise FIFO; the `dispatch_single` mpsc queue map is keyed on `(CapsuleId, PrincipalClass)` for the same reason. `PrincipalClass` is a new bounded enum (`System`/`User`/`Agent`) extracted into `crates/astrid-capsule/src/principal_class.rs` so dispatcher.rs and the routing demux agree on label cardinality (3 buckets × capsule_count). WIT publish/recv signatures unchanged. Closes #813.
- **Capsule orchestration concurrency cliff resolved (`ipc::recv` truncation + `invoke_interceptor` lock-window collapse + per-capsule semaphore wiring).** Three independent defects collapsed throughput at ~100 concurrent principals and silently dropped cross-principal IPC traffic. (1) `ipc::poll` / `ipc::recv` truncated any mixed-principal batch at the first principal boundary — the tail was *dropped*, not deferred — because the per-recv principal context is keyed off a single publisher. The drain now partitions the tail into per-principal `pending` buckets on the subscription resource (cap 64 messages per bucket, 8 distinct principals per subscription, total 512 queued ≈ 0.5x the broadcast capacity; oldest-first drop on overflow, least-recently-pushed bucket eviction on the 9th principal), each drop logged as `tracing::error!(target: "astrid.audit.ipc", security_event = true)` and counted via a new `astrid_capsule_pending_tail_overflow_total{capsule, principal_class}` metric (principal_class ∈ `system`/`user`/`agent` — never raw `PrincipalId`, to keep label cardinality bounded). The next `recv`/`poll` round-robin-drains the pending buckets before reading fresh events, so cross-principal traffic surfaces instead of vanishing. (2) `invoke_interceptor` held a three-phase lock window (SET → drop → CALL → drop → CLEAR) which let a parallel chain dispatch observe another principal's `caller_context` between SET and CALL — the cross-principal race the 100-wide collapse turned on. The window is now collapsed to a single `store.lock()` held across SET + CALL + CLEAR, with a `ClearOnDrop` RAII guard so the per-invocation fields are wiped on every exit path (normal return, early `?`, panic-unwind through `func.call`). Trade-off: chain steps for the same capsule now serialize on Store contention (they already did via the per-capsule mpsc on single-match dispatch); the existing per-capsule semaphore (cap 4) protects burst memory. (3) The per-capsule interceptor semaphore was declared but never acquired by the dispatcher; chain and single-match invokes both now `acquire_owned()` a permit *immediately before* each `invoke_interceptor` call, with `Err` from a closed semaphore treated as "capsule unloading" (debug log + return for chains, `continue` for the consumer loop so hot-reload doesn't tear down the replacement capsule). Wait time recorded via `astrid_capsule_interceptor_permit_wait_seconds_total{capsule}` so the cap-of-4 is observable from day one. Companion: the `EventReceiver::recv` `Lagged` arm upgrades from `warn!` to `tracing::error!(target: "astrid.bus", security_event = true)` to match the audit-pipeline keying used by `astrid-capabilities`/kernel-router. Companion capsule fix in `capsule-react`: `TurnState::load` and `load_active_sessions` switch from `kv::get_json` to `kv::get_json_opt`, treating cold-start `Ok(None)` as the default instead of an error case, which previously generated ERROR floods on every fresh session. Closes #813.
- **Per-invocation env overlay reaches capsule `env::var(...)` calls.** The gateway's `POST /api/capsules/{id}/env/{field}` route writes operator-supplied env values to `$ASTRID_HOME/home/<principal>/.config/env/<capsule>.env.json` — but the kernel's `get_config` host-fn was reading only `self.config` (the manifest defaults loaded once at capsule boot from the load-time principal's home). For any principal other than `default` the env-write route was effectively write-only: an operator setting `base_url = http://localhost:1234` on `astrid-capsule-openai-compat` for a gateway-minted bearer still saw their LLM request hit `api.openai.com` (the manifest default). The dispatcher now loads `<home>/.config/env/<capsule>.env.json` into a new `HostState::invocation_env_overlay` whenever an interceptor is dispatched under a non-load-time principal (and the recv-context installer mirrors the load on every fresh inbound principal in a run-loop subscription); `get_config` checks the overlay first, falls through to `self.config` on miss, so the manifest default still wins for keys the operator hasn't overridden. Three regression tests pin overlay-wins-over-default, fall-through-on-miss, and absent-overlay-falls-back-to-default. Defensive size cap of 1 MiB on the env JSON read keeps a misconfigured file from blocking every dispatch on a slow disk read.
- **Caller-context principal preserved across empty inner recvs.** Capsules that follow the "receive an event, fan out to plugin hooks, drain responses, finish" pattern (prompt-builder, registry — any `#[astrid::run]` capsule that nests `ipc::recv` inside its event-handling code) were silently flipping every post-hook publish to the capsule's load-time principal (`default`) under any non-default caller. The host's `ipc::recv` / `ipc::poll` paths called `clear_recv_invocation_context` on empty drains, wiping the caller_context that recv had installed when the original event arrived — so when the fan-out timed out and the run loop continued with its follow-up publishes (e.g. `session.v1.request.get_messages`, `prompt_builder.v1.response.assemble`), each one stamped `default` and the kernel's mixed-principal recv-batch security gate truncated downstream consumers. End-to-end effect: every gateway-minted bearer (which always yields a new non-default principal) hit a `ready` SSE event on `POST /api/agent/prompt` and then nothing — no `delta`, no `response`, no LM Studio traffic at all. The fix has two parts. (a) Empty `recv` / `poll` drains no longer touch `caller_context` — they only update it when a new message arrives via `install_recv_invocation_context`, so a follow-up publish between recvs continues to stamp the most recently received message's principal. (b) A new `interceptor_active` flag on `HostState` shorts the recv-driven install path while an interceptor (`#[astrid::interceptor]`) is dispatching — the interceptor's caller is owned by `WasmEngine::invoke_interceptor`, not by recv, so a nested recv inside the handler can no longer rewrite the outer caller. Three unit tests pin the invariants. The now-redundant `clear_recv_invocation_context` is removed.
- **Event-bus subscribers yield more cooperatively under a storm.** `EventReceiver::recv` filters by topic, and `broadcast::recv().await` returns buffered items without yielding — so a filtered subscriber draining a backlog of non-matching events could hold its worker for up to 100 synchronous iterations, and on `Lagged` (the broadcast buffer overran the receiver — a storm) it caught up with no yield at all. The non-matching drain now yields every 32 events (named `YIELD_AFTER_SKIPPED`), and the `Lagged` arm yields before catching up. Dampens worker monopolization during an event storm; the storm's root cause is surfaced separately by the bus-activity diagnostics. Closes #805.
- **Connection topic aligned to the WIT contract (`client.v1.connect`).** The connection-tracker work above (and the capsule-cli proxy) used `client.v1.connected` (past tense), but the WIT `client` interface (`astrid-bus:client@1.0.0`, `wit/interfaces/client.wit`) specifies uplinks publish `client.v1.connect` on attach. A WIT-conforming uplink publishing the contract topic would not have been counted. The tracker now matches `client.v1.connect`; the `client.v1.disconnect` side already matched. Companion: capsule-cli aligns its publish. Closes #793.
- **Connection tracker now recognises `client.v1.*` topics, not just typed payloads.** The kernel's connection tracker matched only the typed `IpcPayload::Connect`/`Disconnect` variants — which no capsule can ever produce, because the SDK publish surface is JSON-only (`publish`/`publish_json`/`publish_as`/`publish_json_as`) and never exposes those variants. So uplink capsules (the CLI proxy) could not populate `active_connections`, leaving `total_connection_count()` structurally pinned at zero: the ephemeral idle-shutdown gate and `astrid who` saw no connections regardless of reality, and the typed `Disconnect` the CLI client sends over the socket was flattened to `RawJson` by the proxy and lost. The tracker now classifies connection lifecycle by **topic** (`client.v1.connect` / `client.v1.disconnect`) in addition to the typed payload, via a pure, unit-tested `connection_signal()` helper; native producers' typed payloads continue to work. Stale comments that described the non-existent wiring are corrected. The capsule-cli companion has the proxy publish those topics carrying the authenticated principal, with stream-close as the authoritative disconnect. Closes #788.
- **Daemon singleton guard: real `flock` lock + fail-closed socket-path resolution.** Two idle daemons were observed running at once. The guard was advisory-only: `kernel_socket_path()` silently fell back to `/tmp/.astrid/run/system.sock` when `AstridHome::resolve()` failed, so two processes with divergent env bound *different* sockets and coexisted (split-brain), and `prepare_socket_path` had a connect-probe → `bind` TOCTOU window with no lock. The kernel now (1) resolves the bind path **strictly** — a daemon whose `ASTRID_HOME` can't be resolved refuses to boot rather than using `/tmp` (fail-closed, matching `generate_session_token`); and (2) holds an exclusive non-blocking advisory lock (`std::fs::File::try_lock`) on `<run>/system.lock` for the process lifetime, so a second daemon fails fast and exits. The OS releases the lock on crash, so a restart is never wedged. **Behaviour change:** unresolvable `ASTRID_HOME` is now a fatal boot error. Closes #790.
- **`cors_allow_origins` is actually wired into the router now.** The gateway shipped in v0.7.0 with a `cors_allow_origins: Vec<String>` field that the router never consumed — `tower-http::CorsLayer` was on the dep list, but no layer was applied. An operator setting the allowlist saw nothing happen at runtime; browsers fell back to same-origin (which was the correct *secure* default but not the configured one). `routes::build` now applies a `CorsLayer` when the allowlist is non-empty: `Access-Control-Allow-Origin` (per-request match), `…-Allow-Methods` (GET/POST/PUT/PATCH/DELETE/OPTIONS), `…-Allow-Headers` (authorization/content-type/accept), `Vary: Origin`, and a 1-hour preflight cache. Empty allowlist stays no-CORS (browsers refuse cross-origin) so single-tenant deployments don't grow a `Vary` header. New `GatewayConfig::validate` rejects malformed origins (scheme other than http/https, trailing slash, path/query/fragment, unparseable strings, embedded userinfo that browsers strip, and raw IDNs that wouldn't match the Punycode form browsers send) at daemon boot so misconfig fails fast instead of silently no-op-ing. Five new end-to-end tests in `crates/astrid-gateway/tests/cors.rs` cover preflight accept/reject, actual-request ACAO, empty-allowlist secure default, and per-origin echo for multi-origin allowlists. Closes #771.
- **Symlink-escape defence in `copy_capsule_dir` (gateway/CLI install path).** Previously the install copier dereferenced symlinks via `fs::metadata()`. A malicious capsule tree could ship `evil -> /etc/shadow` and have the host secret copied bytewise into the per-capsule directory (which the capsule's WASM sandbox could then read via `home://`), and a directory symlink would either infinite-loop on an ancestor target or balloon the copy across an unrelated tree. The copier now canonicalizes the source root once at entry, threads it through recursion, refuses directory symlinks outright (npm only produces *file* symlinks under `node_modules/.bin/`, so no real use case is lost), and validates that any file symlink resolves *inside* the canonical root via `Path::starts_with`. Two new tests pin both threats: `copy_capsule_dir_refuses_file_symlink_pointing_outside_root` and `copy_capsule_dir_refuses_directory_symlink`. (#756, Gemini r2)
- **PID-only temp filenames in three install paths were race-prone under sibling tokio tasks.** `astrid-capsule-install::wasm::write_atomic`, `astrid-capsule-install::wit::write_atomic`, and `astrid-gateway::routes::env::write_env_string` all used `std::process::id()` as the temp-file disambiguator. Sibling tasks in the same daemon process share that PID, so two concurrent installs of *different* content-addressed WASM blobs (or two concurrent env writes from different dashboards) could collide on the same temp path and clobber each other before `rename`. Each call now uses `uuid::Uuid::new_v4().simple()` so the temp path is unique even across in-process concurrency. (#756, Gemini r2)
- **`POST /api/capsules/{id}/env/{field}` was reading the manifest from the wrong directory.** The handler looked up `Capsule.toml` under `home.root().join("capsules").join(...)` — a path that doesn't exist in the FHS layout (installed manifests live under each principal's home at `principal_home(p).capsules_dir()`). Every env-write request fell through to a "manifest not found" 500 against a real install. Fixed to use the principal-scoped path resolver. (#756, Gemini r2)
- **`write_env` was doing synchronous file I/O on the tokio worker thread.** At the gateway's measured 5,400 RPS read ceiling, a single slow fsync on the env path would have stalled every other in-flight HTTP request. The secret-store write, the `text`/`select` write, and the `array` append now run inside `tokio::task::spawn_blocking` so the worker stays free. (#756, Gemini r2)
- **Audit-stream cap probe used the socket-based `AdminClient` instead of the bus-direct path.** `caller_holds` on the `GET /api/events` handler dialled the daemon over the Unix socket through `astrid-capsule-cli` to ask whether the caller held `audit:read_all`, adding the ~50ms proxy handshake to every SSE open — exactly the latency the rest of the gateway routes were rewritten to skip. Now uses `state.admin_client(principal)` like every other route, so SSE first-byte time is microseconds. (#756, Gemini r2)
- **`POST /api/auth/redeem` rate limiter was per-proxy, not per-client, behind a reverse proxy.** The redeem path keyed `RateLimiter` on `ConnectInfo<SocketAddr>::ip()`, which is the proxy's IP under a typical nginx/Caddy/cloud-LB deployment — one abusive client would lock out every legitimate user globally. New `GatewayConfig.trust_forwarded_from: Vec<IpAddr>` lists the reverse-proxy IPs the gateway trusts; when the immediate peer is on the list, the limiter resolves the real client from `X-Forwarded-For` (first hop) then `X-Real-IP`, falling back to peer. Empty list = no forwarded-header trust (peer IP is used directly), preserving the previous behaviour for direct-internet deployments. Operators **must** set this when the gateway is behind a proxy; the docstring on the config field calls this out explicitly. (#756, Gemini r2)
- **`cargo publish -p astrid` escaped the published tarball via a workspace-rooted `include_str!` path.** `astrid setup` embedded the bundled AppArmor profile with `include_str!("../../../../dist/apparmor/astrid")` — a path that resolves outside the crate during `cargo publish`, so packaging the CLI failed the verifier compile (same class as the wit-staging / chrono publish fixes folded into 0.7.0). The profile moved to `crates/astrid-cli/apparmor/astrid` (content unchanged) and the include is now crate-relative; the workspace-rooted `dist/` directory is gone. Closes #765.

### Security

- **Capsule audit-feed subscriptions are now scoped to the subscriber's own principal by default.** A capsule that declared `ipc_subscribe = ["astrid.v1.audit.entry"]` (or any `astrid.v1.*` superset) in its manifest previously received *every* principal's audit entries — the cross-principal firehose was the default, gated by no capability and guarded by no reserved namespace (`check_subscribe_acl` matched topics syntactically, and the per-principal bus demux is fairness-only, not access control). An audit-covering subscription is now self-scoped **at enqueue** to the capsule's load-time owner principal, so a foreign principal's entries never enter the route's byte budget and a noisy co-principal can never head-evict the owner's own entries. The catalogued `audit:read_all` capability — resolved against the owner's profile and the live group config, never the self-declared manifest, so a capsule cannot grant itself the firehose — lifts the scope back to the full firehose, matching the gateway SSE model. Unscoped subscriptions are byte-identical to before; wildcard supersets are detected via the route-layer topic matcher. No WIT change, no new capability. Refs #850.
- **Persistent host-process spawning now requires an explicit operator `allow_persistent` sub-grant.** `astrid:process.spawn-persistent` was gated only on the `host_process` capability plus an authenticated principal, even though the WIT documents it as additionally requiring an operator `allow_persistent` opt-in — the sub-grant did not exist, so the gate the contract promised was never enforced. `CapabilitiesDef` gains an `allow_persistent` manifest field (bool, `#[serde(default)]`, fail-closed): `host_process` alone grants the ephemeral `spawn` / `spawn-background` tiers, while a persistent child — which outlives the pooled instance and, on macOS, has no `die-with-parent` — additionally requires this opt-in. Without it, `spawn-persistent` returns `capability-denied` (audited); the ephemeral tiers are unaffected. The grant is surfaced by `enumerate-capabilities` through the serde-derived `held_names` / `has`, so a capsule can introspect it. No WIT change — the contract already specified the gate; this makes the host conform. Capsules that spawn persistent processes (e.g. `astrid-capsule-shell`) must add `allow_persistent` to their manifest `[capabilities]`. Refs #872.
- **macOS native-subprocess sandbox is no longer silently disabled on macOS 15+ (Darwin >= 24).** `SandboxCommand::wrap` — the macOS arm of the `host_process` spawn path — carried a version guard that returned the spawned subprocess **completely unsandboxed** on every current Mac (Darwin 24 = macOS 15 Sequoia, Darwin 25 = macOS 26), logging only a `tracing::warn!`. The stated premise (that `sandbox-exec` is deprecated and therefore unusable on macOS 15+) was wrong: `sandbox-exec` is deprecated but still enforces on current macOS. The SIGABRT that originally motivated the guard (introduced in #603) was a fail-closed *profile* defect, not an OS incompatibility — `wrap`'s inline profile was a stale duplicate that omitted the `(allow file-read* (literal "/"))` rule a dynamically-linked binary (e.g. `node`) needs to stat the filesystem root at startup, so Seatbelt correctly aborted the process and the guard turned that fail-closed signal into a silent fail-open passthrough. On `host_process`-capable capsules (`astrid-capsule-shell`) every native subprocess then inherited the host user's full filesystem reach (`~/.ssh`, dotfiles, arbitrary writes). The version guard and the stale inline profile are both removed; `wrap`'s macOS arm now routes through `build_seatbelt_prefix`, the single profile that already carries `(literal "/")` + `(allow mach*)` (added for Node.js in #534) and that the MCP spawn path has been running on macOS 15+ all along — so both spawn paths share one profile and containment no longer depends on which path a capsule happens to use. If `sandbox-exec` genuinely cannot run, the subprocess spawn now fails rather than launching unsandboxed. A macOS-only test spawns a real `node` under the generated profile and asserts it runs, with a contrast proving the same profile without `(literal "/")` fails closed. Closes #855.
- **`POST /api/auth/pair-device/redeem` is now per-IP rate-limited.** The device-pairing redeem route is unauthenticated and public (the pair-token is the auth), but unlike its sibling `POST /api/auth/redeem` it had no brute-force fence in front of the kernel's constant-time pair-token scan — an attacker could enumerate pair-tokens as fast as the network allowed. It now runs the same per-IP throttle as invite-redeem, **deliberately sharing the one `redeem_limiter`** so the per-IP budget is spent across both unauthenticated redeem routes and cannot be dodged by alternating between them. `X-Forwarded-For` is honoured only behind a configured trusted proxy (`resolve_client_ip`), and the `429` (with `retry_after_secs`) is now part of the route's documented OpenAPI contract. A new router test pre-seeds the shared limiter and asserts a pair-redeem from that IP is rejected before it ever reaches the daemon. Surfaced by the CLI/API sysadmin parity audit.
- **Failed token redeems now record a failure-outcome audit row.** `InviteRedeem` and `PairDeviceRedeem` bypass the capability preamble (the token is the auth, since the redeemer's principal does not exist yet), so the kernel admin dispatcher special-cased them — but it stamped the audit row `AuthorizationProof::System` + `AuditOutcome::success` **before** dispatching the handler. A redeem the handler then rejected (invalid / expired / consumed / forged token, or an internal store error) still left a *success* row, so brute-force / forged-token attempts were invisible in the audit log itself and surfaced only as a tracing `warn!(security_event=true)`. The dispatcher now records the audit row **after** dispatch with the real outcome — a rejected token writes `Denied` + `Failure(reason)`, a mint writes `System` + `Success` — which is the "allow OR deny" the surrounding comment always promised. The decision is a small pure helper (`redeem_audit_proof`) with a unit test pinning the failure-on-`Error` mapping. A security team relying on audit rows (not tracing) can now detect token brute-forcing. No WIT/contract change. Surfaced by the CLI/API sysadmin parity audit.
- **`self:agent:list` no longer leaks the full principal roster.** `AgentList` always resolves to `AuthorityScope::Self_`, so the required capability is `self:agent:list` — which the `agent` builtin holds via `self:*`. That lowering is deliberate (it lets an agent resolve its own group-inherited caps without being handed the admin-tier `agent:list`), but the kernel handler returned **every** principal's profile regardless of the caller — an information-disclosure: any ordinary `agent` could enumerate every other principal's id, groups, grants and revokes via `GET /api/sys/principals`. The gateway already documented the intended behaviour ("operators with `agent:list` see everyone; an `agent` with `self:agent:list` sees only themselves; the kernel filters server-side") — the kernel just never implemented it. `agent_list` now filters to the caller's own row unless the caller also holds the global `agent:list` capability; `self:*` does not match `agent:list`, so self-scoped callers are correctly narrowed. Both halves are required for the full roster — the `AgentList` preamble independently requires `self:agent:list`, which a bare global `agent:list` grant does not satisfy (the grammar does not make a global cap imply its self-scoped form) — so in practice only the `admin` group's `*` (matching both) sees everyone. Fail-secure: an unresolvable caller profile yields self-only. The CLI is unaffected (its `default` principal is admin-seeded). `GroupList` is intentionally left a full read — it is system config, not per-principal data, and an agent needs it to resolve group→capability inheritance. **Behavioural change** for API callers holding only `self:agent:list`. Surfaced by the CLI/API sysadmin parity audit.
- **Host `random-bytes` now sources from the OS CSPRNG.** The `astrid:sys/host.random-bytes` implementation filled buffers from `rand::thread_rng()` — a userspace ChaCha CSPRNG seeded from OS entropy — while the WIT contract and the Rust/JS SDKs all advertise the bytes as coming from "the host's OS-level CSPRNG". It now fills from `rand::rngs::OsRng` so the implementation matches the documented guarantee, and uses `try_fill_bytes` (not `fill_bytes`) so a practically-impossible entropy-source failure fails secure as `error-code::unknown(...)` instead of panicking inside the host call. No WIT/contract change required. Closes #799.
- **Security-response-headers stack applied to every gateway response.** The gateway returns JSON, SSE, plain text, and Prometheus — never HTML — but every response now carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, and `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`. The headers are `if_not_present` so a handler that intentionally sets one wins; defaults fill in everywhere else. Defends against MIME-confusion (nosniff), clickjacking against any HTML that lands in the surface later (DENY + CSP frame-ancestors), and `Referer` leakage of principal-id-bearing URLs to third-party origins (no-referrer). Two new e2e tests pin headers on success and 401 paths. Closes #771.
- **Bearer revocation on principal delete.** Session bearers shipped in v0.7.0 as 8-hour ed25519-signed tokens with no revocation mechanism — an admin who deleted a compromised principal still had to wait out the bearer lifetime (or rotate the gateway signing key, which logs out every other user too). The gateway now subscribes to the kernel's audit-event firehose (`astrid.v1.audit.entry`); when an `AgentDelete` admin op records a `success` outcome, the target principal is added to a per-gateway `revoked_at` map (persisted atomically to `$ASTRID_HOME/etc/gateway-revocations.json`) and every bearer with an `iat` at-or-before the recorded epoch is rejected by `verify_bearer`. Survives daemon restart. Resilient to clock skew between the kernel and the gateway because the timestamp baked into the revocation entry comes from the kernel's own audit envelope, not gateway-local wall clock. Closes #772.

## [0.7.0] - 2026-05-25

### Breaking

- **`KernelRequest` / `KernelResponse` / `CommandInfo` / `CapsuleMetadataEntry` / `DaemonStatus` / `SYSTEM_SESSION_UUID` moved from `astrid_types::kernel` to `astrid_core::kernel_api`.** Re-exports under `astrid_events::kernel_api` are preserved for migration ergonomics. The reason: `astrid-types` is the WASM-compatible shared-types crate intended to compile on `wasm32-unknown-unknown` for capsule SDK consumption — it cannot depend on `astrid-core` (which references `PrincipalId`, `Quotas`, and the rest of the kernel-only type universe). The kernel-management RPC surface (CLI ↔ daemon) doesn't belong in a WASM-compatible crate to begin with. CLI commands, `socket_client`, `admin_client`, the TUI, and the integration tests have all been updated to import from `astrid_core::kernel_api`.

### Added

- **`astrid:fs/host.fs-mkdir-all`** — unstubbed. Idempotent recursive directory creation via the existing VFS `mkdir` call (every VFS impl already routes through `std::fs::create_dir_all` under the hood). Capability gating, audit envelope (`astrid:fs/host.fs-mkdir-all`), and error mapping match `fs-mkdir`. Unblocks capsule code that wants the `std::fs::create_dir_all`-style idempotent variant instead of the strict `fs-mkdir`. (Closes one item of #753.)
- **Outbound TCP for capsules — `net.connect-tcp` host fn + `net_connect` capability.** Capsules can now open persistent TCP connections via the new `astrid:capsule/net.net-connect-tcp(host, port) -> stream-handle` host fn, gated by a per-capsule `net_connect = ["host:port", "host:*"]` allowlist in `Capsule.toml`. The returned handle flows through the existing `net-read` / `net-write` / `net-close-stream` plumbing, and the kernel reuses the same `is_safe_ip` airlock that gates `http-request` to reject loopback / private / link-local / multicast IPs after DNS resolution. Connect timeout bounded to 10s; per-capsule active-stream cap (`MAX_ACTIVE_STREAMS = 8`) shared with inbound `net-accept`. Unblocks WebSocket clients, MQTT, Discord/Telegram gateways, postgres/redis, and the immediate motivator: a Unicity-network capsule wrapping Sphere SDK (Fulcrum + Nostr WebSocket transports). Tracking issue: #745. RFC: [rfcs#27](https://github.com/unicity-astrid/rfcs/pull/27). WIT contract: [wit#5](https://github.com/unicity-astrid/wit/pull/5).
- **`NetStream` enum (Unix + Tcp)** in `engine::wasm::host_state` — replaces the bare `Arc<Mutex<UnixStream>>` value type in `active_streams`. The `net_read` / `net_write` dispatchers match on the variant; the inner framing (`read_frame` / `write_frame` generic helpers) is shared via `tokio::io::AsyncRead + AsyncWrite` trait bounds. Single-variant capsules see no behavior change.
- **`CapsuleSecurityGate::check_net_connect(capsule_id, host, port)`** — new trait method, default-deny. `ManifestSecurityGate` implements it by matching the requested `host:port` against the manifest's `net_connect` allowlist (case-insensitive host, exact-or-`*` port).
- **`astrid-build` is target-agnostic now.** Drops the hardcoded `--target wasm32-wasip2` flag on `cargo build`; instead lets the capsule's own `.cargo/config.toml` select the target. After compilation, probes `target/wasm32-unknown-unknown/release/` first, `target/wasm32-wasip2/release/` second (and the workspace root) for the produced `.wasm`. When the produced artifact is a core wasm module (no Component Model layer = 1 in the magic bytes) — which is what `wasm32-unknown-unknown` produces — `wit_component::ComponentEncoder::default().validate(true).module(&core).encode()` wraps it into a Component Model component. Required because the Astrid-canonical guest target is `wasm32-unknown-unknown` (zero `wasi:*` imports), and `cargo` does not produce a component directly for that target.
- **`astrid-build`'s `ensure_component` overwrites the original `.wasm` artifact** instead of writing a sibling `.component.wasm`. Capsule `Capsule.toml [[component]] file = "<crate>.wasm"` directives keep resolving without per-target conditional logic — the toolchain hides which target produced the artifact.
- **`astrid-types` `clock` feature.** Default OFF — gates `IpcMessage::new()` and the `#[serde(default = "Utc::now")]` timestamp default behind a Cargo feature. Kernel-side consumers (`astrid-events`, the daemon) enable it via their dep declaration. Capsule-side consumers (via `astrid-sdk` on `wasm32-unknown-unknown`) leave it off; absent timestamps fall back to the Unix epoch via a `from_timestamp(0,0)` default. Capsule code reads timestamps from kernel-published messages, never constructs them.
- **`chrono = { default-features = false, features = ["serde"] }`** workspace-wide. The `clock` feature is what pulls `wasm-bindgen` + `js-sys` on `wasm32-unknown-unknown`, which then inject `__wbindgen_placeholder__` imports that `wit-component`'s encoder refuses to round-trip. Records that need a clock value receive it from `astrid_sdk::time` (audited host fn); `DateTime<Utc>` is only used as a serializable field shape, never constructed via `Utc::now()` in capsule code.
- **`uuid = { default-features = false, features = ["v4", "v5", "serde", "rng-getrandom"] }`** workspace-wide. The default features pull a `js`-based RNG on `wasm32-unknown-unknown` (via `wasm-bindgen`). `rng-getrandom` routes v4 generation through `getrandom`, which `astrid-sys` configures with a custom backend (`astrid:sys.random-bytes`) on capsule builds. Same dep wiring in `sdk-rust`'s `astrid-sdk`.

### Changed

- **Kernel exposes zero `wasi:*`.** `configure_kernel_linker` no longer registers `wasmtime_wasi::p2::add_to_linker_sync`. The Astrid-canonical guest target (`wasm32-unknown-unknown`) produces wasm with zero `wasi:*` imports; a capsule that somehow ships with a `wasi:*` import fails to instantiate at load time with a clear "interface not found" error — the intended posture, not a bug. Capsules that historically targeted `wasm32-wasip2` and relied on auto-injected `wasi:*` imports will fail to load until they migrate to `wasm32-unknown-unknown` via the upcoming SDK + capsule sweep (a separate PR cluster, blocked on this one landing first).
- **`crates/astrid-capsule/src/manifest.rs` split into a `manifest/` submodule.** The 1000-line single file became `manifest/mod.rs` + `manifest/capabilities.rs` + `manifest/topics.rs`. `CapabilitiesDef`, `PublishDef`, `SubscribeDef` (with their custom deserializers and TOML parsing tests) live in dedicated submodules; the top-level `manifest::*` public API is preserved via `pub use` re-exports — no consumer-side change required.
- **`astrid-storage::kv` split into a submodule directory.** `kv.rs` (now ~1200 lines after the CAS addition) became `kv/mod.rs` (validators, helpers, trait, re-exports) + `kv/memory.rs` (`MemoryKvStore`) + `kv/surreal.rs` (`SurrealKvStore`, behind the `kv` feature) + `kv/scoped.rs` (`ScopedKvStore`), each well under the 1000-line CI ceiling. Public API preserved verbatim via `pub use`.
- **`wit/astrid-capsule.wit` resynced from canonical `unicity-astrid/wit`** to pick up the new `net-connect-tcp` fn and the `ipc-message.principal` field (canonical PR #4 from May; was missing from the in-tree copy). Internal `IpcMessage → WitIpcMessage` conversion now forwards `principal`.

### Fixed

- **`TcpStream::read_bytes` / `peek` return `Err(ErrorCode::Closed)` on cancellation.** Previously both methods collapsed cancel to `Ok(Vec::new())`, which is indistinguishable from a clean EOF in byte-stream reads. Capsules with EOF finalizers (write trailers, send last-message IPC, flush logs) would execute those finalizers under a forced unload. Now matches `read_frame` / `write_bytes` / `shutdown` — Closed surfaces cancellation as its own signal. Empty Vec retains its "clean EOF" meaning.
- **Per-domain WIT review fixups (PR #752).** A multi-agent review surfaced fixes addressed in-branch before merge:
  - `ipc::recv` mixed-principal batches are now truncated at the first publisher boundary so tail messages can't be silently mis-stamped with the head's principal context; new `truncate_to_homogeneous_principal` unit tests cover the boundary cases.
  - `TcpStream::write` surfaces peer-disconnect IO kinds as `ErrorCode::ConnectionReset` instead of swallowing them as `Ok(())`; pure-function tests pin the `BrokenPipe / ConnectionReset / ConnectionAborted / UnexpectedEof → ConnectionReset` mapping and use `tokio::io::duplex` to reproduce the live close.
  - `TcpStream::read` cancellation now returns `NetReadStatus::Closed` (not `Pending`) so a cancelled run-loop terminates instead of busy-looping.
  - `spawn_background` registers the spawned PID in the `ProcessTracker`, and the `ProcessHandle` drop path unregisters; previously a `tool.v1.request.cancel` could never reach backgrounded children.
  - `Subscription` recv keeps the resource handle valid across calls — the `EventReceiver` lives behind an `Arc<Mutex<...>>` so the wasmtime resource is no longer deleted-and-re-pushed each blocking wait.
  - `read_file` re-checks the post-read payload size for `TooLarge` rather than relying on a pre-stat TOCTOU.
  - `ProcessHandle::wait` uses `tokio::task::spawn_blocking(child.wait)` racing a `tokio::time::timeout` instead of the 50ms `try_wait` busy-poll.
  - `unix_listener::accept` retries credential-rejected connections with a 100ms back-off to avoid a CPU-pinned spin against a hostile peer.
  - `http-stream` per-chunk timeout extracted to `HTTP_STREAM_READ_TIMEOUT` named constant.
  - Build script now invalidates the WIT staging dir on `.gitmodules` changes so CI runners that lazily `git submodule update` don't compile against a stale tree.
- **Per-domain WIT review fixups round 2 (Gemini, PR #752).**
  - `MAX_ACTIVE_STREAMS` / `MAX_SUBSCRIPTIONS` / `MAX_BACKGROUND_PROCESSES` quota gates now read O(1) counter fields on `HostState` (`net_stream_count`, `subscription_count`, `process_count_total`, `process_count_by_principal`) instead of walking the entire `ResourceTable`. Each successful resource insert bumps the counter; the matching `drop` impl decrements. Per-principal sub-budgets for `spawn-background` use a `HashMap<PrincipalId, usize>` keyed on the creator. Single-threaded: wasmtime stores are owned by exactly one OS thread.
  - CI workflows (`ci.yml`) now check out the `wit/` submodule recursively so `astrid-capsule`'s build script can stage the per-domain WIT packages. Previously every job that touched the build (`check`, `clippy`, `test`, `msrv`) panicked with `read wit/host: No such file or directory`.
- **Atomic `kv_cas` across capsules.** The host fn used to emulate compare-and-swap with a `get` followed by a `set`, which raced across capsules running concurrently in the same kernel. `KvStore` now exposes an atomic `compare_and_swap(namespace, key, expected, new)` primitive; `MemoryKvStore` serializes read+conditional-write under its existing `RwLock`, and `SurrealKvStore` runs the comparison inside a single MVCC transaction guarded by an internal mutex that closes the TOCTOU between surrealkv's `validate_write_conflicts` and its actual write phase. Two concurrent-spawn regression tests (memory + surreal) assert exactly one of 16–32 racing tasks succeeds the swap. The capsule `kv_cas` host fn delegates straight to the new primitive — no more emulation.

## [0.6.0] - 2026-05-19

### Breaking

- **Native subprocess capsules refuse to launch when the OS-level sandbox is unavailable (security, fixes #655).** Previously, when `bwrap` failed its user-namespace probe — most commonly on Ubuntu 24.04+ where `kernel.apparmor_restrict_unprivileged_userns=1` ships enabled — Astrid silently fell through to an unsandboxed launch with a single `tracing::warn!` line as the only signal. This contradicted the README's "subprocess capsules are sandboxed" promise: a Node.js MCP server (or OpenClaw Tier 2 plugin) could read `~/.ssh/id_rsa`, write to `~/.bashrc`, or punch through the `~/.astrid` tmpfs overlay without any capability check firing. The new default policy is `Required`: `ProcessSandboxConfig::sandbox_prefix()` returns an actionable `Err` instead of `Ok(None)` when the sandbox can't be applied, and the MCP server-startup path propagates the error so the daemon refuses to launch the subprocess. The only escape hatch is `ASTRID_SANDBOX_POLICY=off`, which silently launches without a sandbox — for trusted dev environments and CI runners where the kernel can't be configured. There is **no** "warn and fall through" middle state: a soft fallback hides the security gap and that is the bug. The error message names the sysctl remediation (`sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`) and the env-var escape hatch directly. **Breaking for any Ubuntu 24.04+ install that was tacitly relying on the silent fallback** — those deployments now need to either flip the sysctl (recommended) or set `ASTRID_SANDBOX_POLICY=off` (only if the sandbox bypass is intentional).
- **`caps grant <agent> "*"` now requires `--unsafe-admin` acknowledgement.** Mirrors the long-standing rail on `group create --caps "*"`. Without it, the group-level safety check was trivially bypassable: instead of creating a custom admin-equivalent group (kernel rejects without `unsafe_admin = true`), an operator could `caps grant bob "*"` and silently promote `bob` to universal admin via direct grant. Layer 6's `mutate_caps` now refuses any `Grant` that includes the literal bare `*` capability unless `unsafe_admin` is true on the request; the CLI surfaces this as `caps grant bob "*" --unsafe-admin` and rejects the call client-side before any IPC round-trip. Multi-segment wildcards (`network:egress:*`, `self:capsule:*`) are unaffected — they're inherently scoped, the gate only triggers on the universal pattern. **Breaking for automation:** the new `AdminRequestKind::CapsGrant.unsafe_admin: bool` field defaults to `false` via `#[serde(default)]` so wire-format clients that omit it land on the safe side, but any tooling that previously assumed a bare `*` grant would succeed must now pass `unsafe_admin = true` (or set `--unsafe-admin` on the CLI).
- **`PrincipalProfile` files moved out of the principal home directory.** Per-principal `profile.toml` now lives at `~/.astrid/etc/profiles/{principal}.toml` instead of `~/.astrid/home/{principal}/.config/profile.toml`. Profile contents are 100% system policy (enabled, groups, grants, revokes, quotas, auth public keys, egress, process allowlist) — keeping them inside the principal's home directory let any capsule with `fs_read = ["home://"]` read its own policy file (and `fs_write` would have let it self-elevate). The new location sits outside the `home://` VFS scheme entirely. `PrincipalProfile::path_for(&PrincipalHome)` is now `PrincipalProfile::path_for(&AstridHome, &PrincipalId)`; same for `load`/`save`. A one-shot migration in `seed_default_principal_admin_profile` moves any legacy `home/{principal}/.config/profile.toml` to the new location on next boot. (#672)
- **`AdminKernelRequest` and `AdminKernelResponse` are now wrapper structs** with the typed body on a `kind`/`body` field, plus an optional `request_id` for client-side correlation. Pre-existing test fixtures using `AdminKernelRequest::AgentCreate { ... }` should construct the variant on `AdminRequestKind` and convert (`AdminRequestKind::AgentCreate { ... }.into()` or `AdminKernelRequest::new(...)`). The wire format is forward-compatible: `request_id` is omitted when `None`. (#672)
- **`AuditAction::AdminRequest` gained a `params: Option<serde_json::Value>` field.** Forward-compatible (`#[serde(default)]` + `skip_serializing_if`), but external consumers parsing audit entries with strict schemas may need to add the field. Capture is for forensic replay (issue #672). (#672)
- **`PermissionError` gained a `PrincipalDisabled` variant** thrown by the Layer 5 enforcement preamble when the caller's profile has `enabled = false`. Existing `match` blocks against the enum need a new arm. (#672)
- **`Kernel.groups` field type changed from `Arc<GroupConfig>` to `Arc<ArcSwap<GroupConfig>>`** (issue #672 — Layer 6). The boot-loaded group config is now hot-reloadable through the admin IPC topics (`astrid.v1.admin.group.*`); every authorization check clones the current `Arc` via `load_full()` on each request. Enforcement preambles hold their own `Arc` snapshot per check, so in-flight checks observe a consistent config even during a swap. Any direct consumer matching on `Arc<GroupConfig>` must migrate to `kernel.groups.load_full()`.
- **Built-in `agent` group gains `self:quota:get` and `self:agent:list` capabilities** (issue #672). `self:*` already subsumed both, but operators inspecting or matching on the exact capability vector see a minor widening. This makes agent self-service visibility into their own row and quotas an explicit contract rather than an incidental consequence of `self:*`.
- **Default principal's profile now carries `groups = ["admin"]` after boot (issue #670).** `bootstrap_cli_root_user` writes `groups = ["admin"]` to `~/.astrid/home/default/.config/profile.toml` on any boot where the profile is absent or has `groups=[] && grants=[] && revokes=[]`. Operators who previously ran with an explicitly empty `groups` list — and no grants/revokes — will see the default principal gain full management-API capabilities on the next boot. Either edit the profile to add a non-admin group (e.g. `restricted`), or add an explicit `grants`/`revokes` entry, to block the auto-seed. Profiles that already name any group, grant, or revoke are left untouched. (#670)
- **`CapabilityToken` signing format bumped v1 → v2** with `principal` signed into the payload (Layer 4 multi-tenancy, issue #668). Existing v1 persistent tokens on disk fail `verify_signature()` after upgrade and get rejected with `InvalidSignature` plus a `tracing::error!` pointing operators at re-mint. There is no silent migration path — changing the signing payload is a cryptographic break, not a data migration. `CapabilityToken::create` and `create_with_options` now take a required `principal: PrincipalId` argument. (#668)
- **`Allowance.principal: PrincipalId`** is now required at construction. `AllowanceStore` keys on `(principal, id)`; `find_matching`, `find_matching_and_consume`, `consume_use`, `export_session_allowances`, `export_workspace_allowances`, and `clear_session_allowances` now take `&PrincipalId`. A new `clear_all_session_allowances` retains the global sweep for kernel-initiated shutdown. (#668)
- **`CapabilityStore::has_capability` / `find_capability` now take `&PrincipalId`.** Tokens whose `principal` does not match the caller are rejected up front, even if the resource pattern matches — fail-closed cross-principal check. Revocation and single-use consumption stay global (they are about the token's identity, not the caller). Persistent KV keys changed from `caps:tokens/{token_id}` to `caps:tokens/{principal}/{token_id}`. `CapabilityValidator::check` and `validate_by_id` also thread `&PrincipalId`. (#668)
- **`Kernel.active_connections` is now per-principal** (`DashMap<PrincipalId, AtomicUsize>`). `connection_opened(&PrincipalId)` / `connection_closed(&PrincipalId)` take the connecting principal; only that principal's session allowances are cleared on last-disconnect. New `total_connection_count()` sums across principals for ephemeral-shutdown. (#668)
- **`Kernel.overlay_vfs` replaced by `Kernel.overlay_registry: Arc<OverlayVfsRegistry>`.** Each invoking principal resolves their own `OverlayVfs` on first use; Agent A's workspace writes never reach Agent B's view of the same tree. The registry is bounded (default 1024 principals, tunable via `ASTRID_OVERLAY_REGISTRY_MAX_PRINCIPALS`) with idle-eviction. `Kernel.vfs` now points at a plain workspace `HostVfs` — kernel-internal paths that do not know a principal keep using that field. (#668)
- **`SecurityInterceptor::intercept` and `ApprovalManager::check_approval` take `&PrincipalId`.** Single-tenant callers pass `PrincipalId::default()`. (#668)
- **`ApprovalDecision::ApproveWithAllowance` now boxes `Allowance`** (`Box<Allowance>`) — the added `principal` field pushed the enum past clippy's `large_enum_variant` threshold. (#668)
- **WASM engine migrated from Extism to wasmtime Component Model.** The kernel now loads Component Model binaries via `Component::from_binary`, not Extism modules. Existing capsules compiled with `extism-pdk` will not load — they must be rebuilt with the migrated SDK targeting `wasm32-wasip2`. This is a coordinated multi-repo migration (SDK + 16 capsule repos). (#632)
- **WIT host function signatures retyped.** All 49 functions now use proper typed params/returns (`result<T, string>`, WIT records, `u64` handles) instead of `string`-based JSON blobs. The `HostResult` 0x00/0x01 prefix encoding is removed — errors are returned via WIT `result` types. (#632)
- **Guest export `astrid-hook-trigger` signature changed.** Was `func(input: list<u8>) -> list<u8>`. Now `func(action: string, payload: list<u8>) -> capsule-result`. The action name and payload are separate typed parameters; the return is the typed `capsule-result` record. (#632)
- **`capsule_abi` module removed from `astrid-core`.** Types (`CapsuleAbiContext`, `CapsuleAbiResult`, `LogLevel`, etc.) are replaced by `wasmtime::component::bindgen!` generated types. (#632)
- **Approval API simplified.** `risk-level` removed from `approval-request` WIT record. `decision` removed from `approval-response`. Capsules declare action + resource, get back approved/denied. Risk classification was speculative complexity — the kernel manages allowance-based approval without risk levels. (#641)

### Added

- **`astrid -p ... --session <ID_OR_NAME>` accepts either a UUID or a name.** A string that parses as a UUID is used as-is for resume; anything else is treated as a stable session name and hashed via UUID v5 (`NAMESPACE_URL`). Operators can copy the UUID printed by `--print-session` straight into `--session <that UUID>` for the next turn and round-trip works, matching the `cargo` / `gh` / `claude` convention on the same flag. Help text uses value name `ID_OR_NAME`.
- **`agent create` inherits `default`'s per-principal state out-of-the-box.** After the home tree provisions, the kernel handler copies env JSON (`~/.astrid/home/default/.config/env/<capsule>.env.json` → `home/<new>/.config/env/...`), per-capsule KV namespaces (`default:capsule:<id>:*` → `<new>:capsule:<id>:*`), and per-capsule secret files (`secrets/default/<capsule>/<key>` → `secrets/<new>/<capsule>/<key>`) for every `env_type = "secret"` field every installed capsule declares. The new agent reaches the chat REPL with the same model selection, base URLs, and credentials the operator already configured for default — no fresh `astrid secret set` for every key on every new agent. Best-effort: a copy failure logs `warn` and leaves the agent in a "needs manual setup" state, but doesn't roll back the profile or home tree (those already succeeded; the confidentiality boundary is intact regardless). The registry read lock is held only while extracting capsule IDs + per-capsule secret-key lists — the async KV copy and blocking file-system copy run after the lock is dropped so concurrent install/update/remove isn't blocked for the duration of the inheritance.
- **`agent create` rich provisioning flags now chain end-to-end (no new IPC required).** `--group`, `--memory`, `--timeout`, `--storage`, `--processes`, `--egress`, `--process-allow` used to all bail with "needs kernel-side IPC that has not shipped" and fall back to a copy-pasteable three-line script. The flags now compose using the existing admin handlers: `--group` passes through to `AgentCreate.groups`; the quota flags fetch the new agent's default `Quotas` and write them back via a single `QuotaSet`; `--egress` / `--process-allow` translate to `network:egress:<label>` and `process:spawn:<cmd>` capability patterns. All inputs (quota specs, capability labels) are validated client-side before any IPC and the capability grants ride along on `AgentCreate.grants` so a malformed pattern can't leave a half-provisioned agent on disk. `--bare`, `--distro` (non-default), and `--link` still bail with a clear "needs distro / admin.agent.link IPC" message — those genuinely need new admin topics. Tested live: `agent create alice --group admin --memory 32M --timeout 60s --storage 512M --processes 4 --egress openai,internal --process-allow git,npm` lands all four quota fields, four capability grants, and the admin group membership atomically.
- **`agent modify --add-group / --remove-group` now actually works.** Previously the CLI parsed the flags but emitted "needs `admin.agent.modify` IPC that has not shipped" and bailed at exit 2; operators worked around it by hand-editing `etc/profiles/{p}.toml` and rebooting the daemon. The new `AdminRequestKind::AgentModify { principal, add_groups, remove_groups }` variant ships the partial-update path end-to-end: per-group idempotent (set-based comparison so a no-op modify reports `changed = false` regardless of Vec ordering), profile-validate before save, cache-invalidate after, audit-logged as `admin.agent.modify`. Empty (no flags) is rejected with a clear error so a script that forgot to populate either list can't silently no-op.
- **Cargo-like manifest schema (forward-compatible parser side).** `Capsule.toml` now accepts the new `[publish]` / `[subscribe]` tables, the flat `[exports]` / `[imports]` form with quoted `"namespace:interface"` keys, and `[[tool]]` blocks with the operator-reviewable `description_for_llm` field — all per the cargo-like-manifest RFC. Each `[publish]` / `[subscribe]` entry carries a required `wit = "@scope/repo/iface/record"` (or bare-name self-reference) plus optional `version` / `tag` / `rev` / `branch` / `path` source pinning and `handler = "..."` to bind to a `#[astrid::interceptor("...")]` export. Keys in those tables also serve as the IPC publish/subscribe ACL — when present, they supersede `[capabilities].ipc_publish` / `ipc_subscribe`. The legacy nested `[exports.<ns>] foo = "1.0"` form, the `[[topic]]` blocks, the `[[interceptor]]` blocks, and the `[capabilities].ipc_publish` / `ipc_subscribe` arrays continue to parse and behave exactly as before — both forms coexist during the migration window. Resolver, registry index, and lockfile (BLAKE3 verification) plumbing land in a follow-up; today the parser stores `wit` refs as strings without resolving them. The parser rejects entries that set more than one of `version` / `tag` / `rev` / `branch` / `path` at deserialize time so ambiguous manifests fail fast at install instead of producing inconsistent resolver behaviour.
- **`EnvScope { Agent, Shared }` enum on `EnvDef`.** Operator-only sharing model for `env_type = "secret"` env vars: per-agent (default, fail-closed) or host-wide (shared, kernel falls through on per-agent miss). The capsule manifest does NOT declare scope — capsules come from external sources and cannot be trusted to mark their own credentials as host-shared (a malicious capsule could otherwise mark its bot token "shared" and inherit every agent's invocation budget). The operator decides scope at `astrid secret set --scope` time. Additive: the consuming routing change ships in a separate PR.
- **`astrid setup` subcommand + bundled `AppArmor` profile (follow-up to #655).** Ships `dist/apparmor/astrid` — a narrow `flags=(unconfined)` profile whose only addition is the `userns,` rule — and an `astrid setup` subcommand that diagnoses the Linux sandbox prerequisites and prints the exact commands (or runs them via `sudo` with `--apply`) to install the profile at `/etc/apparmor.d/astrid`. With the profile loaded, `bwrap` regains the ability to call `unshare(CLONE_NEWUSER)` on Ubuntu 23.10+ / 24.04 hosts that keep `kernel.apparmor_restrict_unprivileged_userns=1` enabled, so native subprocess capsules (MCP servers, OpenClaw Tier 2 plugins) launch under the default `SandboxPolicy::Required` without the operator having to flip a kernel-wide sysctl (which would weaken every other unprivileged process on the host, not just Astrid). `astrid setup --print-apparmor` emits the profile alone — distro packagers can pipe it straight into a `.deb` / `.rpm` build without invoking the diagnostic path. The subcommand is a no-op on non-Linux hosts and on Linux hosts where the sandbox probe already passes.
- **`ipc-publish-as` host function + uplink principal propagation.** New WIT entry (`astrid:host/ipc.ipc-publish-as`) and SDK wrappers (`publish_as` / `publish_json_as`) stamp the outgoing IPC envelope with a caller-supplied principal instead of the capsule's own. Used by uplinks (CLI proxy, future Telegram/Discord bridges) to relay external client traffic while preserving the operator's claimed identity through to the kernel's caller resolution and Layer 5/6 capability enforcement. Gated host-side on `uplink = true` in `Capsule.toml [capabilities]` (the gate uses `manifest.capabilities.uplink` — the matching `has_uplink_capability` field in `HostState` was previously bound to `!manifest.uplinks.is_empty()`, which is the unrelated `[[uplink]]` declaration list). Before this, every uplink-forwarded message landed at the kernel as `principal = <uplink's principal>` and `astrid agent switch` was purely cosmetic — admin commands as `alice` executed as `default` and bypassed RBAC. The IPC quota bucket is also keyed by the relayed principal so two principals routed through the same uplink cannot starve each other's per-tenant budget. Trust-the-uplink model: the kernel does not verify the uplink authenticated the claimed principal — same trust level as today's "anyone with the daemon socket token can act as default-admin", just extended to the per-principal axis. Cryptographic per-connection auth lives in #658.

- **CLI redesign — modern noun-verb structure with multi-tenancy admin surface (issue #657).**
  - The CLI is now modelled after `gh` and `fly`: `astrid agent <verb>`, `astrid capsule <verb>`, `astrid quota <verb>`, etc.
  - System-level operations (`status`, `start`, `stop`, `restart`, `ps`, `top`, `who`, `logs`) stay as bare verbs for speed.
  - `astrid` with no subcommand still drops the operator into an interactive agent session — the unchanged self-hosting path.
  - Phase 1 covers the admin-surface bulk unblocked by Layer 6 (#672); sub-agent delegation, vouchers, audit, trust, budget, and remote A2A surfaces are registered in the clap tree but stubbed with tracking-issue references (`#656`, `#658`, `#675`, `#653`) and exit code 2 so CI scripts can pattern-match.
  - New verbs implemented end-to-end against the Layer 6 admin IPC: `agent create / list / show / delete / enable / disable / switch / current`, `group create / list / show / delete / modify`, `caps show / grant / revoke / check`, `quota show / set`, `secret set / list / delete`, `capsule install / update / list / remove / tree / build / config / show`, `distro apply`, `gc`, `restart`, `logs`, `doctor`, `version`, `completions`, `update`, `ps`, `top`, `who`.
  - Every list/show command supports `--format pretty|json|yaml|toml`.
  - Per-agent commands accept `--agent <name>`/`-a` to override the active context; without the flag they fall back to `~/.astrid/run/cli-context.toml` written by `astrid agent switch`.
  - Migration aliases retained: `session info` → `session show`, `self-update` → `update`, `wit gc` → `gc`, top-level `build` → `capsule build`.
  - New `admin_client` module wraps `SocketClient` with `request_id` correlation (UUID v4 per call, echoed on `astrid.v1.admin.response.<topic>`) so multiple in-flight admin commands can disambiguate responses, and tolerates broadcast frames (`astrid.v1.capsules_loaded`) whose payloads do not round-trip through `IpcPayload::RawJson`'s tag.
  - New `read_until_topic` helper on `SocketClient` propagates the same skip-and-retry pattern to the bare verbs. (#657)

- **`PrincipalProfile.enabled` is now enforced by the Layer 5 management-API preamble.** Pre-Layer-6 the flag was set on disk by `agent.disable` but never consulted by `authorize_request` — operators who disabled an agent saw the flag persist while the agent kept passing authz checks. The preamble now resolves the caller's profile, and if `enabled = false` returns the new `PermissionError::PrincipalDisabled` variant before the capability check. (#672)
- **`PrincipalProfile.enabled` is also now enforced at Layer 3 (`WasmEngine::invoke_interceptor`).** Pre-fix, only the management API honored the flag; capsule invocations bypassed it entirely. The Layer 3 gate runs right after profile cache resolution and returns `CapsuleError::WasmError("principal '{p}' is disabled")` with a `security_event = true` log. In-flight invocations finish under the old value (we only check at entry); new invocations after `agent.disable` are refused. Together with the Layer 5 gate, `agent.disable` now denies *every* surface a principal can drive. (#672)
- **Phantom-principal pre-condition** on every mutating admin handler. `caps.grant`, `caps.revoke`, `quota.set`, `agent.enable`, and `agent.disable` now require the target's `profile.toml` to already exist on disk. Without this gate, a typo'd principal name (`alic` vs `alice`) silently materialized a phantom principal — `PrincipalProfile::load_from_path` returns `Default` on `NotFound`, the handler then saved the mutated default to disk, and any future traffic claiming that principal inherited the phantom permissions. `quota.get` got the same gate so a typo doesn't return Default-shaped quotas without revealing the mistake. (#672)
- **`agent.delete` removes `profile.toml`.** Pre-fix the handler unlinked the identity and invalidated the cache but left the policy file on disk; subsequent traffic claiming that principal would re-load the old policy. Home-directory data (capsule KV, audit chain) is still left intact — that's an ops-managed concern. (#672)
- **`agent.disable` and `caps.revoke` against the `default` principal are rejected.** The `default` principal is the bootstrap admin anchor; either operation could lock the operator out of the management API entirely (the new `enabled` gate denies every request from a disabled principal, and `caps.revoke self:*` would clear the operator's grants). `caps.grant` and `quota.set` on `default` remain allowed — they only add/adjust, never remove. (#672)
- **`AdminKernelRequest` now carries an optional `request_id`** echoed back on `AdminKernelResponse`. Lets clients with multiple in-flight requests on the shared `astrid.v1.admin.response.<topic>` channel disambiguate responses. The wire shape is `{ "request_id": "...", "method": "...", "params": {...} }` — `request_id` is `skip_serializing_if = Option::is_none`, so single-client deployments emit no extra field. The typed body lives on `AdminKernelRequest.kind: AdminRequestKind` (the previous enum, renamed). (#672)
- **`AuditAction::AdminRequest.params: Option<serde_json::Value>`** captures the request payload (capabilities granted, quotas set, group definition, etc.) so forensic replay doesn't require diffing `profile.toml`/`groups.toml` snapshots. `None` for legacy `KernelRequest` entries with no params struct. Forward-compatible add (`#[serde(default)]` + `skip_serializing_if`). (#672)
- **Layer 6 management IPC for agent lifecycle, quotas, groups, and capability grants (issue #672).** New `astrid.v1.admin.*` IPC surface with 13 topics covering agent (`create`, `delete`, `enable`, `disable`, `list`), quotas (`set`, `get`), groups (`create`, `delete`, `modify`, `list`), and per-principal capabilities (`grant`, `revoke`). Every topic runs through the existing Layer 5 `CapabilityCheck::require` preamble — there is no new authz mechanism. Capability mappings:
  - `admin.agent.create|delete|enable|disable` → `agent:create|delete|enable|disable`
  - `admin.agent.list` → `self:agent:list` (self) / `agent:list` (cross-tenant)
  - `admin.quota.set|get` → `self:quota:set|get` / `quota:set|get`
  - `admin.group.create|delete|modify|list` → `group:create|delete|modify|list`
  - `admin.caps.grant|revoke` → `caps:grant|caps:revoke`

  Mutating topics acquire a new `tokio::sync::Mutex<()>` (`Kernel.admin_write_lock`) so concurrent writers never interleave on `profile.toml` / `groups.toml`. Every profile-mutating handler (`quota.set`, `caps.grant`, `caps.revoke`, `agent.enable`, `agent.disable`) calls `profile_cache.invalidate(&target)` after the atomic write so subsequent authz checks reflect the new state without waiting on kernel restart. Group admin topics rewrite `groups.toml` atomically (tempfile + rename + `0o600` on Unix) and then `kernel.groups.store(Arc::new(new_config))` the `ArcSwap` — in-flight checks holding the old `Arc` finish under the old config, the next `load_full` sees the new one. Built-in groups (`admin`, `agent`, `restricted`) cannot be deleted or modified; `default` principal cannot be deleted. `caps.grant` never clears a matching revoke — Layer 5 precedence (revoke > grant > group) is preserved. Every allow and deny writes `AuditAction::AdminRequest` with `method` set to the topic wire name (`admin.agent.create`, etc.) and `target_principal` set when operating on another principal. `agent.delete` removes the `AstridUserId` and CLI identity link but does not scrub the home directory — reclamation is an ops concern. (#672)
- **`AdminKernelRequest` / `AdminKernelResponse` wire types** in `astrid-types::kernel` with tagged serde (`#[serde(tag = "method", content = "params")]`), mirroring the shape of `KernelRequest` / `KernelResponse`. Response variants include `Success(Value)`, `AgentList(Vec<AgentSummary>)`, `GroupList(Vec<GroupSummary>)`, `Quotas`, and `Error`. (#672)
- **Atomic `GroupConfig::save_to_path` + `save`** in `astrid-core::groups::io_impl` — tempfile + rename + `0o600` on Unix, mirroring `PrincipalProfile::save_to_path`. On rename failure the tempfile is removed so no secret-adjacent stale state is left on disk. (#672)
- **`GroupConfig::insert_custom_group`, `modify_custom_group`, `remove_group`, `is_builtin_name`** — immutable-value mutators that return a new `GroupConfig` with the requested change applied. Built-in names are rejected; modify/remove of unknown custom groups returns the new `GroupConfigError::UnknownGroup` variant (vs `DuplicateName` for insert collisions). (#672)
- **`IdentityStore::delete_user` and `list_users`** on the trait and `KvIdentityStore` — required by `admin.agent.delete` (delete user record + all links pointing at it, idempotent for unknown UUIDs) and `admin.agent.list` (enumerate user records). The name-index entry is cleared only when it still points at the deleted UUID (last-writer-wins survives). (#672)
- **`arc-swap` workspace dependency** for lock-free hot-reloadable `GroupConfig`. (#672)
- **Capability/group enforcement on the management API (issue #670).** Every arm of `kernel_router::handle_request` now runs through a `CapabilityCheck` enforcement preamble before reaching the handler body. Each `KernelRequest` variant maps to a required capability (`Shutdown → system:shutdown`, `ReloadCapsules → self:capsule:reload`, `InstallCapsule → self:capsule:install`, `ListCapsules`/`GetCommands`/`GetCapsuleMetadata → self:capsule:list`, `GetStatus → system:status`, `ApproveCapability → self:approval:respond`); no default-allow branch. The caller's principal is resolved from `IpcMessage.principal` (falling back to `PrincipalId::default()` for pre-#658 single-token socket traffic), their `PrincipalProfile` comes from the profile cache, and the new `GroupConfig` is boot-loaded from `$ASTRID_HOME/etc/groups.toml` (missing file → built-ins only). Precedence follows revoke > grant > group-inherited, so operators can revoke specific capabilities from admins without dropping the admin membership. Built-in groups (`admin`, `agent`, `restricted`) cannot be redefined at load time; custom groups must opt-in via `unsafe_admin = true` to grant the universal `*`. Every allow and deny outcome writes a new `AuditAction::AdminRequest { method, required_capability, target_principal }` audit entry (chain-linked, signed). The `default` principal is seeded with `groups = ["admin"]` on first boot so single-tenant deployments keep full access with no config. `CapabilityCheck` is a pure function — no I/O, no locking — and is a distinct namespace from the runtime `CapabilityToken` system (which continues to gate capsule-level sensitive actions). (#670)
- **`GroupConfig` + `Group` in `astrid-core::groups`** with a TOML loader at `$ASTRID_HOME/etc/groups.toml`, baked-in `admin`/`agent`/`restricted` built-ins, fail-closed handling for missing/malformed files, rejection of built-in redefinition, rejection of capability strings containing `**` or shell metacharacters, and a `Group::unsafe_admin` opt-in for custom groups that need the universal `*` capability. (#670)
- **`PrincipalProfile.grants` and `PrincipalProfile.revokes`** (per-principal capability overrides) validated at load/save time via the new `astrid_core::capability_grammar::validate_capability` helper. Revokes have strict precedence over grants and group-inherited capabilities. (#670)
- **`CapabilityCheck` + `PermissionError` in `astrid-capabilities`** — the policy-evaluation primitive. Borrowed evaluator over `(&PrincipalProfile, &GroupConfig)` with `has(&str) -> bool` and `require(&str) -> Result<(), PermissionError>`. Pure, thread-safe, zero-alloc on the hot path. (#670)
- **`AuditAction::AdminRequest { method, required_capability, target_principal }`** — new audit action for management-API requests, written with `AuthorizationProof::System` on allow and `AuthorizationProof::Denied` on deny. (#670)
- **Principal-scoped `AllowanceStore`, `CapabilityStore`, `OverlayVfs`, and connection counter (Layer 4).** `Allowance.principal` and `CapabilityToken.principal` are now required construction-time fields. All store lookups are principal-filtered up front; Agent A's approvals and capabilities never match Agent B. A new `astrid_vfs::OverlayVfsRegistry` gives each invoking principal a fresh `OverlayVfs` on first use, with a bounded (default 1024) LRU-evicting cache keyed by `PrincipalId`. `Kernel.active_connections` became a per-principal `DashMap`; only the disconnecting principal's session allowances are cleared, while ephemeral shutdown still waits on the global `total_connection_count()`. `WasmEngine::invoke_interceptor` resolves the invoking principal's overlay on every call and installs it on `HostState.invocation_overlay_vfs`. Single-tenant deployments pass `PrincipalId::default()` and see no behavior change. Extends invariants #6 and #7 from issue #653 (Agent A cannot use Agent B's approvals or capabilities). (#668)
- **Per-invocation quota enforcement from `PrincipalProfile` (Layer 3).** `WasmEngine::invoke_interceptor` now resolves the invoking principal's `PrincipalProfile` through a new kernel-scoped `PrincipalProfileCache` (`astrid_capsule::profile_cache`) and applies per-invocation: `max_memory_bytes` via a rebuilt `StoreLimits`, `max_timeout_secs` via the epoch deadline (non-daemon capsules only), and `max_ipc_throughput_bytes` via the rate limiter. `IpcRateLimiter` is rekeyed from `Uuid` to `(Uuid, PrincipalId)` so two principals sharing a single capsule instance never starve each other's throughput. `ManagedProcess` and active HTTP streams are tagged with the creator principal and counted per-principal against the existing per-capsule hard ceilings (`MAX_BACKGROUND_PROCESSES = 8`, `MAX_ACTIVE_HTTP_STREAMS = 4`) — the effective cap is always `min(profile, hard_cap)`. Profile load failures (malformed TOML, unknown fields, invalid values, future `profile_version`) fail the invocation closed; there is no fallback to the capsule owner's limits. `max_storage_bytes` is read but not enforced (no storage accountant yet; deferred). Single-tenant deployments without a `profile.toml` get Layer 2's `Default` profile and see no behavior change. (#666)
- **Per-principal `PrincipalProfile` + `profile.toml`.** New `astrid_core::profile` module with the per-principal policy struct (enablement, groups, auth methods, network egress, process spawn, resource quotas) plus loader and atomic writer at `~/.astrid/home/{principal}/.config/profile.toml`. Missing file falls back to `Default`; malformed TOML, unknown fields, failed validation, or a future `profile_version` are hard errors. Save is atomic on Unix (temp write at `0o600`, then `rename`). Validation fires on both load and save. Pure data plumbing — Layer 3 enforcement in `invoke_interceptor`, hot-reload, management IPC, and CLI are separate follow-ups. (#663)
- **Content-addressed WIT store** at `~/.astrid/wit/{blake3}.wit`. Install-time WIT files (including `deps/`) are recursively hashed, deduped, and stored with atomic writes. Per-capsule `wit/` is removed after addressing; `meta.json.wit_files` is the authoritative manifest. Append-only by design for replay preservation. (#649)
- **`astrid wit gc`** — admin-only mark-sweep GC for the WIT content store. Dry-run by default, `--force` to delete. Scans all principal homes + workspace. (#649)
- **Per-invocation `home://` and `/tmp/` VFS scoping.** A shared capsule serving multiple agents now resolves `home://` and `/tmp/` against the *invoking* agent's home directory (`~/.astrid/home/{principal}/`) instead of the capsule owner's. The security gate accepts a `principal_home` parameter and `WasmEngine::invoke_interceptor` builds a per-principal VFS bundle when the invocation principal differs from the capsule's. Unregistered principals (no home directory on disk) receive a clean denial — the kernel does not auto-create principal homes. Single-tenant installs (all traffic under `default`) see no behavior change. Precursor to multi-tenancy (#653). (#549)
- **Per-invocation `SecretStore` and capsule log re-scoping.** `has_secret` now reads secrets from the *invoking* agent's KV namespace (and OS keychain scope), and `astrid_log` writes to the invoking agent's `~/.astrid/home/{principal}/.local/log/{capsule}/{date}.log`. `HostState` gains `invocation_secret_store` / `invocation_capsule_log` fields plus `effective_secret_store()` / `effective_capsule_log()` accessors; `WasmEngine::invoke_interceptor` installs per-principal resources alongside the existing invocation VFS bundle and clears them on exit. Unregistered principals receive `None` — no attacker home is auto-created. Finishes #653 Layer 1's side-channel isolation started in #659. (#661)

- **WIT-driven IPC topic schemas.** Capsules declare `wit_type = "record-name"` on `[[topic]]` entries in `Capsule.toml`. At install time, `wit-parser` reads the record from the capsule's `wit/` directory, extracts field names, types, and `///` doc comments into JSON Schema, and bakes it into `meta.json`. At runtime, `WasmEngine::load()` populates the `SchemaCatalog` from baked schemas. The LLM sees typed field descriptions without capsule authors writing JSON Schema by hand. (#643)
- `astrid-build::wit_schema` module — converts WIT records to JSON Schema. Handles primitives, `option<T>`, `list<T>`, tuple, enum, flags, variant, result, nested records, and type aliases. (#643)
- `wit_type: Option<String>` field on `TopicDef` in `Capsule.toml` — references a WIT record by kebab-case name. (#643)
- Schema catalog (`SchemaCatalog`) for A2UI Track 2 — maps IPC topics to schema definitions. Populated at capsule load time from baked `meta.json` schemas. (#632, #643)
- Epoch-based WASM timeout with `EpochTickerGuard` RAII type — replaces Extism wall-clock timeout. 5-minute deadline for interceptors, u64::MAX for daemons/run-loops, 10-minute safety net for lifecycle hooks. (#632)
- 64MB per-capsule WASM memory limit via `StoreLimitsBuilder` (matches old Extism setting). Global budget for multi-tenant hosting is a follow-up (#639). (#632)
- New WIT record types: `spawn-request`, `interceptor-handle`, `net-read-status` (variant), `capability-check-request/response`, `identity-*-request`, `elicit-request`. (#632)

### Changed

- **Persistent daemon no longer auto-shuts down after 5 minutes idle.** The non-ephemeral mode (`astrid start`) used to default `ASTRID_IDLE_TIMEOUT_SECS` to 300s and kill operator daemons mid-session. Idle shutdown is now opt-in for persistent mode — `ASTRID_IDLE_TIMEOUT_SECS=<secs>` re-enables it for housekeeping flows that genuinely want auto-shutdown. `--ephemeral` mode is unchanged (30s after last disconnect, env-var override still honoured).
- **`astrid capsule install/update/list/remove` no longer accept `--agent`/`-a` or `--group`/`-g`.** Capsules are deployed once and shared across every principal — per-invocation isolation is provided by the kernel's caller-context scoping (KV namespace, home, secrets, log, quotas, audit), not by duplicating the WASM. The `--agent` flag previously parsed and was discarded (`let _ = agent;` in `dispatch.rs`); the silent no-op misled operators into thinking they were installing per-tenant. Removed in line with the NEAR-style "one contract, many callers" model. Capsule config and secrets remain per-agent: `astrid capsule config -a <agent>` and `astrid secret set ... -a <agent>` still scope to a principal because they hold per-tenant data, not capsule code.

### Removed

- **Raw `.wasm` release asset install paths.** Capsule distribution is now `.capsule` archive or clone+build. Raw WASM assets can't carry WIT dependencies. (#649)
- **`install_standard_wit()` from init.** Fetched stale per-interface WIT from upstream repo; shared contracts are now bundled by `astrid-build` into each capsule archive. (#649)

- `extism` dependency — replaced by direct `wasmtime` 43 + `wasmtime-wasi` 43. (#632)
- `capsule_abi.rs` (252 lines) — hand-written WIT type mirrors. (#632)
- `host/shim.rs` (430 lines) — Extism dispatch shim, `WasmHostFunction` enum, `register_host_functions()`, manual memory helpers. (#632)
- `RiskLevel` enum and all references — removed from WIT, IPC payloads, approval engine, audit entries, CLI renderers, policy engine, and test fixtures. Approval prompts now render with a single style. The allowance store handles "don't ask again" patterns without risk classification. (#641)

### Fixed

- **`astrid who` now attributes connections to the actual agent holding the socket.** The kernel's per-principal `active_connections` counter was previously incremented by `client.v1.connected` events fired from `net_accept` with no principal stamp — at accept time the host has no idea which principal will eventually claim the socket, so the unstamped publish landed under `default`. The uplink (cli capsule) now publishes the lifecycle event with the claimed principal once the first principal-stamped ingress message arrives. `DaemonStatus` gains `connections_by_principal: Vec<PrincipalConnectionCount>` (optional / skip-if-empty) so older daemons stay wire-compatible. The CLI uses the shared `SocketClient::extract_kernel_response` to decode the response (so any future envelope change is picked up uniformly across `ps` / `daemon` / `who`) and falls back to the bare count + default attribution when the new field is absent.
- **`astrid caps check <self>` works for non-admin queriers.** Previously failed because the CLI calls `admin.group.list` to resolve group-inherited caps and `GroupList` required the admin-tier `group:list`. Now self-scoped: `(GroupList, AuthorityScope::Self_)` requires `self:group:list`, which the `agent` builtin satisfies via `self:*`. The mutating group operations (`group create / delete / modify`) keep their dedicated caps (`group:create`, `group:delete`, `group:modify`) and remain `AuthorityScope::Global` — read-only widening only.
- **`ipc::recv` and `ipc::poll` install per-message invocation context and clear on empty.** Run-loop capsules that consume IPC via `subscribe` + `recv` (prompt-builder, registry, context-engine) used to silently fall back to the capsule owner's principal (default), so non-default agent chat hung when the chain bounced through a run+recv capsule. The host now mirrors the dispatcher's per-interceptor invocation-context setup onto the recv path: `caller_context`, `invocation_kv`, and `invocation_capsule_log` are set from the first message of a recv'd batch, so subsequent publishes / KV reads stamp the publisher's principal correctly. **Mixed-principal batches** are truncated to the first publisher's contiguous prefix with a `security_event = true` warn, so trailing messages from a different principal can't be silently mis-stamped under the first's context. **Empty drains** (timeout, cancellation, idle poll) explicitly clear the recv context so a previous publisher's principal doesn't leak into the next guest host call. **Same-principal fast path**: when the new message's principal matches the currently installed one, the KV namespace and capsule log are reused instead of re-opened, dropping per-tick I/O for the steady-state chat run loop.
- **`astrid secret set` routes secret-typed values through the file-per-secret store (security).** Previously every `secret set` invocation wrote plaintext JSON to `<principal_home>/.config/env/<capsule>.env.json` regardless of whether the manifest declared the key as `env_type = "secret"`. `OPENAI_API_KEY` and friends sat in cleartext on disk even though the manifest correctly marked them secret. The CLI now reads the installed `Capsule.toml`, looks up the `EnvDef` for the key, and writes secret-typed values through a new `astrid_storage::FileSecretStore` rooted at `~/.astrid/secrets/<scope>/<capsule>/<key>` with 0600 perms (atomic via tempfile+rename, 64 KiB per-file read cap, null-byte and path-separator rejection in keys). `--scope agent|shared` controls per-agent vs host-wide; the manifest does NOT declare scope — capsules can't elevate their own credentials to host-shared. Kernel-side `get_config` consults the same store at invocation time, per-agent first with host-wide fall-through. Plaintext secret-typed env JSON entries are stripped at capsule load with a `security_event = true` warning so legacy on-disk state heals on next boot. `secret list` cross-references the manifest's `[env]` declarations to surface each row with a STORAGE column — `file` (green) for the new path, `env-json` (dim) for non-secret config, `env-json (LEGACY!)` (red) for secret-typed keys still on plaintext, awaiting the load-time heal. `secret delete` checks the file store first when the manifest declares the key secret. Pivoted away from an earlier OS-keychain implementation because headless containers and CI environments often lack a DBus secret-service / keyring; the file path works uniformly across Linux, macOS, Windows without backend gymnastics.
- **`caps check` now resolves group-inherited capabilities.** Previously the CLI answered `indeterminate: 'bob' belongs to groups agent — group capabilities not enumerated by Layer 6` whenever the requested capability could only be satisfied through group membership. The new implementation runs the same resolution order the kernel's Layer 5 enforcement preamble uses — explicit revokes → direct grants → group-inherited patterns — calling `astrid_core::capability_grammar::capability_matches` (the kernel-side matcher) on the data returned by `admin.agent.list` + `admin.group.list`. Reports the matching pattern in the output so operators can trace why a capability resolved the way it did. Verified live: `caps check bob self:capsule:install` → `allowed: 'bob' inherits from group 'agent' (pattern: self:*)`.
- **`caps grant` / `caps revoke` / `caps check` help text updated to a syntactically valid example.** The previous example was `network:egress:api.openai.com`, but the capability grammar disallows dots in segments (segments are `[a-zA-Z0-9_-]+` or bare `*`, no shell metacharacters so strings round-trip through TOML and the audit log without escaping). Operators following the example got `capability segment "api.openai.com" contains invalid character '.'`. Help text now shows `network:egress:openai` and similar dot-free examples, and explicitly names the grammar.
- **`caps show` no longer mislabels revoked grants as active (precedence display).** Layer 5 evaluates revoke > grant; a grant shadowed by any revoke pattern is dead at check time even though both rows persist on disk. The pretty renderer now uses the kernel's own `capability_matches(revoke, cap)` to detect shadowing (so pattern revokes like `sys:*` correctly mark `sys:status` grants as `shadowed by revoke`), instead of exact string equality which missed pattern-based suppression. Fixes column alignment in the table at the same time — ANSI escape codes inside `{:<N}` format specs were counted toward width but didn't render visually, shifting every subsequent column.
- **`agent create` provisions the new principal's home directory tree, fail-closed.** The kernel handler now calls `principal_home(&p).ensure()` after writing `etc/profiles/{p}.toml`, so `~/.astrid/home/{p}/.local/{kv,log,audit,tmp,tokens,capsules}` and `~/.astrid/home/{p}/.config/env` exist before the first interceptor scoped to the new principal fires. Without this, `caller_context.principal` resolved to a directory that didn't exist and per-invocation KV/secret/log/tmp/audit overrides had nowhere to land — silent fallback to default's namespace would defeat multi-tenancy at the data plane. If provisioning fails, the handler rolls back the identity link and profile file and returns an error rather than leaving a half-provisioned agent whose invocations would leak into another principal's namespace.
- **CLI `read_message` skips unparseable broadcast frames instead of dying on the first one.** The kernel publishes `astrid.v1.capsules_loaded` with an `IpcPayload::RawJson` whose inner JSON is emitted via `to_guest_bytes` — the `type` discriminator is stripped before the proxy forwards it to the socket. Strict `serde_json::from_slice::<IpcMessage>` propagated the resulting `missing field type` error up through the TUI run loop on the very first tick, broke out, restored the terminal, and dropped the operator back to a shell prompt with no visible cause. `read_message` now logs unparseable frames at `debug` and reads the next valid frame instead. The crash was masked while the wasip2 stub-run misclassification was suppressing every interceptor — once that fix landed, the chat-stack capsules actually started publishing the broadcast and the silent TUI exit became reachable on every boot.
- **`IpcMessage` now tolerates wire frames that omit `timestamp` and `signature`.** Both fields are serde-defaulted (`timestamp` falls back to `Utc::now()` on deserialize, `signature` to `None`). The CLI proxy capsule (`astrid-capsule-cli`) forwards bus messages to socket clients using only the fields exposed by the SDK's `ipc::Message`: `{topic, payload, source_id}`. The SDK does not surface the original timestamp or signature, so every CLI proxy frame previously omitted them and the headless client's strict `from_slice::<IpcMessage>` silently failed on every frame — `astrid -p` and the chat REPL never received `agent.v1.response` and either timed out at 120s or hung indefinitely. Defaulting at the deserialization boundary preserves semantics for in-process publishers (which always set the fields via `IpcMessage::new`) while letting forwarding capsules pass partial frames through. Round-trip behaviour for already-complete frames is unchanged.
- **Interceptor dispatch restored for capsules without a `#[astrid::run]` loop.** After the wasmtime Component Model migration, the kernel's run-loop pre-scan misclassified every Component Model capsule as a live run-loop daemon, zeroed out the store/instance, and returned `NotSupported("plugin handles interceptors internally via IPC auto-subscribe")` for every direct interceptor invocation. End-to-end chat REPL was non-functional. The `wasm32-wasip2` toolchain auto-synthesizes a single shared nop function for every mandatory WIT `func()` export the source crate doesn't implement (`run`, `astrid-install`, `astrid-upgrade`) and aliases all unimplemented exports to that one function. The pre-scan now treats a `run` export aliased to `astrid-install` or `astrid-upgrade` as a stub and routes the capsule through the direct-invoke path. The same fix applies to lifecycle pre-scans, so capsules without real `#[astrid::install]` / `#[astrid::upgrade]` no longer compile a transient component just to run a nop. Real `#[astrid::run]` daemons (`prompt-builder`, `context-engine`, `cli`) keep using the auto-subscribe path introduced in #343.
- **`[[topic]]` declarations now accept trailing-suffix wildcards (e.g. `llm.v1.request.generate.*`).** The previous validator rejected every wildcard in topic names, which broke fan-out topic families where the trailing segment names a provider, source, or recipient that can't be enumerated at manifest-author time (multiple LLM providers, multiple session callbacks, hook fan-out targets). Every member of the family shares the same envelope, so a pattern is the genuine schema declaration. Mid-segment (`a.*.b`) and leading (`*.b`) wildcards are still rejected — the bus matcher only supports trailing-suffix wildcards, so those would silently never fire. Bare `*` is rejected as too broad. Mirrors `ipc_subscribe`'s host-side check.

- **bwrap mount ordering hides capsule directory on Linux.** Hidden `--tmpfs` overlays (e.g. `~/.astrid`) were applied before writable `--bind` mounts, erasing capsule directories inside the hidden path. Reordered so bind-mounts come after tmpfs and punch through. Mirrors the ancestor check already present in the macOS Seatbelt path (PR #534). (#648)
- **bwrap silently fails on Ubuntu 24.04+ (AppArmor).** `kernel.apparmor_restrict_unprivileged_userns=1` blocks user namespaces required by bwrap. Added a cached probe at startup that detects this and falls back to unsandboxed execution with a clear warning and distro-specific install instructions. (#648)
- **`capsule remove` no longer deletes env config by default.** User configuration (API keys, secrets) in `env.json` is preserved across uninstall/reinstall cycles. Use `--purge` to explicitly delete saved configuration. (#647)
- **`astrid-build` targets `wasm32-wasip2`** for Component Model capsules. Was still targeting `wasm32-wasip1`, producing plain WASM modules. (#649)
- **`astrid-build` bundles SDK shared WIT** (`astrid-contracts.wit`) into capsule archives as a WIT dep, so `wit_type` references in `Capsule.toml` resolve at install time without manual WIT duplication. (#649)
- **JSON Schema field names converted to snake_case** in `wit_schema` to match `serde(rename_all = "snake_case")` wire convention. (#649)
- **`reqwest::blocking` inside `#[tokio::main]` panics on first run.** All HTTP call sites in `self_update.rs`, `init.rs`, and `capsule/install.rs` used `reqwest::blocking::Client`, which creates an internal tokio runtime that panics on drop inside the outer async context. Converted to async `reqwest`. Only manifested on fresh installs (no cache/lock files to short-circuit). (#645)
- **`INTERNAL_SUBSCRIBER_COUNT` debug_assert race.** `EventDispatcher` subscribed to the event bus inside `tokio::spawn(dispatcher.run())`, so the assert could fire before the spawned task started. Moved subscription into `EventDispatcher::new()`. (#645)

## [0.5.1] - 2026-03-25

### Added

- `cargo install astrid` now also installs `astrid-build` (capsule compiler) alongside `astrid` and `astrid-daemon`. Previously required a separate `cargo install astrid-build`.

### Fixed

- `astrid capsule install` no longer blocks when a new capsule exports an interface already exported by an installed capsule. Multiple providers (e.g. two LLM providers) can now coexist — prints an informational note instead of prompting for replacement.

## [0.5.0] - 2026-03-24

### Changed

- `workspace://` VFS scheme renamed to `cwd://` — the scheme maps to the daemon's CWD at boot; the old name implied a structured project workspace concept that was never implemented.

- **Tools are now a pure IPC convention.** Removed kernel-side tool dispatch (`WasmCapsuleTool`, `CapsuleTool` trait, `inject_tool_schemas`, `CapsuleToolContext`), `ToolDef` and `[[tool]]` from manifest, `inject_tool_schemas` from `astrid-build`. The kernel no longer parses or manages tool schemas. Tool capsules use IPC interceptors on `tool.v1.execute.<name>` and `tool.v1.request.describe`. The router capsule handles discovery and dispatch.
- **LLM providers are now a pure IPC convention.** Removed `LlmProviderDef` and `[[llm_provider]]` from manifest, `LlmProviderInfo` and `llm_providers` from `CapsuleMetadataEntry`. The kernel no longer parses or manages provider metadata. LLM capsules self-describe via `llm.v1.request.describe` interceptors; the registry capsule discovers them via `hooks::trigger`.
- **Removed dead cron host functions.** `astrid_cron_schedule` and `astrid_cron_cancel` were never implemented (stubs only). `CronDef` and `[[cron]]` removed from manifest. WIT spec updated: 49 host functions across 10 domain interfaces.
- Append-only artifact store — `bin/` and `wit/` are never deleted on capsule remove. Content-addressed artifacts are the audit trail; deleting them breaks provability. Future `astrid gc` for explicit cleanup.
- Replace `[dependencies]` provides/requires string arrays with `[imports]`/`[exports]` namespaced TOML tables — semver version requirements on imports (`^1.0`), exact versions on exports (`1.0.0`), optional imports, namespace/interface name validation
- **WIT spec:** Rewrite `wit/astrid-capsule.wit` to document all 51 host ABI functions (was 7). Split monolithic `host` interface into 11 domain-specific interfaces (fs, ipc, uplink, kv, net, http, sys, cron, process, elicit, approval, identity). Updated guest exports to reflect actual entry points (`astrid_hook_trigger`, `astrid_tool_call`, `run`, `astrid_install`, `astrid_upgrade`). Bumped package version to `0.2.0`.

### Added

- `cargo install astrid` installs both `astrid` (CLI) and `astrid-daemon` binaries from a single crate. The CLI crate now includes the daemon as a second `[[bin]]` entry point.
- `astrid self-update` command — checks GitHub releases for newer versions, downloads platform-specific binary to `~/.astrid/bin/`, no sudo required. Startup update banner (cached 24h) notifies on interactive commands.
- `astrid init` PATH setup — detects shell (zsh/bash/fish), offers to append `~/.astrid/bin` to the appropriate RC file
- Standard WIT interface installation during `astrid init` — fetches 9 WIT files (llm, session, spark, context, prompt, tool, hook, registry, types) from the canonical WIT repo and installs to `~/.astrid/home/{principal}/wit/` for capsule and LLM access via `home://wit/`
- Short-circuit interceptor chain — interceptors return `Continue`, `Final`, or `Deny` to control the middleware chain. A guard at priority 10 can veto an event before the core handler at priority 100 ever sees it. Wire format: discriminant byte (0x00/0x01/0x02) + payload, backward compatible with existing capsules.
- Export conflict detection on `capsule install` — detects when a new capsule exports interfaces already provided by an installed capsule, prompts user to replace. Nix-aligned approach: conflicts derived from exports data, no name-based `supersedes` field needed.
- Interceptor priority — `priority` field on `[[interceptor]]` in Capsule.toml (lower fires first, default 100). Enables layered interception (e.g. input guard before react loop).
- Distro.lock regeneration on `astrid capsule update` — keeps the lockfile in sync after capsule updates
- Content-addressed WIT storage — capsule install hashes `.wit` files into `~/.astrid/wit/`, capsule remove cleans up unreferenced WIT files, `wit_files` field in `meta.json`
- `astrid capsule tree` command — renders the imports/exports dependency graph of all installed capsules, showing which capsule exports satisfy each import, with unsatisfied imports highlighted in red (`astrid capsule deps` retained as hidden alias)
- `astrid init` with distro-based capsule installation — fetches Distro.toml, multi-select provider groups, shared variable prompts with `{{ var }}` template resolution, progress bars, writes Distro.lock for reproducibility. Supports `--distro` flag for custom distros.
- Distro.toml parser and Distro.lock generator — parse distro manifests with full os-release style metadata, shared variables with `{{ var }}` templates, provider groups, uplink roles, and semver validation. Atomic lockfile writes with BLAKE3 hashes for reproducible installs.
- Kernel boot validation — validates every capsule's required `[imports]` has a matching `[exports]` from another loaded capsule, logs errors for unsatisfied required imports and info for optional ones
- `astrid capsule remove` command with dependency safety checks — blocks removal if the capsule is the sole exporter of an interface that another capsule imports (`--force` to override), cleans up content-addressed WASM binaries from `bin/` when no other capsule references the same hash
- Install capsules from GitHub release WASM assets — `astrid capsule install @org/repo` now downloads pre-built `.wasm` binaries from release assets before falling back to clone + build from source
- Per-principal audit chain splitting — each principal maintains its own independent chain per session, independently verifiable via `verify_principal_chain()` and `get_principal_entries()`
- `AuditLog::append_with_principal()` for principal-tagged audit entries
- Auto-provisioning gated on identity store — only `"default"` principal is auto-provisioned when identity store is configured
- Linux FHS-aligned directory layout (`etc/`, `var/`, `run/`, `log/`, `keys/`, `bin/`, `home/`) replacing the flat `~/.astrid/` structure
- `PrincipalId` type for multi-principal (multi-user) deployments — each principal gets isolated capsules, KV, audit, tokens, and config under `home/{principal}/`
- Content-addressed WASM binaries in `bin/` using BLAKE3 hashing — integrity verified on every capsule load (no hash = no load, wrong hash = no load)
- Per-capsule daily log rotation at `home/{principal}/.local/log/{capsule}/{YYYY-MM-DD}.log` with 7-day retention
- `/tmp` VFS mount backed by `home/{principal}/.local/tmp/` for per-principal temp isolation
- Multi-source capsule discovery with precedence: principal > workspace (dedup by name)
- `PrincipalHome` struct with `.local/` and `.config/` following XDG conventions
- Per-invocation principal resolution — KV, audit, logging, and capability checks scope to the calling user per IPC message, not per capsule load
- `IpcMessage.principal` field for carrying the acting principal through event chains (transparent to capsules)
- `AstridUserId.principal` field mapping platform identities to `PrincipalId` with auto-derivation from display name
- Dynamic KV scoping via `invocation_kv` on `HostState` — capsules call `kv::get("key")` and the kernel returns the right value for the current principal
- Principal auto-propagation on `ipc_publish` — capsules never touch the principal, it flows through event chains automatically
- Auto-provisioning of principal home directories on first encounter
- `astrid_get_caller` host function now returns `{ principal, source_id, timestamp }` instead of empty object
- Dynamic per-principal log routing — cross-principal invocations write to the target principal's log directory
- `AuditEntry.principal` field with length-delimited signing data encoding
- `ScopedKvStore::with_namespace()` for creating scoped views sharing the same underlying store
- `AuditEntry::create_with_principal()` builder for principal-tagged audit entries
- `layout-version` sentinel in `etc/` for future migration support
- `lib/` directory reserved for future WIT shared WASM component libraries
- End-to-end Tier 2 OpenClaw plugin support: TypeScript plugins with npm dependencies install, transpile, sandbox, and run as MCP capsules with full tool integration
- OXC `strip_types()` transpiler for Tier 2 TS→JS (preserves ESM, unlike Tier 1's CJS conversion)
- Node.js binary resolution at build time: prefers versioned Homebrew installs (node@22+), validates each candidate
- MCP-discovered tools are now merged into the LLM tool schema injection alongside WASM capsule tools
- `astrid_net_read` now uses a self-describing `NetReadStatus` wire format: every response is prefixed with a discriminant byte (`0x00` = data, `0x01` = closed, `0x02` = pending), replacing the previous single-byte sentinel hack
- Headless mode: `astrid -p "prompt"` for non-interactive single-prompt execution with stdin piping support
- Post-install onboarding: `astrid capsule install` now prompts for `[env]` fields immediately after install
- Shared `astrid_telemetry::log_config_from()` behind `config` feature flag — replaces duplicate config bridge code
- `--snapshot-tui` mode — renders the full TUI to stdout as ANSI-colored text frames using ratatui's `TestBackend`. Each significant event (ready, input, tool call, approval, response) produces a frame dump. Configurable with `--tui-width` and `--tui-height`. Enables automated smoke testing without an interactive terminal.

### Fixed

- `cwd://` VFS scheme was handled in the security gate (capability checks) but not in the runtime path resolver — capsules using `cwd://` paths at runtime received a security denial because the path resolved to `<cwd>/cwd:/path` instead of `<cwd>/path`
- `sandbox-exec` (Seatbelt) crashes with SIGABRT on macOS 15+ (Darwin >= 24) — skip sandboxing on affected versions
- Headless approval response published to wrong IPC topic (`astrid.v1.approval.response` instead of `astrid.v1.approval.response.{request_id}`) and used wrong decision string (`allow` instead of `approve`)
- `[[component]].capabilities` (fs_read, fs_write, host_process) not merged into root capabilities — security gate couldn't see them
- Lifecycle hooks (`on_install`) couldn't access `home://` VFS — added `home_root` to `LifecycleConfig`
- `astrid init` standard WIT files were installed to `~/.astrid/wit/astrid/` (root-level, no VFS scheme). Capsules access the VFS via `home://` which maps to `~/.astrid/home/{principal}/` — the files were unreachable. Now installed to `~/.astrid/home/{principal}/wit/`, accessible as `home://wit/` (fixes #598)
- Dispatcher `known_principals` HashSet capped at 10K entries to prevent unbounded memory growth
- Dispatcher only caches principal after successful home provisioning — transient failures allow retry on next event
- `AstridUserId.principal` now has `#[serde(default)]` — existing identity records without the field deserialize with `"default"` instead of failing
- `transpile_and_install` now correctly unpacks `.capsule` archives from `astrid-build` output
- `copy_capsule_dir` only skips `dist/` at the top level; npm packages inside `node_modules` retain their `dist/` directories
- MCP host engine: absolute system binaries (e.g. `/opt/homebrew/opt/node@22/bin/node`) skip path traversal check when declared in `host_process` capability
- MCP host engine: `allow_network` derived from capsule capabilities (uplink/net) instead of defaulting to `false`
- Capsule env resolution no longer blocks loading on missing optional fields; fills with empty defaults so uplink capsules can boot before clients connect
- macOS Seatbelt sandbox: added `mach*` permission and unrestricted `file-read*` for Node.js compatibility
- macOS Seatbelt sandbox: hidden path deny rules skip paths that are ancestors of the writable root
- MCP tool schemas now include `properties` field for LLM API compatibility
- `net_write` no longer causes a WASM trap on broken pipe / connection reset when a headless client disconnects; write errors are logged at debug level and the dead stream is cleaned up on the next read
- `net_read` returns a `NET_STREAM_CLOSED` sentinel byte instead of trapping on peer EOF/disconnect, allowing the CLI capsule run loop to remove dead streams gracefully
- Also fixes a variable name mismatch (`capsule` vs `plugin`) in `approval.rs` that caused a compile error
- `~/.astrid/shared/` directory now created on boot, eliminating `global:// VFS not mounted` warning on fresh installs
- Capsule reinstall now preserves existing `.env.json` rather than overwriting it with an empty file
- WASM execution timeout bumped from 30s to 5 minutes to prevent premature cancellation on slow operations
- IPC event dispatcher now delivers events to each capsule in publish order via per-capsule mpsc queues, fixing out-of-order stream text assembly in the ReAct capsule
- `IpcMessage` gains a monotonic `seq` field assigned at publish time for ordering and diagnostics
- KV host function double-encoding: `kv_get_impl` returned `serde_json::to_vec` of raw bytes instead of raw bytes directly
- Config host function double-encoding: `get_config_impl` wrapped string values in JSON quotes, breaking URLs and other string config
- React capsule LLM topic validation: `active_llm_topic()` could produce topics with empty segments causing IPC publish failures
- `astrid_read_file` host function trapped (WASM abort) on recoverable errors (file-not-found, permission denied) — now returns status-prefix wire format (`0x00`+content / `0x01`+error), paired with SDK-side decoding. Eliminates crashes in memory, agents, identity, and fs capsules when reading optional files.

### Changed

- `global://` VFS scheme renamed to `home://`
- `Capsule::invoke_interceptor` now accepts `Option<&IpcMessage>` for per-invocation principal context
- `CapsuleContext.global_root` renamed to `home_root`; `HostState.global_vfs` renamed to `home_vfs`
- `AstridUserId` now requires `principal: PrincipalId` field (existing KV records incompatible — nuke `~/.astrid/`)
- Capsule install target moved from `~/.astrid/capsules/` to `home/{principal}/.local/capsules/` (capsule dir now holds only manifest + meta.json)
- KV namespace format changed from `capsule:{name}` to `{principal}:capsule:{name}`
- Socket/token/ready paths moved from `sessions/` to `run/`
- Env config moved from capsule dir `.env.json` to `home/{principal}/.config/env/{capsule}.env.json`
- System logs now use `.log` extension, no ANSI escape codes in file output, 7-day retention
- `user_key_path()` renamed to `runtime_key_path()` (now at `keys/runtime.key`), `logs_dir()` renamed to `log_dir()`
- Ephemeral daemon now shuts down immediately when the last client disconnects (idle timeout 0, 1s check interval) instead of waiting 5 minutes
- Renamed `plugin` → `capsule` in the WASM host layer and audit log fields for consistency with project terminology
- Split `astrid-build` 1166-line `build.rs` into focused modules: `rust.rs`, `openclaw.rs`, `mcp.rs`

### Removed

- `~/.astrid/capsules/` system capsules directory (user installs go to principal home)
- `sessions/`, `shared/`, `audit.db`, `capabilities.db`, `state/`, `spark.toml`, `cache/capsules/` — replaced by FHS equivalents or moved to principal home

### Breaking

- Existing `~/.astrid/` must be deleted — no migration path. Reinstall all capsules after upgrading.

## [0.4.0] - 2026-03-17

### Added

- `astrid-daemon` crate — standalone kernel daemon binary with `--ephemeral` flag for CLI-spawned instances vs persistent multi-frontend mode
- `astrid-build` crate — standalone capsule compiler and packager (Rust, OpenClaw, MCP). Invoked by CLI via subprocess.
- `astrid start` command — spawn a persistent daemon (detached, no TUI)
- `astrid status` command — query daemon PID, uptime, connected clients, loaded capsules
- `astrid stop` command — graceful daemon shutdown via management API
- `KernelRequest::Shutdown`, `KernelRequest::GetStatus`, and `DaemonStatus` types in `astrid-types`
- `Kernel::boot_time` field for uptime tracking
- Streaming HTTP airlock: `astrid_http_stream_start`, `astrid_http_stream_read`, `astrid_http_stream_close` host functions for real-time SSE consumption (`astrid-capsule`)

### Changed

- CLI no longer embeds the kernel — spawns `astrid-daemon` as a companion binary
- CLI no longer compiles capsules — delegates to `astrid-build` as a companion binary
- CLI reads `IpcMessage` directly from socket instead of wrapping in `AstridEvent::Ipc`
- IPC type imports in CLI now use `astrid-types` directly instead of going through `astrid-events` re-exports
- Package renamed from `astrid-cli` to `astrid` (`cargo install astrid`)

### Removed

- `astrid-kernel` dependency from CLI
- `astrid-openclaw`, `extism`, `cargo_metadata`, `toml_edit` dependencies from CLI
- `Commands::Daemon` and `Commands::WizerInternal` from CLI (moved to `astrid-daemon` and `astrid-build`)

## [0.3.0] - 2026-03-17

### Added

- `astrid-types` crate — shared IPC payload, LLM, and kernel API types with minimal deps (serde, uuid, chrono). WASM-compatible. Both `astrid-events` and the user-space SDK depend on this.
- `yolo` as an alias for `autonomous` workspace mode (`astrid-config`, `astrid-workspace`)

### Changed

- `astrid-events` now re-exports types from `astrid-types` instead of defining them inline. All existing import paths remain valid.
- `astrid-events` `runtime` feature removed — all functionality is now always available. Consumers no longer need `features = ["runtime"]`.

### Removed

- `astrid-sdk`, `astrid-sdk-macros`, `astrid-sys` extracted to standalone repo ([sdk-rust](https://github.com/unicity-astrid/sdk-rust))

## [0.2.0] - 2026-03-15

Initial tracked release. See the [repository history](https://github.com/unicity-astrid/astrid/commits/v0.2.0)
for changes included in this version.

[Unreleased]: https://github.com/unicity-astrid/astrid/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/unicity-astrid/astrid/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/unicity-astrid/astrid/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/unicity-astrid/astrid/releases/tag/v0.2.0
