# 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] ### Added - **Hosted filesystem name projection is deterministic and reversible.** Byte-exact principal content names now plan through typed target-volume comparison and syntax policies, with collision-safe names for case, normalization, reserved-name, length, and file/directory conflicts. Atomic reservation prevents a late host-side collision from overwriting another source, and `astrid doctor --projection-name-policy` reports only the authenticated principal's collisions and escaped paths without repair. - **Principal KV point writes now use canonical transitions over immutable B+-tree checkpoints.** The accepted wire layout reduces a 128-byte replacement from the measured 2,883-byte AVL baseline to 948 authoritative bytes at 10k, 100k, and one million entries. Page-bounded checkpoints retain shallow reads and cached accounting; background construction rebases concurrent transition tails before root publication. Full decode verifies counters, no-op exclusion, page bounds, child summaries, quota totals, and spilled values. Existing version-3 AVL roots migrate in place with commit lineage, and the independent RÚNATAL reader reconstructs both predecessor and current projections. - **Durable closures can carry construction-diverse custody evidence.** A verified Refinery pass streams one exact, canonically ordered Owns closure into a bounded-fanout SHA-384 tree, then emits an Ed25519-signed Evidence record binding its selected roots, immutable snapshot, pass descriptor, and placement epoch. The pass rejects omissions, unrelated objects, cycles, substitutions, and reordered input before publication; generated records still enter the store through ordinary Evidence admission. RÚNATAL freezes the byte grammar and successor re-root ceremony, and its independent reader rebuilds the same tree and verifies the signature. BLAKE3-256 remains the ingest and read-path identity. - **Durable principal ownership now uses stable opaque identities.** A canonical genesis record binds each principal's existing identity UUID, creation timestamp, and initial Ed25519 key to a domain-separated 32-byte UID. Root journals and native staging intents store that UID rather than a mutable alias; alias lookup remains a capability-bound runtime directory. Existing pre-release stores rewrite root snapshots and queued intents under a crash-recoverable marker while preserving exact root generations and commit identities. Alias changes and authentication-key rotation therefore do not rewrite durable ownership, and the RÚNATAL reader independently decodes the frozen owner grammar. - **The selected FastCDC content profile is frozen as implementation-independent format.** RÚNATAL now specifies the exact gear-table derivation, normalization masks, wrapping cut algorithm, accepted parameter grammar, whole-object policy, and seeded and unseeded golden vectors. Production Rust and the independent reader reproduce those vectors; independent recovery validates every rooted file's canonical tree shape and chunk boundaries. Existing development stores amend in place to the successor in-band specification. - **The format-one content chunker now has a reproducible evidence gate.** A non-published harness compares the pinned FastCDC profile with exact MinCDC and Moth evidence oracles across live aggregate corpora, captured and synthetic version chains, adversarial inputs, local-edit resynchronization, whole-file versus chunk deduplication, object-cost estimates, and CPU-only throughput. A scalar independent MinCDC reader pins constants and tie/bound/final-chunk behavior; reports exclude input paths and file names. The measurements retain FastCDC for format one, keep Moth representation claims separate from boundary selection, and record why Chonkers is not yet a licensed byte-exact candidate. - **Refinery can emit measured, independently verifiable resemblance sketches.** The canonical bottom-k transform retains 256 deterministic 128-bit scores over verified format-one Chunk objects in bounded memory and emits evictable Derived metadata bound to the exact File and pass descriptor. Live agent-state and workspace sweeps select the constants from actual reconstructed COPY/ADD deltas, preserve lineage-first selection, and limit materialization to multi-chunk Files. The independent RÚNATAL reader recomputes the source closure and every score, and existing development stores amend in place to the successor in-band specification. - **The principal-store format-one review surface is mechanically frozen.** The in-band RÚNATAL specification now classifies durable discriminants, evidence-selected parameters, reserved capacity, runtime policy, native staging, and disposable acceleration state. Exact-code and framed-magic regressions prevent silent wire drift, `store.meta` is checked byte for byte, and existing development stores amend from the immediately prior specification identity without changing principal roots. - **Astrid has a content-addressed computation design contract.** A canonical invocation object now specifies the complete identity boundary for reusable deterministic work, including the exact transform closure, its registered contract, typed ordered inputs, canonical parameters, semantics-visible runtime profiles, output contracts, provenance snapshots, and explicit seeds. Pure, snapshot-bound, effectful, and nondeterministic execution classes prevent memoization from replaying side effects. Muninn is specified as a disposable fleet index over durable derivation evidence, with differential spot checks, logical-versus-physical compute accounting, and principal/trust-domain authority preserved below its cache. Huginn applies the same primitive to canonical local-model context and prefix assembly. The Refinery contract and initial engine seam unify scrub, SHA-384 cross-hashing, resemblance sketches, and representation-aware compaction behind one resource-budget shape while retaining a sealed engine-only mutation boundary. Linked GC plan and commit evidence keep the exact fact snapshot, retention policy, auditor proof, condemned set, and placement transition replayable, while commit construction rejects a changed fence-held snapshot. Durable storage freeze and freedom-to-operate records pin the associated format, chunking, identity, and public-layer research decisions without activating a capsule or WIT surface. - **Principal storage now has a portable executable architecture model.** The `no_std` `astrid-storage-model` crate defines immutable logical objects, distinct encoded blob identities, atomic principal-root transitions, idempotent closure import/export, root-and-pin garbage collection, stable per-principal accounting, and root-preserving placement epochs. Companion design and evidence documents specify the future user-space engine, filesystem/KV migration, principal export/import, sysadmin rebalancing, privacy and erasure domains, realistic deduplication bounds, and the crash/property/adversarial tests required before production claims. Typed owning, evidence, lineage, and derived references keep retention authority explicit, while capability-scoped state views and structural transition witnesses model independently verifiable root changes without claiming semantic execution proofs. Public capsule interfaces remain unchanged. Domain-bearing identifiers, relation labels, root generations, object kinds, and non-zero format/replica versions are distinct Rust types; imports reject unrelated records, principal roots must name typed commits, and placement epochs advance monotonically over registered blobs. - **Principal storage has a thread-safe in-memory engine prototype.** The new `astrid-storage-engine` validates caller-declared object identities and complete immutable closures before publishing linearizable per-principal root generations. Semantic object kinds and kind-scoped format versions are identity-bearing, and a principal root must name a typed commit envelope. The engine supports consistent closure snapshots, exact compare-and-swap, pins, logical accounting, and garbage collection without selecting an on-disk format. Bounded transition traces and concurrent-writer tests exercise the engine contract; the native durable realization refines it while encryption and placement remain future work. - **Principal storage has an additive KV compatibility bridge.** `PrincipalKvStore` implements the existing async `KvStore` contract over typed principal roots while requiring an authority-aware namespace-to-principal resolver. The version-one logical projection stores KV leaves, branches, namespace maps, principal state, and commit lineage without choosing a production hash or disk encoding. Root conflicts retry from a fresh snapshot, non-KV state edges survive KV mutations, and generated traces compare every operation and resulting namespace state against both `MemoryKvStore` and `SurrealKvStore`. The bridge remains the whole-state differential oracle; the runtime hot path uses a bounded persistent tree. - **Principal storage has a host-file durability foundation.** `DurableEngine` persists immutable object frames before publishing a checksummed principal-root journal record and maintains a disposable, checksummed persistent object-location index. A missing, stale, torn, or corrupt index falls back to authoritative arena recovery; principal roots always come from journal replay and live closure verification. Recovery truncates incomplete or physically invalid uncommitted tails only when no valid frame follows, and rejects interior or semantic corruption. One object-arena flush makes the transaction's complete object batch durable before one root-journal flush publishes it; index deltas add no commit-path flush. Deltas consume an incrementally tracked append frontier, so an earlier staged frame cannot disappear behind a later durability boundary and commits do not rescan the complete object map. An exclusive store lock and poison-on-write-failure behavior prevent concurrent-process corruption and stale in-memory reads. Named crash-boundary tests prove recovery to the old or new complete root. Recovery keeps only roots and arena offsets resident, while live reads load and checksum payloads lazily. Every persistent identity occurrence now carries an algorithm, construction version, and variable digest length with capacity for 384-bit successors. Each store persists RÚNATAL — Recoverable Universal Notation for Astrid's Tagged Archival Layout — as a frozen, byte-exact plain-text specification object referenced by `store.meta`; an independent Python reader verifies Rust-produced arenas, identities, root chains, and live closures in CI. Proof-audited arena compaction now rewrites exactly the closures selected by an explicit retention contract, preserves root generations in a canonical journal snapshot, and atomically replaces both authority files through a durable intent. Recovery accepts only a complete arena/journal pair, rebuilds the disposable index, and is exercised at every named replacement boundary. Compaction is a sealed engine-only Refinery pass; Tensor Logic may audit its native condemned set but cannot acquire deletion authority. Every deletion now prepares a self-contained, checksummed evidence bundle before publishing its intent; recovery installs only the exact physical placement named by that receipt, marks it ready for independent audit delivery before deleting the old generation, and retains it until explicit acknowledgement. Persistent history-pin policy, kernel audit draining, and final representation profiles remain future work. - **Native kernel state now cuts over to durable principal roots.** Kernel boot migrates the legacy SurrealKV database under the singleton lock, verifies a canonical digest independently per owner, preserves the legacy source, and publishes a versioned completion marker only after a durable flush. `TreeKvStore` keeps point reads and mutations height-bounded through a persistent AVL tree instead of using the linear compatibility projection. Durable open, arena reads, mutations, and flushes run on the blocking pool rather than parking asynchronous runtime workers during filesystem I/O. System state has its own owner; host-stamped capsule namespaces map to validated principals and share their live, invalidatable profile quota. The principal store is an invariant rather than a configurable backend, has no fixed frame or capacity ceiling, and derives finite storage budgets from the live principal profile. Quota accounting includes canonical namespace/key bytes as well as values, so empty values cannot consume unmetered principal-controlled metadata. The legacy `kv` feature remains only as a deprecated no-op compatibility alias; `legacy-surrealkv` names the temporary reader and migrator. Neither feature selects a runtime backend. - **Principal state can retain named, content-defined file DAGs.** The new `astrid-storage-content` crate turns bytes into canonical chunk, bounded chunk-tree, and file objects using an exact-pinned FastCDC 2020 profile. Files at or below the 256 KiB maximum remain one whole object; larger files enforce the declared minimum/maximum chunk bounds during decode, with only the final chunk exempt from the minimum. A blocking streaming builder reads through a profile-bounded FastCDC buffer and emits immutable records into an identity-checking staging sink without retaining the complete source. Fragmented readers produce the exact same descriptor and canonical DAG as the slice builder. `PrincipalContentStore::put_streaming` stages those records incrementally in four-MiB coalesced shared-engine batches without per-object flushes; the ordinary root transaction revalidates the complete closure, flushes objects before the root, and retries root conflicts without rereading the source. Source or sink failure never publishes a partial root. Hosted writable projections can now stage random-access writes in private native files and acknowledge a close after durable bytes plus a checksummed intent, without waiting for content addressing. Startup promotes sealed pre-rename writes, preserves incomplete entries for diagnosis, and rejects redirected or malformed ready entries without deleting acknowledged bytes. Background publication runs on a blocking worker, enforces close order per owner and content name, and uses a durable post-root marker so a crash between the root CAS and cleanup retries idempotently. Full and range reconstruction validate the typed graph and load only overlapping chunks. `PrincipalContentStore` publishes named content through the same per-principal root CAS and durable object arena as KV, so identical chunks and complete files are physically reused across names and principals without creating a side root. Catalog accounting charges every visible byte and name even when physical objects deduplicate, and KV/content mutations enforce one combined live principal quota in either mutation order. Golden boundaries, insertion-locality, malformed graph, concurrent writer, range, alias, and cross-principal tests pin the behavior. Host filesystem materialization, capsule WIT access, encryption domains, and arena compaction remain separate work. The accompanying semantic-representation design separates exact `ObjectId`, contract-scoped `SemanticId`, trusted physical representations, and similarity relations. Stable semantic contracts pin a canonicalizer, while independently versioned representation contracts pin codec decoders so adding a format does not redefine existing identities. Alternate transforms cannot mint identity without complete reference verification or a contract-pinned proof, and untrusted source encodings never enter the shared trusted serving pool by equivalence alone. The design records the cross-principal substitution adversary, authority-controlled registration, bounded typed route planning, generic future streaming WIT, image-codec capsule decomposition, explicit source-retention policy, and derived-cache accounting without activating a capsule interface. The live corpus measured 98.02% object-instance convergence but 47.1% whole-file byte convergence; the 95–98% platform-scale byte result remains a separately testable hypothesis. - **Named content uses a canonical path-copy radix catalog.** Point lookup and mutation no longer decode or rewrite every principal name. UTF-8 names plus a zero terminator form prefix-free radix keys, making root identity independent of insertion history while a replacement writes only its search path. Leaves contribute visible bytes exactly once; structural branches carry checked child totals for constant-time root quota accounting. Complete principal-partitioned validation rejects false totals, non-canonical partitions, reuse, cycles, and file-length substitution before either KV or content trusts a catalog root. An ordered, crash-resumable migration converts legacy flat catalogs through the ordinary root CAS, and a second in-band frozen specification plus the independent reader keep the new grammar recoverable without Astrid code. At 230,000 entries, the release probe measured a 2.68-microsecond warm catalog lookup and 3,480 retained metadata bytes for one replacement versus 17,250,041 bytes for the legacy rewrite. Closes #1400. - **Principal storage has a reproducible native I/O benchmark boundary.** A custom release-profile benchmark compares same-volume native writes and explicit syncs with staging writes, durable seal, unique and duplicate publication, content-only construction, verified range reads, and engine reopen, including repeated fresh-store and concurrent shared-content workloads across principals. It reports raw samples and derived throughput as JSON, keeps foreground acknowledgement separate from asynchronous publication, and compares small-file close, sync, and seal rather than calling them equivalent operations. The companion benchmark contract maps measured bottlenecks and defines the future read/write filesystem-provider matrix without presenting engine measurements as mounted throughput. Refs #1398, #1399, #1392, and #1391. - **Principal storage reuses verified immutable reads under governed memory.** Durable object reads now use positional I/O after a short index lookup instead of seeking under the engine write mutex. Principal-scoped read handles resolve and validate a root generation, catalog entry, and file descriptor once, then preserve that immutable generation across later replacement or deletion of the catalog name. Canonical builders and completed validating reads mint unforgeable verification tokens, allowing later reads to skip redundant FastCDC neighbour proofs while retaining object, shape, and range checks. Reuse is partitioned by principal so cache timing cannot expose another principal's equal content. A separately governed decoded-object cache shares immutable allocations physically while charging every principal their complete logical weight; cache exhaustion always falls back to verified arena reads. Cache hits now remain shared through the projection and content-source boundaries instead of cloning full records, physically adjacent frames are verified from coalesced positional reads, and synchronized tick indexes make global and per-principal eviction logarithmic rather than scanning the cache under its mutex. Model-scale range reads retain successful local boundary evidence as profile-bound 128-bit bitmaps per immutable chunk-tree node, so a cold range still touches only its own chunks and neighbours while later reads avoid neighbour-only I/O without first scanning the complete file. Edge evidence, complete-file tokens, and decoded catalog headers are principal/object-partitioned, fail-closed, process-local values inside the same governed cache as decoded objects. Their physical bytes and per-principal logical charges are bounded by the injected cache authority; refusal or eviction always falls back to verified reads. Cold reopen tests corrupt a neighbour frame after recovery and prove that no process-local evidence can suppress its checksum failure. Closes #1399. - **Windows local transport uses authenticated per-user named pipes.** A pipe name derived only from the caller's operating-system SID replaces filesystem endpoint naming on Windows. Local-only byte-mode instances use a protected current-user and Local System DACL, reject namespace squatting, authenticate the client's effective token, and pin peer process identity before the unchanged session-token and signed-principal handshake. Bounded, cancellable busy-instance retries support probe-then-connect and concurrent clients. Native x86_64 and ARM64 Windows jobs exercise binding, concurrent instances, shutdown, and reconnect behavior. Closes #1349. - **Linux amd64 now has a distro-neutral OCI build target.** The image packages exact immutable GitHub release bytes only after their tagged release-workflow signatures and manifest digests verify, runs the persistent daemon as a non-root foreground process with logs on standard error, and refuses startup unless an operator supplies an externally pinned, internally signed distro. CI loads one exact OCI export and passes only its content-addressed repository digest to runtime tests, scanning, and SBOM generation. It binds the archive's sole manifest to that digest, then emits signed checksums for the evidence, a per-export provenance attestation, and an exact-archive blob signature without claiming byte reproducibility or publishing mutable or multi-architecture tags. Closes #1344. - **Linux ARM64 now has a native distro-neutral OCI build target.** The image installs the exact authenticated `aarch64-unknown-linux-gnu` release bytes into a platform-specific digest-pinned Ubuntu base, then exercises the real daemon and authenticated status path on a native ARM64 runner under the same rootless, read-only, capability-free, signed-distro contract as amd64. CI binds the export's sole, variant-free `linux/arm64` manifest to the loaded image's repository digest and passes only that content-addressed name to runtime tests, scanning, and SBOM generation. It then re-verifies the archive and binding and signs an evidence manifest covering the tar, binding, SBOM, and release receipt without emulation, registry publication, or mutable and multi-architecture tags. Closes #1346. - **Windows filesystem security now has a fail-closed platform boundary.** Windows builds resolve Astrid state beneath the per-user LocalAppData known folder, protect runtime, workspace-state, and key paths with exact current-user/System/Administrators ACLs, reject reparse-point redirects, and keep trusted directory components identity-checked and locked against path swaps. Self-update staging flushes bytes on the live volume and uses a private transaction journal, OS-backed cross-process lock, identity-bound source handles, and independent rollback copies to recover failed or interrupted multi-executable commit sequences. Sensitive single-file writes use the same journal-before-mutation rule with an independent rollback, so interrupted session-token, profile, and group writes restore the prior private file on the next operation. Their readers share the transaction lock, recover before opening the live name, and read through an identity-bound handle, so they cannot consume an uncommitted or incompletely committed private file. Native x86_64 and ARM64 MSVC tests exercise the prerequisite without enabling self-update, named pipes, daemon lifecycle, sandboxing, packaging, or a Windows support claim. Closes #1348. - **Foreground daemon logs can be routed to standard error.** `ASTRID_DAEMON_LOG_TARGET=stderr` sends ANSI-free daemon diagnostics to the process supervisor's standard-error stream. The strict override also accepts `file`; when unset, the unchanged default continues to write rotating logs beneath `$ASTRID_HOME/log`. Closes #1340. - **Linux musl releases use a backward-compatible signed metadata extension.** The legacy release manifest and mutable channel pointer remain fixed at their exact four-target schema, while musl builds authenticate a separately signed two-target manifest from the same immutable tag. The extension binds the exact legacy manifest digest and release identity, and interrupted release publication validates and recovers its metadata, bundle, and archives without replacing uploaded assets. Closes #1339. - **Host-local daemon communication now has one transport boundary.** Existing Unix socket construction, acceptance, endpoint presence and stale cleanup, liveness probing, stream splitting, and same-user peer verification route through a shared abstraction while retaining the Unix backend and wire protocol unchanged. Closes #1341. - **Linux musl is now a native release target on x86_64 and ARM64.** Signed immutable extension metadata covers both musl archives, and CI builds each one in a pinned Alpine toolchain on its native architecture before executing all four release binaries in that environment. Closes #1338. - **Capsule provenance is now bound to local install authority.** `astrid-build` signs canonical capsule content with the installation's runtime key. Same-runtime builds and operator-owned distributions retain a low-friction path, while foreign-signed or unsigned manual installs require an exact, digest-bound approval that displays capability expansion. Installed authority snapshots prevent later manifest edits from silently acquiring additional host capabilities. Closes #1318. ### Security - **Wasmtime is updated to 46.0.2 across the runtime.** The patched release addresses RUSTSEC-2026-0222 and RUSTSEC-2026-0223 while retaining the current Wasmtime 46 compatibility line. Closes #1429. ### Fixed - **Production compaction retention preserves every standalone bootstrap object.** `RuntimePrincipalStore` now builds scheduler-facing retention from one typed bootstrap registry, adds each registered object as a `System` root, and verifies its presence before returning a policy for proof. The raw durable engine remains policy-neutral, while native composition can no longer silently collect the RÚNATAL specification and discover the damage only when the store next opens. Closes #1408. - **Kernel management rate limits no longer cross authorization boundaries.** Requests consume a principal-and-method-scoped action budget only after principal and device-scope authorization succeeds. A restricted caller or device can no longer exhaust another authorized caller's shutdown, reload, install, or workspace-operation allowance; rate-limit rejections are audited as failures, and expired principal buckets are retired. Closes #1378. - **Fresh Windows capsule installs provision private runtime and principal homes.** Capsule signing and authority inspection now create and validate the Astrid directory tree before generating the runtime identity, secure the new key with an exact private file ACL, and provision the selected principal boundary before mutating a capsule target. Existing unsafe homes remain rejected. Unix install behavior is unchanged, and workspace targets continue to use their checked selection path. Closes #1366. - **Windows builds retain the cross-platform key persistence contract.** The non-Unix parent-directory sync shim keeps the same fallible interface as the Unix implementation without failing the native ARM64 Clippy gate. Closes #1361. - **Linux release binaries now run on glibc 2.34 enterprise hosts.** The x86_64 and ARM64 GNU artifacts are built in a pinned glibc 2.31 environment, and release publication rejects binaries whose ELF symbol requirements exceed glibc 2.34. Closes #1322. - **Ephemeral daemons now shut down when their final client disconnects.** Client leases, rather than loaded uplink capsules or a 30-second idle timer, own ephemeral process liveness. The never-connected fallback starts only after daemon readiness, and lease accounting bypasses the lossy broadcast queue so an event storm cannot hide a live client. Persistent daemons retain their explicit operator-controlled lifetime. Closes #1296. - **Capsule lifecycle hooks preserve the target principal.** Explicit-principal installs now provision and scope lifecycle `home://`, KV, secrets, and IPC identity to the authenticated install target instead of silently executing the hook as the bootstrap `default` principal. - **Gateway env schemas now use the authenticated principal's capsule registry or verified workspace.** Schema reads no longer fall back to `default`, so non-default and workspace-only installs are configurable and a same-named default capsule cannot supply the wrong field contract. Closes #1327. - **Secret commands now read manifests from the selected principal registry or verified workspace.** Secret set, list, and delete no longer fall back to `default`, while workspace-only capsule secrets retain their declared classification and remain out of plaintext env configuration. Closes #1325. - **Per-agent CLI commands now inherit the global `--principal`.** A command-local target such as `--agent` still wins, but omitting it uses the process principal already resolved from the flag, environment, or active context instead of re-reading and potentially selecting a different context. Closes #1326. - **Capsule registry queries now honor `--principal`.** The `capsule list`, `capsule tree`, and `capsule deps` commands pass the process-wide authenticated principal into capsule discovery instead of falling back to the bootstrap `default` principal, so diagnostics match the capsule view that the daemon loads. Closes #1112. - **SVG URL sanitization uses the patched `ammonia` release.** The locked transitive dependency is updated to 4.1.4 so crafted animation attributes cannot preserve a `javascript:` URL and the workspace security audit no longer accepts the vulnerable 4.1.3 release. Closes #1336. - **Stable crates publication installs its authenticated-hash prerequisite.** The protected publisher installs the pinned `b3sum` binary before validating the exact dev candidate, so BLAKE3 release metadata checks run before any crates.io mutation instead of failing on a missing executable. Closes #1294. - **Channel promotion starts successfully after the stable crates workflow split.** The reusable stable publisher now declares its optional named crates.io secret while the protected release environment remains the actual credential authority, so GitHub can validate dev, nightly, and stable dispatches without broadly inheriting caller secrets. Closes #1292. ## [0.10.4] - 2026-07-20 ### Removed - **Capsule manifests no longer embed the agent-specific `[[skill]]` protocol.** Skills remain a user-space concern that agent runtimes can discover over IPC or advertise through product registries without teaching the Astrid kernel or generic capsule archive about an AI workflow format. Builders still preserve files under `assets/` and legacy `skills/` as opaque, symlink-free capsule data without generating manifest metadata. ### Fixed - **`astrid capsule check` no longer treats embedded Rust examples as live tools.** The scanner now parses Rust attributes instead of matching source text, so scaffold templates and documentation strings cannot create phantom unrouted-tool failures. - **Interrupted immutable-release drafts recover without replacing uploaded assets.** Release uploads are sequential, and reruns authenticate every existing payload and signature before adding only the missing files. Closes #1285. ## [0.10.3] - 2026-07-20 ### Fixed - **MCP form elicitations now interoperate with strict clients such as Codex.** Ingress, capsule-grant, and capability-approval prompts omit optional top-level schema annotations that Codex rejects while preserving typed responses and fail-secure authorization. Closes #1277. - **Dev candidates no longer publish the Astrid workspace to crates.io.** Immutable GitHub releases remain binary candidates; the protected stable promotion authenticates the exact signed dev candidate and publishes its crates idempotently before advancing the stable pointer. Closes #1279. ### Added - **Rust capsule builds now package files declared by `[[skill]]`.** Declared skill assets are confined to regular files inside the capsule source tree and flow through the existing per-principal introspection mirror, allowing agent runtimes to discover skills from installed capsules without install-time copies. Native not-found and permission-denied filesystem errors also retain their typed guest error codes. Closes #1281. ## [0.10.2] - 2026-07-19 ### Fixed - **`astrid mcp serve` now waits for the authenticated principal's broker to answer before exposing MCP stdio.** Non-default capsule views intentionally warm after global daemon readiness; an immediate client `tools/list` could previously publish before the broker subscribed, lose the non-durable bus request, and hang for the full 55-second reply deadline. Startup now proves the generic broker path with an idempotent `tools/list` probe and reissues it when the principal's `capsules_loaded` signal arrives, without hardcoding a product capsule name. ## [0.10.1] - 2026-07-17 ### Fixed - **Runtime service discovery is product-neutral.** CLI and unique compatible model/session interfaces authenticate live source IDs. Closes #1267. ## [0.10.0] - 2026-07-17 ### Added - **Native process requests now honor their declared environment and working directory.** Every spawn tier starts from a small host environment allowlist, applies validated guest variables, rejects session-token injection, confines relative working directories to the capsule workspace, and can resolve `HOME=home://...` or a `home://...` working directory after host-side policy checks. The native child receives the resolved path, but the process host API does not return it directly. Principal-home reads require an explicit read capability; the OS sandbox makes writable only the capsule's declared `home://` write roots for that principal. Recv-driven capsules now install the same per-principal home/tmp overlays as interceptor invocations. - **Capsule installation has an explicit non-interactive configuration path.** `astrid capsule install --yes` resolves lifecycle fields from repeatable `--var KEY=VALUE` inputs, `ASTRID_VAR_` environment variables, or manifest defaults, and fails instead of silently choosing an enum value or empty secret when a required value is absent. Secret automation can use the environment form so credentials do not appear in process arguments. - **Signed runtime release channels and immutable manifests.** Every release records the exact runtime and WIT source commits, release-workflow identity, and all four platform archives with their sizes, BLAKE3 digests, SHA-256 compatibility digests, and Sigstore bundle names. Protected manual promotion advances signed, expiring `stable`, `dev`, or `nightly` pointers only to those immutable releases. `astrid update` authenticates the pointer, manifest, and archive; rejects generation rollback, same-generation equivocation, expiry, digest drift, and workflow-identity drift; and follows deliberate higher-generation rollbacks for self-managed installations. An opt-in daily nightly train reuses the same tag-bound signer, remains disabled by default, and still requires protected release and channel approvals. A merge to `main` never publishes a release or moves a pointer. - **Operators can enforce a distro for `astrid init`.** `ASTRID_ENFORCED_DISTRO` supplies the distro source and rejects CLI attempts to override it. Standalone `astrid init` requires an explicit `--distro`; Astrid Runtime never chooses a product distro. Closes #1253. - **Passive content-addressed capability-registry primitives.** Astrid now has exact capability IDs, typed content-bound references, immutable registered definitions, deterministic BLAKE3 semantic digests and canonical registry manifests. Existing profile persistence, wildcard evaluation, bootstrap, socket and wire behavior remain unchanged. Closes #1233. Refs #1228. - **Distro init can grant exactly installed capsules to an explicit target.** `astrid --principal init --target-principal --grant-capsules` ensures the runtime daemon, verifies the operator has `agent:modify` authority over an existing target before provisioning, and applies the installed set through the shared `admin.agent.modify` path. Distro capsules require a concrete released version or tag; identity and declared version are checked before install mutation, and locks record the version and WASM hash that actually landed. Fresh-lock reuse rehashes the installed content blob before names become grants. Concurrent provisioning of one target is rejected, and recovery commands preserve the operator identity. The target defaults to the process principal when omitted; no principal name receives special treatment. Signed `.shuttle` grant composition remains deferred and fails explicitly. Closes #1195. - **Capability-registry revision 1 now has fixed semantics and BLAKE3 digest vectors.** All 51 kernel entries bind scope, target kinds, delegability, privileged status and provenance. Kernel/admin request mappings and the current capsule-side secondary enforcement constants resolve through the registry in tests, while the complete role partition is frozen independently. Authorization and persisted state remain unchanged. Closes #1235. Refs #1228 and #1233. ### Changed - **Device key IDs now use BLAKE3.** The short per-device handle is derived from the first eight bytes of `BLAKE3(pubkey_hex_bytes)`. Profile loading already treats the stored `key_id` as informational and re-derives it from the public key, so existing local profiles self-heal; device-scoped bearer sessions must authenticate again after upgrading. - **Self-managed updates now authenticate the release publisher before any archive is extracted or installed.** The updater requires a Sigstore bundle for the exact archive bytes, verifies it with fresh public-good trust material, and pins the certificate to Astrid's release workflow, repository, tag, and GitHub Actions issuer. Only an authenticated archive can enter the independent BLAKE3 integrity stage; missing, duplicated, malformed, or mismatched evidence fails closed with a distinct publisher-authentication or integrity error. Homebrew and Cargo installs remain delegated to their package managers, and the signed SHA-256 compatibility manifest remains available to downstream tooling. Before publishing, release automation now requires both Cosign and the updater's native production verifier to accept every generated archive and bundle pair. Existing v0.9.x self-updaters cannot enforce the new publisher policy retroactively; it applies from the first release containing this updater onward. Closes #1250. - **Astrid-owned identifiers now use domain-separated BLAKE3.** Invite and pair-device token stores carry an explicit schema and invalidate legacy SHA-256 records that cannot be rehashed without their raw secrets; newly issued bearer tokens use type-specific `astrid_inv_` and `astrid_pair_` prefixes, while fingerprints use an explicit `blake3:` label. CLI key metadata self-heals from the retained public key. Public-key fingerprints share a typed derivation primitive, MCP binary pins now carry an honest `blake3:` label, and gateway env-write logs no longer expose dictionary-testable fingerprints of low-entropy values. External SHA-based protocols such as SRI, Git, and registry checksums remain unchanged. Closes #1247. - **Astrid release archives now use BLAKE3 as their primary integrity manifest.** Release automation publishes, signs, and attests `BLAKE3SUMS.txt`, while retaining a signed `SHA256SUMS.txt` compatibility manifest for Homebrew and existing downstream tooling. Self-managed updates require a strict lowercase BLAKE3 entry and reject absent, malformed, duplicate, or SHA-only manifests. Existing v0.9.x installations can still cross the boundary through the compatibility manifest. External protocol requirements remain unchanged. Closes #1249. - **Project runtime state now uses one validated workspace layout.** The CLI and daemon default to `.astrid`, while distributions can select another safe relative directory name through `--workspace-state-dir` or `ASTRID_WORKSPACE_STATE_DIR`. Config, capsule installation and discovery, kernel boot, gateway source checks, hooks, and WIT garbage collection share the selected layout and reject persistent symlink/reparse redirection anywhere in workspace capsule and hook trees before reading them. They never scan both project roots. CLI uplinks, including long-lived MCP reconnects, and project-sensitive management reads reject a daemon booted for a different project or layout; daemon stop remains available as a recovery operation. - **Runtime E2E now stages the pinned Unicity AOS monorepo.** The workflow preserves the AOS Cargo workspace outside the core checkout and supplies compatibility directory aliases for the existing runtime harness, replacing seven historical per-capsule repository checkouts. Closes #1220. - **Astrid Runtime no longer silently selects a product distro.** Standalone `astrid init` and `astrid distro apply` now require an explicit distro, first-run bootstrap only creates runtime state, self-update refreshes only an already locked distro, and agent creation no longer carries a product-distro default. Closes #1214. - **Removed residual product distro policy from Astrid Runtime.** Standalone distro installation now requires an explicit `@owner/repo`, URL, local manifest, or signed `.shuttle` input; it no longer manufactures a source URL from a bare distro name. Background update does not reconstruct a source from a lockfile identifier, and runtime guidance, examples, and mutable repository links use neutral/current values while published WIT and package identities remain unchanged. Closes #1216. - **Daemon status and shutdown now use one typed runtime-control path.** `astrid status` propagates daemon connection and response failures instead of reporting success, and `astrid stop` sends its shutdown request through the authenticated, correlated uplink client. Closes #1210. - **CLI runtime-control requests now use the shared typed uplink client.** `astrid status`, `ps`, `who`, and `doctor` no longer rebuild socket frames or parse response envelopes themselves; they use `astrid-uplink` for the same authenticated, principal-bound `astrid.v1` control contract that external uplinks will consume. Closes #1208. - **Removed the product-branded README image.** Astrid's repository front door now presents the runtime without Unicity artwork. Closes #1204. - **Astrid's mutable repository identity now points to `astrid-runtime`.** Current documentation links, release automation, the WIT submodule origin, and the self-update default use the Astrid Runtime organization, while published crate, WIT, tag, artifact, and binary identifiers remain compatible. Closes #1202. - **Astrid's MCP bridge now uses RMCP 2.2.** The client and server adapt to RMCP's current content and elicitation APIs while preserving existing roots and sampling support. The CLI prompt also follows terminal color preferences, including `NO_COLOR`. The reviewed dependency refresh includes updated cryptography, signal handling, random-source, regex, ignore, and WebAssembly component tooling dependencies; `astrid-core` remains on TOML 0.8 because its public error types expose that API, and the optional SurrealDB backend remains on 3.1.5 pending a dedicated storage migration. Closes #1193. ### Fixed - **`astrid secret list` no longer reports an empty configured marker as a second legacy secret.** Secret-typed capsule fields may retain `""` in their env JSON to record completed configuration while the credential lives only in the principal file-secret store. Listing now omits that valueless marker but continues to flag every non-empty secret-typed env value as legacy plaintext. - **Offline init no longer performs the cached release check.** `astrid init --offline` and `astrid distro apply --offline` skip update discovery before command dispatch, so their no-network contract covers the complete CLI invocation rather than only distro and capsule resolution. - **Invite commands accept every token the runtime can issue.** `invite redeem` and `invite revoke` now treat a leading hyphen in an opaque base64url token as token data instead of misparsing it as an unknown option. - **Security Audit now uses a reproducible cargo-audit installation.** CI installs cargo-audit 0.22.2 with its published lockfile before invoking the pinned RustSec action, keeping the advisory scan compatible with the repository toolchain. Closes #1245. - **`astrid chat` now remains readable on light and dark terminal themes.** Primary chat text, user input, and the cursor inherit the terminal's configured foreground instead of forcing white or gray; assistant and running-tool bullets follow the same foreground. Closes #1178. - **The CLI no longer includes unused Syntect XML and YAML loaders.** Its embedded default syntaxes and themes do not need runtime plist or YAML loading, so the dependency graph now excludes `quick-xml` and `yaml-rust`; their obsolete Cargo Audit exceptions are removed. Closes #1190. ## [0.9.4] - 2026-07-09 ### Added - **WIT pin retention and contracts-skew visibility.** Capsules record BLAKE3 pins of their vendored WIT files in `meta.json` (`wit_files`), but the bytes behind an `astrid-contracts.wit` pin were only incidentally retained and no `astrid capsule` surface reported when a capsule's contracts snapshot drifted from the daemon's — a real gap found on a live deployment where one side-loaded dev capsule pinned newer contracts than 13 registry capsules, invisible until someone `b3sum`-ed files by hand. Because the contracts are pure data-shape records over the bus (zero WIT funcs), such skew never fails at link time; it fails silently at runtime when a record shape moves. Install now content-addresses every vendored WIT file into a dedicated blob store at `~/.astrid/wit/store/.wit` (moved out of the top of `wit/`, which now holds only the daemon's canonical named copies, so `astrid capsule wit gc` sweeps the store without endangering them), and seeds the daemon canonical `astrid-contracts.wit` first-writer-wins on the first install that vendors it. Three read paths surface skew, **warn-only**: `astrid capsule show ` prints the capsule's contracts pin with a match / MISMATCH marker; `astrid capsule list` prints one summary line naming any mismatching capsule(s) (per-capsule pins in `--verbose`); and a successful install whose contracts pin differs from the canonical prints a notice. Side-loading an ahead-of-daemon dev build is legitimate, so skew is never a failure — install and every read path succeed regardless, and an absent canonical (fresh home, daemon never booted) degrades silently. Closes #1163. ### Fixed - **`astrid mcp serve` now keeps an ephemeral daemon alive for the MCP session lifetime.** The MCP stdio shim could be alive while the daemon still aged out underneath it, because the daemon's idle monitor only counts authenticated uplink connections and `mcp serve` had no dedicated session-lifetime guard. The shim now quietly ensures the daemon before starting, then opens a separate authenticated guard uplink for the same principal and holds it until the MCP process exits; if the daemon drops the connection, the guard reconnects. This makes Codex/Claude plugin sessions behave as real daemon clients without writing anything to the MCP stdout transport. - **The daemon is now always killable by `SIGTERM`, even when every worker is pinned by guest compute.** A live daemon wedged: alive but ignoring `SIGTERM` (only `SIGKILL` freed it), holding the audit-DB `LOCK`, with `astrid status` failing. Root cause: signals were handled by a `tokio::select!`, which can only fire when a tokio worker is free to poll the reactor — and enough concurrent *exempt* guest compute (an uplink broker's run-loop, pinned at `epoch_deadline = u64::MAX` with no yield callback) pinned every worker, so the `SIGTERM` arm never ran. SIGTERM/SIGINT/SIGHUP are now owned by a dedicated OS-thread watchdog (`astrid-daemon/src/signal.rs`) scheduled by the OS, independent of the tokio runtime: the first signal requests a graceful shutdown through the kernel's `shutdown_tx` watch channel (so the normal `kernel.shutdown().await` still runs when the reactor is healthy) and arms an 8s hard deadline on a separate thread that force-exits if the graceful path itself wedges; a second signal before the deadline force-exits immediately. The tokio signal arms were removed so the watchdog is the single owner (no double-handling). Killable-but-abrupt beats wedged, and the OS releases every lock on the forced exit. - **Exempt capsules can no longer starve the daemon.** An *exempt* capsule (owner holds `CAP_RESOURCES_UNBOUNDED` / `CAP_NET_BIND` / `CAP_UPLINK` — e.g. an `sage-mcp`/`sibyl-mcp` uplink broker) ran its run-loop guest with the wasmtime epoch deadline pinned at `u64::MAX` and no callback, so the guest fiber never reached a cooperative yield point; enough concurrent exempt compute pinned every tokio worker and starved the reactor (the direct cause of the `SIGTERM`-deafness above). Exempt run-loops now use the same finite epoch window as bound run-loops but with a **yield-always** policy (`exempt_epoch_action`): they remain unbounded CPU — never `Interrupt`-trapped, never fuel-out — yet cooperatively `Yield` the tokio worker every window and re-arm, so they can no longer starve the daemon. An OS cgroup remains the backstop for raw CPU burn. (The exempt *interceptor-pool* epoch path is deliberately unchanged this round — see the STOP-AND-REPORT note in review.) - **The daemon acquires its singleton lock before opening any shared store, so a boot-race loser reports "already running" instead of a raw store-lock error.** `Kernel::new` opened the KV and audit stores *before* acquiring the singleton advisory flock, so a second daemon losing the boot race died on a raw surrealkv `Database ... LOCK is already locked` after having already opened (and touched) the shared stores it should never open, instead of the actionable "already running (singleton lock held)" message. The flock is now the FIRST fallible boot step (`socket::acquire_boot_singleton_lock`), before the KV/audit opens; the listener bind (`socket::bind_listener`) runs later and no longer re-acquires it. The loser now fails at the flock and never opens the shared stores. - **The audit log is now closed on graceful shutdown, releasing its `LOCK` on exit rather than only on process death — and the store locks are released before the capsule drain.** `Kernel::shutdown` closed the KV store but never closed the audit log, so its persistent surrealkv `LOCK` was held until the process died — which is why a wedged daemon needed `SIGKILL` to free the audit lock, and why the next boot could then race the still-held lock. `AuditLog::close` (which flushes and closes through the backend's shared `Arc`, no exclusive ownership required) is now called during shutdown. Shutdown also reorders so the KV and audit stores close **first**, before the best-effort capsule drain: the drain is bounded per capsule but a large fleet could otherwise exceed the watchdog's force-exit grace and be killed with the lock still held. The drain itself is now concurrent (`drain()` yields one Arc per distinct runtime, so unload tasks never contend on the same `Arc::get_mut`), keeping the whole graceful path well under the grace so MCP subprocesses are actually disconnected rather than force-exited mid-drain. - **`astrid mcp serve` no longer orphans forever when its launching session dies without closing stdin.** The MCP stdio shim blocked on stdin EOF with no parent-death handling, so a client (Claude Desktop, an IDE, another agent runtime) that died without closing stdin left the shim alive indefinitely — an orphan pinning its daemon uplinks open (observed in the field: a 4-day orphan, each pinning ≥2 uplinks). `serve()` now races the stdin-EOF quit against a portable parent-death future (`mcp/parent_death.rs`, a low-frequency `getppid()`-change poll that works on macOS and Linux without platform syscalls); when the launching session dies the shim drops its transport and exits, freeing the uplinks. - **A crash-looping capsule no longer thrashes-and-leaks a fresh instance every ~10s, and a busy capsule that hiccups is no longer permanently disabled.** `restart_capsule` tore the old instance down via `Arc::get_mut` + `unload`, which is skipped when a live dispatcher consumer still holds an `Arc` clone (`strong_count > 1`) — yet it returned `Ok`, so the health monitor cleared the capsule's `RestartTracker`, reset its attempt budget every tick, and reloaded the capsule forever, leaking the old instance's run-loop/epoch-thread/subprocess each round. The old instance is now cooperatively stopped without exclusive ownership: `request_cancel` cancels the instance token, and the run-loop task races that token against its guest call (`select!`), so even a compute-bound run-loop stops (it now reaches a yield point every epoch window). The retry cap is now driven by **consecutive health failures**, pruned the moment the capsule recovers — not by the restart-call outcome. A restart that leaves the old instance lingering behind a still-held `Arc` (normal for a busy capsule whose dispatcher consumer holds a clone for up to its idle grace) is not counted as a failure, so a transient hiccup on a loaded capsule can no longer exhaust the 5-attempt cap and permanently disable it; only a capsule that keeps failing health across ticks is capped. - **Raised the per-principal routed sub-queue message-count cap from 256 to 16384 so a legitimate high-rate LLM token stream is no longer silently truncated or hung.** The per-principal routed queue (`PENDING_PER_PRINCIPAL_FALLBACK` in `astrid-events`) drops the oldest events once a principal's bucket exceeds the cap — a count-based flood guard secondary to the primary 1 MiB byte budget. An LLM token stream publishes one small event per token, so a fast provider (~2000 tok/s) emits thousands of tiny messages in a 1-2 s burst — far past 256, while the byte budget sits at ~80 KB for a typical response. The queue then dropped oldest, which either corrupted the accumulated response (missing interior tokens) or, when the terminal `Done` was the dropped event, hung the consuming turn indefinitely; short responses stayed under the cap and always completed, which is why only long generations were affected. For real (non-empty) deltas the 1 MiB byte budget binds first — it trips long before a bucket reaches the count cap — so the raised cap only governs a flood of near-0-byte messages the byte budget cannot see. Each queued slot is an ~8-byte `Arc` handle, but the `Arc` retains the whole event allocation, so that pathological flood is bounded per bucket at roughly the cap × per-event struct overhead (a few MiB), not a few KiB; proper memory-based budgeting for it is tracked in #1159. This admits a legitimate high-rate stream without weakening the anti-flood memory guard. Closes #1158. - **The daemon now seeds the canonical contracts baseline at boot, so already-installed fleets get skew visibility without waiting for an install.** #1164's canonical `~/.astrid/wit/astrid-contracts.wit` was seeded first-writer-wins by the *next* capsule install, which left existing homes with two gaps: an upgraded rig saw no skew for its current fleet until something installed, and on a mixed fleet whichever capsule installed first defined the baseline — so a lone side-load landing first could make the healthy majority read as skewed and the one drifted capsule read as clean (inverted noise on a security-adjacent surface). The daemon is now authoritative for the canonical: at boot it rewrites `wit/astrid-contracts.wit` from its *own* system fleet — the contracts pin the plurality of the daemon's `install_principal` capsules agree on, dereferenced from the retained content-addressed store (`wit/store/.wit`), never from a per-principal `home//wit/` mirror (which is last-writer-wins and would reproduce the order dependence). Refresh is daemon-authoritative: absent canonical is written, a differing canonical is overwritten (a fleet or daemon upgrade legitimately moves the baseline, and skew is measured versus the *running* daemon), byte-identical is a no-op. The plurality rule makes the boot baseline order-independent — a single side-loaded capsule on a different pin can no longer flip it away from the majority fleet. Because the pin is read back from untrusted on-disk `meta.json`, it is validated as a BLAKE3 hex digest before it is ever used to build a `wit/store/.wit` lookup path, so a tampered pin (e.g. a `../` traversal sequence) cannot dereference a file outside the store into the canonical. Install-time seeding from #1164 stays as a bootstrap fallback for CLI-only flows where no daemon ever booted. The refresh is best-effort and warn-only: a write failure logs a warning and never breaks boot, and a fresh home (or one whose fleet predates pin retention) simply writes nothing. WIT blob backfill for pre-retention installs remains impossible by definition — this heals the canonical/baseline only. Closes #1165. - **A git-managed workspace no longer diverts capsule writes into the in-process copy-on-write overlay.** The workspace VFS was built as an `OverlayVfs` unconditionally at capsule load, so every capsule write landed in a per-capsule temporary upper directory instead of the real workspace — invisible to spawned `cargo`/tooling and to the user, with no commit path to reconcile it. When the workspace is inside a git work tree the overlay must not engage: git is already the rollback, and writes have to reach the real files that other processes read. Capsule load now detects git management with gitoxide work-tree discovery (`gix-discover`'s `upwards`, which walks up from `workspace_root` for an enclosing repository exactly as git does — correctly following the `.git` *file* a submodule/linked worktree uses, validating the discovered `.git`, and stopping at filesystem boundaries) and, when a work tree is found, backs `HostState.vfs` with a direct `HostVfs` registered at `workspace_root` (no overlay, no upper tempdir — `overlay_vfs`/`upper_dir` stay `None`, exactly like the existing plain-`HostVfs` lifecycle-hook state). Detection is upward-only — git has no robust "contains a repo below" primitive, and the agent workspaces this guards are themselves git roots — and fails safe to the overlay when no repository is found. A non-git workspace keeps today's overlay behaviour unchanged; detection is automatic with no config flag. The OS-sandbox writable root, path confinement, and the security gate are untouched — direct writes still target `workspace_root`, already the sandbox's writable bind. - **A non-git workspace now gets *real* OS-level copy-on-write, so spawned processes and the fs host share one filesystem.** The in-process `OverlayVfs` (the non-git branch of the fix above) diverted capsule writes into a tempdir upper that only the fs host could see; a spawned `cargo`/`git`/`build.rs` opened the workspace through the OS and read the pristine lower, never the upper, so copy-on-write was invisible to exactly the tools that matter. Capsule load now backs a non-git workspace with a new `astrid_vfs::workspace_cow` mechanism — a `WorkspaceCow` trait with an APFS `clonefile(2)` backend (macOS), an `overlayfs` mount backend (Linux, native `mount(2)` when the daemon holds mount authority, else `fuse-overlayfs`), and a fail-closed No-CoW backend — that establishes a single real **merged** directory the principal writes to AND spawned processes run in. `HostState.workspace_root` (the fs-host confinement root, the security gate's root, and the OS-sandbox writable root + cwd for every spawn) becomes that `merged_path`, so a spawned `cargo` builds against the copy-on-write tree where the writes actually are; `promote` (commit the merged tree into the pristine workspace) and `rollback` (discard it) are the safety net, exposed as audited `PromoteWorkspace`/`RollbackWorkspace` kernel admin requests (capabilities `self:workspace:promote` / `self:workspace:rollback`) and torn down on capsule unload. The copy-on-write upper/pristine directories are masked from spawned children (a new `extra_masks` set threaded into the sandbox's hidden-path list) so a child cannot write them directly and bypass the promote/rollback gate. When the clone/mount cannot be established the backend fails **closed** to direct writes (No-CoW) with a warning naming the reason — never a silently faked copy-on-write. The APFS and No-CoW backends are runtime-tested; the Linux `overlayfs` mount is compile-checked with an `#[ignore]`d CI-validated integration test. Making the workspace per-principal is a separate follow-up. ### Security - **Bootstrap authority boundaries now fail closed.** Kernel management requests require a valid principal instead of falling back to the `default` bootstrap administrator, malformed connection lifecycle identities cannot move its counter, bootstrap profile/key and legacy-profile migration failures abort kernel construction, and agent modification cannot remove `default` from the built-in `admin` group. The local CLI retains its intentional active-principal default before requests reach the kernel. Closes #1256. - **Capsule dispatch now preserves device attenuation.** Stamped events resolve one authority snapshot before matching; invalid, revoked, or disabled identities fail closed. `capsule:list` controls global describe visibility, while exact `capsule:access:any` controls unrestricted execution. Closes #1239. - **Audit views now use the live kernel authority evaluator.** Historical and streaming firehose access carries the authenticated device scope; revoked, disabled, malformed, unknown, or narrowed credentials fall back to the caller's own records. Historical continuation cursors bind the immutable audit-entry identity and visibility scope, preventing same-second appends from shifting the page boundary and rejecting unsafe widening. Co-located callers that need firehose visibility must now pass the kernel evaluator via `astrid_gateway::run_with_capability_probe(...)` or `routes::build_with_capability_probe(...)`; the legacy default runner and router remain self-only. Closes #1241. - **Device attenuation now applies to every kernel authority view.** Capsule, agent, and group inventory checks use the authenticating device scope, and a scoped pair request containing bare `*` requires effective pair-admin authority. Explicit pair-scope allow and deny patterns pass canonical validation before persistence, and structural no-escalation denials produce a failure audit row without a preceding success row. Malformed, unknown, and revoked device IDs fail closed, and cache invalidation prevents stale pre-revocation profiles from being republished. Closes #1237. - Bumped the locked `crossbeam-epoch` transitive dependency to `0.9.20`, clearing `RUSTSEC-2026-0204` (published 2026-07-06 — an invalid pointer dereference in the `fmt::Pointer` / pre-0.9 `fmt::Display` impls for `Atomic` and `Shared` when the underlying pointer is null or invalid) from the RustSec audit. The advisory landed the day after the 0.9.3 release and turned the Security Audit CI check red on `main` and every open PR; nothing in our own code changed. Lockfile-only, same-minor patch — no other dependency moved. Closes #1161. ## [0.9.3] - 2026-07-06 ### Added - **`astrid capsule check` — a static, CI-friendly linter for a capsule's tool wiring.** Several capsule-authoring mistakes fail *silently* at runtime: a `#[astrid::tool]` with no `[subscribe] "tool.v1.execute."` route advertises in `tools/list` but never executes (#1127); a tool capsule without the mandatory `[publish]` boilerplate can't return results or answer the describe fan-out; a `[subscribe]` handler that isn't `tool_execute_` routes then gets denied. `astrid capsule check [PATH]` cross-checks the `#[astrid::tool("…")]` annotations in `src/` against the `Capsule.toml` `[subscribe]`/`[publish]` tables and reports each problem with the exact line to add, exiting non-zero on any finding so it drops straight into a CI job or pre-commit hook. It is deliberately **static** — it derives the advertised-tool set from source, not by instantiating the WASM component — so it needs no build, no daemon, no WASM runtime, and no `ASTRID_HOME`, making it fast and deterministic (`cargo check` for capsules). Check #1 reuses the same `tools_missing_execute_route` predicate as the kernel's load-time warning, so the CI gate and the runtime can never disagree; a `--deep` mode (ephemeral-load + the real `tool_describe`) is a documented later addition. Closes #1129. ### Changed - **`astrid-kernel` now compiles for `wasm32-unknown-unknown`, completing the browser-host portability seam.** The portable composition root (`Kernel::with_resources`) built cleanly, but the kernel crate still unconditionally compiled its native-only machinery: the Wasmtime capsule loader and MCP host client, the `cap-std` VFS and per-principal overlay registry, the disk-backed capsule install/discovery/reload/health paths, the Unix socket manager (`UnixListener` bind + singleton flock), the management-API router, and the idle-shutdown monitor (`process::exit`). Those are now target-gated to native (`cfg(not(all(target_arch = "wasm32", target_os = "unknown")))`, with the socket manager and native composition root `Kernel::new` under `cfg(unix)`), and their native-only dependencies (`astrid-mcp`, `astrid-vfs`, `astrid-capsule-install`, and `tokio`'s `full`/`signal` features) move into a matching target-specific dependency table; the base `tokio` set is trimmed to the wasm-clean subset. The `Kernel.kv` and `KernelResources.kv` fields change from the concrete `Arc` to `Arc` so a portable host can inject its own backend — a new default `KvStore::close` trait method (a no-op for backends with nothing to flush, delegating to the inherent flush on `SurrealKvStore`) carries the shutdown flush that previously required the concrete type, superseding the #1136 note. The pairing token/invite stores keep their types and validation but gate disk persistence to native (in-memory, no-op on the browser arm), and `astrid-runtime` gains a `spawn_blocking` facade (native `tokio` re-export; inline execution on the single-threaded wasm arm). `astrid-kernel` is added to the `wasm32-unknown-unknown` portability CI guard. This is a deliberate 0.x break: `Kernel.kv`/`KernelResources.kv` are now trait objects, and `Kernel::new`/the `socket` module now require `cfg(unix)`. Native `build`/`test`/`clippy`/`fmt` are unaffected. Closes #1148. - **`astrid-capsule`'s engine-agnostic machinery now compiles for `wasm32-unknown-unknown`, groundwork for an alternate capsule host.** The dispatcher, registry, access resolver, context, trait layer, and manifest analysis (readiness/topic/toposort) are pure logic, but the crate unconditionally compiled the Wasmtime engine and its native-only dependencies (`wasmtime`/`wasmtime-wasi`/`wasmparser`, `reqwest`, `socket2`, `tokio-util`, `notify`, `nix`, `astrid-mcp`, `astrid-workspace`, and `tokio`'s `net`/`rt-multi-thread` features), so no alternate engine host could reuse the shared machinery. The Wasmtime engine, the process-backed MCP engine, manifest discovery, the file-watch hot-reload path, and the per-Store `StoreMemoryMeter` are now target-gated to native (`cfg(not(all(target_arch = "wasm32", target_os = "unknown")))`), and their native-only dependencies move into a matching target-specific dependency table; the portable modules route task spawning and timers through the `astrid-runtime` facade. The host audit-sink contract (`HostAuditSink`, `HostAuditEvent`, `HostAuditOutcome`) — pure Rust with no Wasmtime dependency — moves out of the engine subtree to `astrid_capsule::audit_sink`; the crate-root re-exports (`astrid_capsule::{HostAuditSink, HostAuditEvent, HostAuditOutcome, CapsuleRuntimeLimits, HttpLimits}`) stay reachable and unchanged. An alternate host can now supply its own `ExecutionEngine` behind the same `dyn Capsule` trait. `astrid-capsule` is added to the `wasm32-unknown-unknown` portability CI guard; native `build`/`test`/`clippy`/`fmt` are unaffected. Closes #1142. - **The kernel's task-spawning and time surface is now selected per target through a new `astrid-runtime` facade crate, groundwork for alternate host profiles.** The kernel spawned tasks and read the clock via `tokio::spawn`, `tokio::time::{sleep, interval, timeout, Instant, MissedTickBehavior}`, `tokio::time::Instant`/`std::time::Instant`, and `std::time::SystemTime` directly at ~50 call sites — none of which exist on `wasm32-unknown-unknown` (no tokio runtime, no OS threads, no `std::time` clock), so a portable host could not reuse the kernel's scheduling and timekeeping. Those calls now route through `astrid-runtime`, a single seam that selects the surface at compile time: the native arm (`cfg(not(target_family = "wasm"))`) is pure `tokio`/`std` re-exports — every item resolves to the exact same type it replaced, so it is zero-cost with no behaviour change on native — while the wasm arm (`cfg(target_family = "wasm")`) maps the task surface to the JS microtask queue (`wasm-bindgen-futures`), the timer surface to JS timers (`wasmtimer`), and the wall clock to the browser time bridge (`web-time`). The facade is deliberately a `cfg`-gated re-export rather than a trait: the implementation never varies within one platform build, so there is no runtime polymorphism to model. `astrid-runtime` is added to the `wasm32-unknown-unknown` portability CI guard; native `build`/`test`/`clippy`/`fmt` are unaffected. See #1140. - **The kernel's pure-semantics crates now compile for `wasm32-unknown-unknown`, groundwork for a browser host profile.** `astrid-types`, `astrid-core`, `astrid-crypto`, `astrid-capabilities`, `astrid-audit`, `astrid-events`, `astrid-config`, and `astrid-approval` build for the wasm target with no behaviour change on native. Two structural blockers were cleared. First, the workspace-level `tokio` dependency pinned an additive base feature set (`net`, `signal`, `rt-multi-thread`) onto every `workspace = true` consumer — including leaf crates that only asked for `sync`/`time` — dragging in `mio` (which `compile_error!`s on wasm) and the multi-threaded runtime (which needs OS threads). The base is trimmed to the portable subset (`sync`, `macros`, `time`, `rt`, `io-util`); the native-only features are now declared locally by the crates that actually use them (uplink/cli/kernel/gateway/capsule for `net`, daemon/kernel for `signal`, daemon/emit/cli/capsule-install/capsule/hooks/kernel/gateway for `rt-multi-thread`). Second, `getrandom` (reached via `uuid`/`rand` at major 0.4 and `surrealkv` at major 0.3) refuses to build for wasm without a backend selection: the `wasm_js` feature is now enabled through `[target.'cfg(target_family = "wasm")'.dependencies]` entries on the crates owning those edges, paired with the build-time `--cfg getrandom_backend="wasm_js"` that `scripts/check-wasm-portability.sh` (a new `cargo check`-only regression guard, wired into CI) sets. The audit store's `block_in_place` fast path (native-only) is cfg-gated to its scoped-thread fallback on wasm. No public API changes; native `build`/`test`/`clippy`/`fmt` are unaffected. - **Engine-agnostic capsule types extracted into a new `astrid-capsule-types` crate.** The pure data the kernel routes and gates on — the capsule manifest (`CapsuleManifest` and its validation/error types), the capsule id (`CapsuleId`), the per-principal fuel and memory peak ledgers (`FuelLedger`/`FuelRateLimiter`, `MemoryLedger`), and the resource/HTTP limit types (`CapsuleRuntimeLimits`, `HttpLimits`) — lived inside `astrid-capsule`, the crate that owns the Wasmtime execution engine. Because those types were trapped in the engine crate, every component that needs only the *data* — the kernel included — transitively depended on Wasmtime, and a second capsule engine profile could not share the vocabulary without dragging in the engine it replaces. They now live in a `wasmtime`-free, `tokio`-free `astrid-capsule-types` crate that every engine can depend on; `astrid-capsule` re-exports each moved item at its exact original path, so the kernel and all other consumers compile unchanged. Behaviour that genuinely touches the engine stays behind: the tokio-`Semaphore` constructors on `CapsuleRuntimeLimits` become the `CapsuleRuntimeLimitsExt` extension trait, and the per-Store `StoreMemoryMeter` (`wasmtime::ResourceLimiter`) keeps feeding the shared `MemoryLedger`. A pure move for every consumer that uses the re-exported paths — no behaviour changes, and the manifest's untrusted-input `skip_deserializing` protections and parser-isolation tests move intact. One API-shape caveat for external consumers of `astrid-capsule`: the semaphore constructors are now trait methods, so `CapsuleRuntimeLimitsExt` must be in scope to call `blocking_semaphore()`/`io_semaphore()`. Closes #1133. - **The kernel's boot is now split into a portable composition root and a native one, groundwork for alternate host profiles.** `Kernel::new` performed every platform-specific side-effect inline — resolving the Astrid home, opening the SurrealKV store and the audit log, loading the runtime signing key, binding the singleton Unix socket (acquiring the single-instance flock), writing the PID file, and generating the session token — so the kernel could only ever be constructed on a native host that supplies exactly those facilities. Resource acquisition is now inverted to the caller: a new `Kernel::with_resources` takes an injected `KernelResources` bundle (home, KV, audit log, runtime key, session token, socket listener, singleton lock) and performs all the side-effect-free wiring, while `Kernel::new` keeps its exact signature and becomes the thin native composition root that acquires those resources and delegates. An alternate host (e.g. a browser WebAssembly build) can build its own `KernelResources` and call `with_resources` directly. The kernel also now imports its engine-agnostic capsule vocabulary (`CapsuleId`, `CapsuleManifest`, `FuelLedger`/`FuelRateLimiter`, `MemoryLedger`, `CapsuleRuntimeLimits`, `HttpLimits`, `CapsuleError`/`CapsuleResult`) directly from `astrid-capsule-types` rather than through `astrid-capsule`'s re-exports — the same types, so no behaviour change. `Kernel.kv` stays the concrete `SurrealKvStore` because the shutdown flush calls its inherent `close()`, which is not on the `KvStore` trait. No public signature changes; native `build`/`test`/`clippy`/`fmt` are unaffected. Closes #1136. ### Fixed - **The kernel now actually boots on `wasm32-unknown-unknown` — the first wasm smoke boot of `Kernel::with_resources` surfaced three compiles-but-fails-at-runtime portability bugs, all fixed.** The compile-only portability guard cannot see code that builds for wasm but panics the moment it runs, and the first real boot hit three in sequence. (1) `CapabilityStore` presented a sync API over the async `KvStore` through a private `block_on` helper that built a throwaway current-thread tokio runtime (whose time driver reads `std::time::Instant` — an instant panic on wasm) or parked a scoped OS thread (wasm has neither); the persistence-touching methods (`with_kv_store`/`with_persistence`, `add`, `get`, `find_capability`/`has_capability`, `revoke`, `use_token`/`mark_used`/`is_used`, `list_tokens`, `cleanup_expired`) and `CapabilityValidator::check`/`validate_by_id` are now genuinely `async` and `block_on` is deleted — callers were already in async contexts. This is also required for the browser host regardless of the panic: an IndexedDB-backed `KvStore` is natively async and can never be blocked on. The used-token set moves behind a `tokio::sync::RwLock` so `mark_used` can keep holding the write guard **across** the persist — the guard-across-write is what closes the single-use-token TOCTOU replay window, and an async-aware lock preserves it legally across the `.await`. (2) The fuel-rate-limiter window stamps in `astrid-capsule-types` and the IPC rate-limiter/route-queue stamps in `astrid-events` used `std::time::Instant` directly (`FuelRateLimiter::default()` panicked at kernel boot); they now use `web_time::Instant`, which *is* `std::time::Instant` on native (same type, zero change for native callers) and the JS performance clock on wasm — and the routed receiver's park-with-deadline goes through the `astrid-runtime` timer facade instead of `tokio::time::timeout`. (3) The boot-time `INTERNAL_SUBSCRIBER_COUNT` debug assert is target-gated: the browser profile wires only the event dispatcher and the bus activity monitor (the router pair, connection tracker, and grant-on-first-use observer are native-gated machinery). With these, `Kernel::with_resources` boots on wasm under a JS event loop, the injected KV round-trips through the kernel handle, and the event bus delivers published events — verified by a `wasm-bindgen-test` smoke boot on node, which becomes the browser repo's regression harness for exactly this class of bug. This is a deliberate 0.x break: the listed `astrid-capabilities` methods are now `async`. Native behaviour is unchanged. Closes #1152. - **The `astrid-audit` surface no longer panics on `wasm32-unknown-unknown` — its storage layer is now genuinely async end-to-end.** Same class as the `CapabilityStore` boot panic (#1152): `astrid-audit` bridged its sync `AuditStorage` trait to the async `KvStore` through a private `block_on` helper whose no-runtime path built a current-thread tokio runtime with `enable_all()`, and that time driver reads `std::time::Instant` — an instant panic on the browser profile. `AuditLog::in_memory` constructed fine, but every `append`/`get`/`get_session_entries`/`count`/`verify_*`/`list_sessions`/`flush` call panicked, blocking the browser audit read path. The `AuditStorage` trait (via `async_trait`) and the `AuditLog` read/write methods are now `async` and `block_on` is deleted — the KV calls are awaited directly. The per-chain head cache moves behind a `tokio::sync::RwLock` so `append` keeps holding the write guard **across** the persist `.await`; that guard-across-write is what keeps read-prev-hash → sign → persist → advance-head atomic per chain, preserving the signed-ordering invariant that stops concurrent same-chain appends from forking the hash chain (the `std` guard could not legally live across an `.await`). Chain linkage, ed25519 signing, entry ordering, and the fail-secure "audit failure = continue + alert" posture are all unchanged. The kernel boot (`open_audit_log` → `verify_all`), the admin-request audit path (`record_admin_audit`), the gateway historical-query handler, and the MCP secure client were already in async contexts and now `await`. The one remaining synchronous caller — the native-only wasmtime host-audit sink (`HostAuditSink`/`KernelAuditSink`) — keeps a native-gated block-on that runs inside the host fn's existing `bounded_block_on` blocking context (no new concurrency class, no OS thread spawn); the whole sink module is `#[cfg]`-gated off `wasm32-unknown-unknown`, so no sync-over-async remains reachable on the browser profile. This is a deliberate 0.x break: the listed `AuditLog` and `AuditStorage` methods are now `async`. Native behaviour is unchanged. Closes #1154. - **The event dispatcher no longer resolves the Astrid home from the process environment — running `cargo test` can no longer scaffold fixture principals into the developer's real `~/.astrid`.** The dispatcher's per-principal auto-provision called `AstridHome::resolve()` (`$ASTRID_HOME`/`$HOME`) and wrote directory trees from library code at the point of use, so a workspace test run with no `ASTRID_HOME` isolation created over a thousand fixture principal homes (`user-0`…`user-999`, `alice`, `bob`, …) inside the operator's production Astrid home, alongside a live daemon. The home is now injected at construction: the kernel passes its already-booted home via a new `with_home(...)` builder (mirroring `with_identity_store`/`with_access_resolver`), tests pass a tempdir home and get filesystem isolation for free, and with no home injected auto-provisioning is disabled entirely (fail-closed) — the dispatch path never consults the environment and never writes to the filesystem. The provisioning logic moves into a focused `dispatcher::provision` module with the identity-store gating, retry-on-failure, and known-principal caching behaviour unchanged. Production behaviour is identical: the kernel's booted home is the same home the old call resolved. Closes #1145. - **A stalled uplink client can no longer freeze the entire CLI proxy — the host's framed socket writes are now bounded by a deadline.** The capsule-cli guest services every uplink connection from a single run loop and broadcasts subscription messages to all matching clients; that send path lands in the host's framed `write`/`write-bytes`, which wrapped `write_all` in a concurrency-and-cancellation guard but bounded no *duration*. One connected client that stopped draining its socket (e.g. an orphaned `astrid mcp serve` whose output pipe had filled) let the kernel send buffer fill and `write_all` await forever, freezing accepts, binds, and forwards for *every* uplink while the daemon otherwise stayed healthy — recoverable only via `astrid restart`. Both host write paths now bound the whole operation — mutex-lock acquisition included, since a stuck write holds the per-stream lock and a second write would otherwise block unbounded on the lock itself — with `tokio::time::timeout`: an explicit `write_timeout` on a TCP slot where set, otherwise a payload-scaled ceiling (`5000 + len/1024` ms) that mirrors the framed payload-read bound. A non-draining peer now becomes a prompt write error, which the guest proxy already handles by evicting the client, so the run loop keeps serving everyone else. The Unix arm (which carries no per-slot timeout) always uses the default ceiling. Closes #1144. - **A spawned daemon now detaches into its own session, so a spawner teardown can't kill it before it shuts down cleanly.** The CLI spawned the daemon as a plain child process with no `setsid`/`process_group`, so it inherited the spawner's session and process group. The daemon handles `SIGTERM`/`SIGINT` gracefully but not `SIGHUP` (default disposition = terminate, no cleanup), so when the spawner's process group or session tore down — a smoke-test harness exiting (`killpg`), a controlling terminal closing (`SIGHUP`) — the daemon was killed *without* running its graceful shutdown: no shutdown log, no crash report, and stale run files (`run/system.{sock,pid,ready,token}`) left behind for the next `astrid start` to trip on. It also made a persistent `astrid start` from a terminal a latent orphan that died on terminal close. Fixed defence in depth: (1) the daemon binary calls `setsid(2)` as the very first action in `main`, before the async runtime or any boot work, becoming a session leader with no controlling terminal and immune to the spawner's SIGHUP/`killpg` — one change covering both the ephemeral and persistent spawn modes; (2) an unexpected `SIGHUP` is now routed through the same clean shutdown path as `SIGTERM` instead of the default terminate; and (3) `astrid start` self-heals a crashed daemon's stale run-dir — when the socket is unreachable *and* no recorded daemon PID is still alive (a crashed daemon), it clears all stale sentinels (socket, readiness, PID) and spawns a fresh daemon onto a clean run-dir, so recovery no longer needs a `restart`. It never kills or clobbers a live daemon: a reachable daemon takes the "already running" fast path, and a recorded PID that is still alive but unreachable (a daemon mid-boot still binding its socket, mid-shutdown, wedged, or a recycled PID) is left strictly alone with a pointer to `astrid restart`, which owns the identity-gated force-recycle. Distinct from the `astrid stop` wedge fix (#1120): that was a live daemon reporting success on ACK; this is a dead daemon killed by session teardown. Closes #1138. - **`astrid stop`/`astrid restart` no longer report success while a daemon wedges mid-shutdown and leaks the state-db lock.** The graceful stop path treated the daemon's shutdown *acknowledgement* as proof it had exited: it printed "Astrid daemon stopped" and deleted the recorded PID file the moment the daemon ACK'd the `Shutdown` request — before the process actually exited and released the singleton/state-db lock. A daemon that then wedged during its own shutdown sequence (a capsule run-loop that would not cancel, a hung audit-DB flush) kept the lock forever, but the PID file — the only handle `restart`'s orphan-killer needs — was already gone, so `restart` skipped the kill and the next `start` died on the held lock with a raw "Database … is already locked by another process". Stop now captures the recorded PID *before* requesting shutdown, and after the ACK waits for that process to actually exit; if it is still alive past the grace window it escalates through the same identity-gated `SIGTERM`→`SIGKILL` path used for an unreachable orphan. Runtime files (socket, readiness, PID) are removed only once the daemon is confirmed gone — when a kill cannot confirm exit (`StillAlive`/`Unverified`) they are kept so `start`/`restart` still find the recorded PID and print an actionable message instead of failing on the lock. The unreachable-daemon path is also hardened to sweep a wedged process whose socket has already vanished. Closes #1120. - **The wedged-daemon identity gate now survives an in-place binary upgrade.** Before signalling a stale daemon, the CLI confirms the live process's executable matches the path the daemon recorded at boot — a fail-secure guard against killing an unrelated process that recycled the PID. It resolved the live executable with `canonicalize`, which fails with `ENOENT` the moment `brew upgrade`/`astrid update` unlinks or replaces the running binary, so a daemon left wedged *by* an upgrade could never be confirmed and never reaped (the exact scenario that surfaced #1120 during the 0.9.1→0.9.2 rollout). The gate now compares against the kernel's retained exec path (Linux `/proc//exe` with its `" (deleted)"` marker stripped; macOS `proc_pidpath`) with an exact match, falling back to `canonicalize` only while the file still exists — so a swapped-out daemon still matches its recorded path, while a recycled PID running a different binary still does not. Refs #1120. - **`astrid update --check` now reports an available update on Homebrew and cargo installs, not only self-managed ones.** The command returned inside its Homebrew branch — "installed via Homebrew, run `brew upgrade`" — *before* it ever queried the release, so on a brew install (the common macOS case) it never learned whether an update actually existed, and the session-start doctor nudge that shells `astrid update --check` stayed silent for exactly those users. The check now runs first for every install method: it fetches the latest GitHub release and compares versions regardless of how Astrid was installed, then reports `Update available: vX → vY. Run ` with the command appropriate to the install (`brew upgrade astrid`, `cargo install astrid --force`, or `astrid update`). Only *applying* an update still defers to an external package manager; a self-managed binary is still swapped in place. Install method is classified from the resolved binary path (`InstallMethod::detect`: `Cellar` → Homebrew, `.cargo/bin` → cargo, else self-managed), and the CLI's own startup banner uses the same classification so cargo installs get the right instruction too. Works on both macOS and Linux. Closes #1121. - **A capsule tool that is advertised but has no execute route no longer fails silently — the daemon warns at load, naming the exact missing line.** A tool is advertised straight from its `#[astrid::tool]` annotation (the kernel's `tool_describe` probe bypasses the subscribe ACL), but the dispatcher routes execute calls *solely* from the manifest's `[subscribe]` handlers. So a tool whose `Capsule.toml` is missing (or has a mistyped) `tool.v1.execute.` subscription appears in `tools/list` yet silently never runs: no `tool.v1.execute.` dispatch, no capsule-log entry, no error — the call is dropped because it matches no interceptor route. A capsule author lost real time to this (the model fell back to hand-computing the result). The kernel now cross-checks each advertised tool against the capsule's interceptor routes at load (in `publish_capsules_loaded`, using the same `topic_matches` the dispatcher uses, so wildcard subscriptions don't false-positive) and emits a `warn!` for any advertised-but-unroutable tool, naming the exact `[subscribe]` line to add. Pure diagnostic — no routing or gating behaviour changes. Closes #1127. ## [0.9.2] - 2026-07-03 ### Fixed - **First-run consent no longer storms a `GrantRequired` for every ungranted capsule in the caller's view.** The dispatch access gate ran the grant-on-use check — and emitted a `GrantRequired` signal — for every ungranted capsule in the caller's view *before* checking whether the capsule's subscription matched the dispatched topic. A single `tools/call` on a fresh principal therefore fired a grant prompt for every ungranted view capsule, including all the capsules the call never touched, making first-run consent effectively unconvergeable. The gate now evaluates the cheap, manifest-local interceptor topic-match first (using the same `crate::topic::topic_matches` the delivery push uses) and engages the per-principal access gate only for a capsule that actually provides an interceptor for the requested topic; a non-matching capsule never reaches the gate. A matching ungranted capsule still fail-closed drops and signals exactly once, and behaviour for granted capsules, interceptor ordering, and non-tool topics is unchanged. Closes #1113. - **The MCP shim now resolves every grant a single `tools/call` needs, instead of only the first.** The shim resolved exactly one `grant_required` per call — it elicited consent, re-sent the call once, and passed the re-send's reply straight to the result reshaper — so when the re-sent call tripped the access gate on a *different* ungranted capsule, that second `grant_required` reply was returned to the client as the tool result (empty content, `isError: false`): a phantom empty success, while the broker kept a pending marker for a prompt that never surfaced. The shim now loops resolve→re-send→re-classify until the reply is grant-free or a bound (`MAX_GRANT_RESOLUTIONS = 8`, a distro's worth) trips; a present grant signal is never classified as terminal, so the only exits are a grant-free reply or an honest `isError` (malformed / denied / bound exceeded) — never a fabricated empty success. Ingress, approval-elicitation, grant dedup/marker, and fail-secure deny paths are preserved. Closes #1117. - **`astrid mcp serve` now actually pushes `notifications/tools/list_changed` when a capsule is installed/loaded/unloaded during a live session, so newly hot-loaded tools light up without a session restart.** The hot-reload watcher advertised `tools.listChanged` but never delivered a notification: (1) it seeded its baseline on a *separate*, short-lived uplink and then only ever *read*, so its own watch uplink was never bound to a principal — and the cli-proxy delivers a principal-stamped `astrid.v1.capsules_loaded` broadcast only to bound uplinks, so the watcher was silently starved of every reload signal; and (2) on a reload it re-enumerated the tool set over a fresh `tools/list` round trip that timed out precisely when a reload was in flight (the broker busy with its own describe fan-out), swallowing the push. The watcher now seeds its baseline *on the watch uplink itself* (that first request binds the connection) and reads the new tool surface directly from the `capsules_loaded` payload (`capsules[].meta.tools[].name`, principal-filtered) — the signal's documented intended contract — so the notification fires ~2 s after an install with no dependency on a follow-up request. Refs #1118. ## [0.9.1] - 2026-07-02 ### Added - **`astrid:process@1.1.0` host interface.** Additive successor to the frozen `astrid:process@1.0.0`, carrying the host-verified, read-only per-spawn file-injection surface (the `file-injection` record, the `injection-placement` variant, and the `spawn-request.file-injections` field). The kernel serves both versions off one implementation; capsules opt into injection by importing `@1.1.0`, while `@1.0.0` behaves as spawn-with-no-injections. Refs #1107, wit#19, #881. ### Fixed - **Shipped capsules that import `astrid:process@1.0.0` instantiate again — no rebuild required.** The per-spawn file-injection extension originally landed in-place on the frozen `astrid:process@1.0.0` WIT (wit#15), changing the shape of `spawn-request`. The component-model linker matches host imports structurally by package version, so every capsule compiled against the published pre-extension contract (all shipped capsules, built on astrid-sys 0.7.x) failed to instantiate on 0.9.0 with `component imports instance astrid:process/host@1.0.0, but a matching implementation was not found in the linker` — the sage supervisor could not `astrid init` and `astrid-capsule-shell` v0.2.0 failed to load. The kernel now restores `@1.0.0` to its published shape and serves it alongside the additive `@1.1.0` (dual-version host, mirroring the `astrid:http@1.0.0`/`@1.1.0` precedent), so existing capsules load unchanged. Refs #1107, wit#19, #881, #890. ## [0.9.0] - 2026-07-02 ### Breaking - **`astrid-uplink`'s kernel-request client API is now typed instead of `anyhow`.** `KernelClient::request` and `BusKernelClient::request` return `Result` instead of `anyhow::Result`; the public `is_timeout()` / `RequestTimeout` marker items are removed, and `KernelClientError` / `TimeoutKind` are added. Semver-major for this internal crate, but there are no external consumers and every in-workspace call site is updated (the gateway matches `KernelClientError::Timeout` directly for the new `504`). This underpins the gateway↔kernel timeout change described under Changed (#1092). - **Capsule loading is now principal-view aware while preserving content-addressed artifact reuse.** The runtime registry keys loaded instances by `(principal, wasm_hash)` and records per-principal views as `principal -> capsule id -> hash`, so two principals that install the same capsule bytes reuse the same on-disk artifact while keeping separate in-memory runtime instances, capsule sets, and versions. Dispatcher selection now fail-closes user-facing execute/describe surfaces to the caller's view, grant-on-use only fires for capsules already in that view, and kernel install/reload/unload target the authenticated caller's view rather than mutating `default`. Per-invocation CPU/memory attribution remains keyed to the caller principal through the existing fuel/memory ledgers. Closes #1069. - **The HTTP gateway's admin routes now enforce per-device capability scope (#999), closing a gap where a device-scoped bearer reached the kernel cap-gate as unattenuated full-principal authority.** The `KernelClient` transport (`astrid-uplink`) behind `GET /api/sys/status`, the new `GET /api/sys/readiness`, `POST /api/sys/capsules/reload`, and the `/api/capsules` list/get/install routes stamped only the caller's *principal* on outbound requests, never the authenticating device's `key_id` — so the kernel's `authorize_request` saw `device_key_id = None` and skipped the `DeviceScope` attenuation floor. A `use-only` device whose scope *denies* `capsule:list` / `capsule:reload` / `capsule:install` but whose principal *holds* it could therefore drive those routes (e.g. read the full loaded-capsule inventory via `/api/sys/readiness`, or trigger a reload). `KernelClient` now carries an optional `device_key_id` (`with_device_key_id`) and stamps it on every request via a unit-tested message builder; all six gateway call sites thread `CallerContext.device_key_id`, mirroring the bus-direct `BusAdminClient` path that already did so. Full-authority bearers stamp `None` (unattenuated — single-tenant behaviour unchanged). No WIT / host-ABI change — uplink transport + gateway routes only. Found by the branch's own adversarial security review. Refs #999. - **Capsule access is now per-principal, enforced kernel-side at dispatch — closing the gap where the capsule tool surface was global and any principal could invoke any installed capsule's tools.** Previously `find_matching_interceptors` matched the entire global registry on topic alone, so a non-admin principal (a `restricted`/`claude`-group identity or a delegated sub-agent) could invoke security/identity-sensitive capsule tools (`users.set_public_key`, `identity.save_identity`, `registry.set_active_model`) that only the operator should reach — per-principal isolation existed only at the data/resource layer (home/KV/secrets/ledgers), never at the tool surface. Access is now a per-principal **grant set**: a new `capsules: Vec` field on `PrincipalProfile` (`#[serde(default)]`, empty by default, validated like a `CapsuleId`) names the capsule ids a principal may invoke, and the dispatcher filters topic-matched capsules to that set for the **user-invocable surface only** — `tool.v1.execute.*` and `cli.v1.command.execute`. Every other topic dispatches unchanged: the internal orchestration mesh (`session.*`, `spark.*`, `registry.*`, `prompt_builder.*`, `context_engine.*`, lifecycle/hooks, llm streams) is never gated, so the runtime does not wedge and a dual-role capsule (e.g. `identity` handling both `tool.v1.execute.save_identity` and `spark.v1.request.build`) has its tool gated while its orchestration role stays open — the gate is keyed on the topic, not the capsule. An admin holding `*` bypasses the filter entirely (resolved via the same `CapabilityCheck` machinery `authorize_request` uses), so single-tenant `default` and admins are unaffected with zero migration. The resolver reuses the kernel-owned, in-memory `profile_cache` (an `RwLock`-read on the hot path, no per-event write — the lock-free discipline the fuel/memory ledgers follow) and the live group config, cloned into the dispatcher exactly as the ledgers are. **Fail-closed:** an unknown / `None` / `anonymous` caller, or any profile-resolution error, yields an empty grant set → no tool capsules visible; new principals inherit no capsule access. The principal used for the check is the kernel-stamped value on the IPC message, never a caller-supplied claim, so it cannot be forged. Operators grant/revoke via `astrid agent modify --add-capsule ` / `--remove-capsule ` (and the HTTP management API's `add_capsules`/`remove_capsules`), mirroring the existing `--add-group`/`--remove-group` mechanism exactly (same `agent:modify` gating, same audit path, idempotent). No WIT / host-ABI change — kernel-internal dispatch + profile schema + admin contract only. Refs #992. - **`astrid-audit::AuditAction` gains `NetConnect` / `NetBind` / `ProcessSpawn` variants and is now `#[non_exhaustive]`, and the `astrid-capsule` context structs `CapsuleContext`, `HostState`, and `LifecycleConfig` gain a public `audit_sink` field.** These land the per-action host-call audit coverage described under Security (#994). The changes are additive at the source level but semver-major for these internal crate APIs: downstream code matching `AuditAction` exhaustively must now carry a wildcard arm (the `#[non_exhaustive]` marker makes every *future* variant addition non-breaking), and any struct-literal construction of `CapsuleContext` / `HostState` / `LifecycleConfig` must include the new `audit_sink: Option>` field. There is no backwards-compatible form of these edits — adding a variant to an exhaustive enum and a field to an all-public struct are breaking by Rust's semver rules, and nothing is being *superseded*, so there is nothing to deprecate in their place. These are internal, unpublished workspace crates with no external consumers and every in-tree call site updated. Refs #994. ### Fixed - **`astrid init` now honours the global `--principal` flag, provisioning the named principal's home instead of always `default`.** `astrid init --principal ` (or `ASTRID_PRINCIPAL` / the active-agent context) previously wrote the distro's per-capsule env files and the `Distro.lock` under `default` even though the capsule files themselves already landed under the resolved principal — so a scoped principal was left with an empty capsule view, an empty-hash lock, and a re-provision on every run (its lock lived under the wrong home and never satisfied the principal-aware auto-init freshness check). Init now threads the resolved principal — the same identity `astrid capsule install` uses — through the lock path, env files, per-provider onboarding, and every post-install read-back, both on the online path and the offline `.shuttle` path. Provisioning is also honest: a run where every capsule failed no longer prints "Installation complete" or writes a `Distro.lock` (a stale lock would wedge re-runs) and exits non-zero; a partial run writes NO lock and reports the true installed/total count so a re-run retries the missing capsules instead of short-circuiting at the version-only freshness gate; the shell-PATH edit is idempotent via a whole-path-component match (never a bare substring, so it neither appends a duplicate block nor silently skips setup when an unrelated `…/bin_backup` path is present); and GitHub release/source resolution now authenticates with `GH_TOKEN` / `GITHUB_TOKEN` when present — lifting the anonymous 60/hr API ceiling that a full distro can exhaust mid-provision — while degrading gracefully to anonymous when no token is set. No WIT / host-ABI change — CLI provisioning only. Closes #1095. - **Restored shared-by-hash single-instance capsule loading, correcting the per-principal-instance regression that #1083 introduced against #1069's own spec.** #1083 keyed loaded runtime instances by `(principal, wasm_hash)` and built a **separate** `Arc` runtime (and WASM build) per principal, so a capsule referenced by N principals loaded N times — memory scaled principals × capsules. The registry now keys instances by content hash alone: a hash referenced by N principals loads **exactly once** (one runtime, one WASM build), with the per-principal `name -> hash` views layered on top. A per-hash refcount tracks how many principal views reference the shared runtime; it is cancelled/unloaded only when the **last** view releases it (`unregister_for` now returns whether the instance was torn down, so kernel unload/restart never cancels a runtime other principals still use). The kernel load path dedups by hash: before building, a loaded hash short-circuits to a view-add via `register_existing`/`contains_hash` (no second runtime). **Kept from #1083:** per-principal dispatch/visibility views (`cloned_values_for` scoping, admin bypass, fail-closed view-scoped describe fan-out), per-principal on-disk install sets, and per-invocation env/KV/secret/home overlays. Cancellation granularity now matches the shared-instance model too: each invocation waits on a per-principal child token of the instance token (installed with the other per-invocation overlays), and a non-last view release calls a new `request_cancel_for(principal)` that cancels exactly the departing principal's in-flight blocking host calls (approval/elicit waits, net/io/ipc waits) — so a `capsule remove` with a pending approval can no longer wedge the shared runtime for every other principal, while a full unload still cancels everything via the instance token cascade. Closes #1069; corrects #1083. - **Closed the cross-principal host-state fallback that a shared runtime would otherwise open.** A shared instance is loaded under no real principal's identity, so its load-time host state is a NEUTRAL, fail-closed placeholder: the `kv` is a physically-isolated in-memory store and the `secret_store` is deny-all — never `default`'s (or anyone's) real KV, secrets, or home. The `effective_kv` / `effective_secret_store` / `effective_home` / `effective_tmp` / `effective_capsule_log` accessors therefore fall back to that neutral placeholder for a principal-less / load-time invocation (which denies rather than exposing any principal's state), while EVERY caller that carries a principal — the owner/`default` included — gets its own `invocation_*` overlay scoped to the invoking principal. A regression test constructs a shared instance and asserts a `bob`-stamped invocation reads/writes bob's KV and secret namespaces, an `alice`-stamped invocation lands in alice's, and a principal-less invocation hits only the neutral placeholder. Per-invocation env config is unaffected: it is read per call from the invoking principal's `.config/env/{capsule}.env.json`, not baked into the load-time instance. Relates to #977, #1069. - **Made the shared capsule runtime present one stable, content-addressed IPC `source_id` to every principal, and kept the gateway's reply-trust derivation byte-identical.** The WASM engine's `capsule_uuid` seed and the gateway's `capsule_source_id_v1` both drop the principal segment (now `{capsule_id}\0{content_hash}`, same fixed namespace), so a shared runtime's replies carry one id all principals accept. Cross-principal reply routing is unaffected because it already keys on the principal-scoped routed subscription plus the body correlation id — the `source_id` is only an authenticity gate. A forged reply (wrong capsule/hash) is still rejected, and a new lockstep test pins the two derivations together so they cannot drift. Relates to #1069. ### Added - Added a standalone `fuzz/` package with initial structured fuzz targets for Tier 1 security predicates: IP block-set behavior, topic wildcard semantics, crypto wire encodings, capability resource patterns, and SSRF/local-egress helper predicates. The storage-heavy capability target and Wasmtime-heavy SSRF target are feature-gated so normal workspace builds stay unchanged. Closes #1086. - Runtime capsule commands can now use the existing `elicit()` host API, not only install/upgrade lifecycle hooks. The same host-generated request id, principal-stamped request stream, same-principal response filter, bounded timeout, and cancellation path apply. Runtime E2E now proves a normal capsule command elicit ignores a wrong-principal response before accepting the caller's answer, and crash recovery now kills the daemon while an elicit waiter is in flight to prove the command fails bounded instead of hanging. Closes #1068. - Daemon capsule discovery now includes the configured workspace root's `.astrid/capsules` directory in addition to the default principal install directory, so capsules installed by local workspace flows are picked up when the daemon starts or reloads from that workspace. Closes #1068. - Added runtime e2e coverage gates for the built-in CLI, gateway HTTP routes, and first-party capsule commands, plus a dedicated fake OpenAI-compatible runtime harness and CI workflow that installs current capsule artifacts in an isolated `ASTRID_HOME`. Closes #1068. - **`DevicePubkey` now exposes the same additive string/serde trait surface as the adjacent device-key newtypes.** The public typed wrapper for canonical ed25519 device public keys now supports `TryFrom`, `FromStr`, generic `PartialEq` comparisons, and serde serialization/deserialization while preserving the existing string-shaped profile and management API structs. Closes #1056. - **Registered and implemented `astrid:http@1.1.0` host-side — the per-request control surface for the HTTP client — alongside the frozen `@1.0.0`.** Both versions are now registered in the wasmtime linker and backed by ONE shared implementation, so existing capsules are untouched and new ones opt in package-by-package. A capsule importing `@1.1.0` gets caller-set controls via an all-optional `request-options` record (an empty value reproduces `@1.0.0` exactly): four-phase timeouts (`connect` / `first-byte` / `between-bytes` / `total`), redirect policy (`follow` / `error` / `manual`) with mandatory per-hop SSRF re-validation, response and decompressed-body size caps, an `auto-decompress` toggle, `https-only` scheme enforcement, and subresource integrity (`sha256` / `sha384` / `sha512`); plus `response-meta` (final URL after redirects, redirect count, elapsed ms, wire bytes) on every buffered response. The `@1.0.0` `http-request` / `http-stream-start` are thin shims that reproduce prior behaviour exactly (follow ≤10 redirects, 30 s timeout, 10 MB cap) and delegate to the same backend. **Security:** redirects on BOTH the buffered and streaming paths are now followed MANUALLY by the host, so every hop re-runs the full airlock — scheme check, the async per-capsule allow-list gate, and the egress airlock — with `Authorization` / `Cookie` stripped on a cross-origin hop and IP-literal targets blocked. This closes a redirect-SSRF gap on the streaming path where the per-capsule gate previously ran only on the initial URL (the IP airlock already ran per hop). `astrid:http` is the first host package to ship two versions, so this also threads the multi-version WIT staging (`build.rs` keys each staged `deps/` dir by `name@version`) and the dual-trait registration. Deferred to follow-ups (honest stubs, not silent gaps): the `http-upload` streaming request body, response `trailers` extraction, and un-stubbing the stream pollable / `body-stream`. No `@1.0.0` shape change — it is frozen and untouched. Closes #1049. Part of #1012. - **Operators can now tune the `astrid:http` host's seven previously-hardcoded limits via a new `[http]` config section, defaulting to today's exact constants so an absent section changes nothing.** The host's outbound HTTP ceilings — buffered-path default total timeout (`default_timeout_secs`, 30), streaming connect timeout (`stream_connect_timeout_secs`, 30), per-chunk streaming read timeout (`stream_read_timeout_secs`, 120), time-to-first-byte header-deadline floor (`header_deadline_secs`, 120), max redirect hops (`max_redirects`, 10), per-capsule concurrent-stream cap (`max_concurrent_streams`, 4), and the buffered-body default/ceiling (`max_response_bytes`, 10 MiB) — were fixed module constants. Each is now an operator knob resolved once by the daemon from `[http]` into a typed `HttpLimits` value (seconds pre-converted to `Duration` off the request hot path), stored on the kernel, and snapshotted onto every pooled `HostState` at load — a **global** value, the same for every capsule (it threads through the `WasmEngine`/loader like the runtime concurrency limits, not per-capsule like the local-egress allowlist). The request path reads its defaults and ceilings off `HostState.http_limits` instead of the constants: a per-request `request-options` value may still **tighten** a limit (a shorter timeout, fewer redirects, a smaller body cap) but a configured ceiling can only *lower* the bound, never let a caller exceed it — and `max_response_bytes` is additionally hard-clamped to the shared absolute `MAX_GUEST_PAYLOAD_LEN` host payload limit, so config can lower but never raise it above the host hard cap. **Operator config only:** like `[security.capsule_local_egress]`, a project/workspace config layer cannot set or widen `[http]` (`merge::restrict` reverts the whole section to the operator baseline as a unit; regression-tested), so untrusted project config can never relax the host's HTTP limits. New unit + config-merge + host tests cover the defaults reproducing the constants, partial-section parsing, the operator-only merge guard, `config show` surfacing the section, and that a non-default config actually changes behaviour (`max_concurrent_streams = 2` rejects the 3rd stream with `Quota`, `default_timeout_secs = 5` drives the resolved default total, `max_redirects = 3` clamps a larger caller value). No WIT / host-ABI change — kernel-internal threading + config only. ### Changed - **Gateway-to-kernel requests now survive legitimately slow kernel operations instead of failing them with a spurious timeout, and a genuine timeout now returns `504 Gateway Timeout` rather than a generic `500`.** The IPC response wait was a blanket 15-second *total* deadline: a heavy operation such as installing a capsule (unpack, load, then run the capsule's install lifecycle hook, which instantiates and executes WASM) could legitimately exceed it under load, and when it did the gateway mapped the transport timeout to an opaque `500` — the source of intermittent "capsule install expected HTTP 200, got 500" flakiness. The kernel now emits a lightweight `Working` keepalive on a request's response channel every 5 seconds while a slow handler is still in flight (a fast operation finishes before the first keepalive and emits none, so no extra bus traffic on the common path), and every request-family uplink treats its timeout as an **inactivity** window — max silence between frames — that each keepalive resets, so the total wait is bounded only by how long the kernel keeps signalling liveness. An absolute 10-minute ceiling (checked against each read, so it cannot be overshot by a full inactivity window) still bounds a wedged-but-still-signalling kernel, and runaway guest execution remains bounded independently by the fuel ledger, the epoch-interrupt deadline, and the memory ledgers — the IPC timeout was never that guard. The kernel-request client paths now return a typed `KernelClientError` (preserving the underlying transport error as a `source` rather than a flattened string): a `Timeout` maps to `504` (retryable, request may still be completing) while connection loss, bus shutdown, and decode failures stay `500` and a real install/authorization failure stays `403`, all unchanged. The keepalive is a liveness signal only: it is never a terminal response and is swallowed by the uplink, so it never reaches an HTTP client. Closes #1092. - **Marked the public field-bearing structs and enums touched by the shared-by-hash capsule work `#[non_exhaustive]` so future additive changes (a new field or variant) are non-breaking.** Covers `astrid-capsule`'s `HostState`, `NetStream`, `TcpStreamSlot`, `InterceptorHandle`, `PrincipalMount`, `ConnectionIdentity`, `Unregistered`, and `CapsuleRegistry`, plus `astrid-storage`'s `SecretStoreError`, `ScopedKvStore`, `KvSecretStore`, `FileSecretStore`, and (under the `keychain` feature) `KeychainSecretStore` / `FallbackSecretStore`. To keep `HostState` constructible from `astrid-hooks` after the marker (a cross-crate struct literal no longer compiles), added a `HostState::for_hook(HookHostStateParams)` constructor in `astrid-capsule` that fills every fail-closed hook default and takes only the pieces a hook varies; the hook handler now builds through it instead of naming ~50 fields. `LifecyclePhase` is deliberately left exhaustive: its unit variants are constructed by name in other crates (`astrid-capsule-install`, integration tests), and `#[non_exhaustive]` would forbid that cross-crate construction with no in-scope fix. Relates to #1069. - **Restored `HostState::principal_kv_namespace()` as `#[deprecated]` rather than removed, with a corrected body.** The method is superseded by `effective_kv()` and returns only a namespace string, so it cannot express the fail-closed/neutral fallback a shared runtime uses for principal-less contexts. Its body now anchors the INVOKING principal (`effective_principal()`, falling back to the load owner only when no caller is in scope) instead of the raw load owner, so a stray caller gets the current principal's namespace rather than the misleading owner-only value the shared-runtime model made incorrect. Deprecating (not deleting) keeps the public API backward-compatible. Relates to #1069. - **Device key identifiers now have typed core wrappers while preserving string-shaped public API and wire formats.** Added generic `DeviceKeyId` and `DevicePubkey` views for paired-device handles and canonical ed25519 public-key hex, then routed profile validation, lookup, pair-device redeem/revoke, and socket handshake verification through those typed paths. This removes duplicated normalization and makes key-id/pubkey mixups harder without changing serialized profile or management API structs. Closes #1047. - **CI clippy checks updated to run with `--all-targets` and all pre-existing clippy lints resolved across test, example, and benchmark targets.** Refactored test-only code to address dead code, field reassignments with default, misplaced imports, and excessively long functions workspace-wide. Closes #1004. - **Added support for Copilot automated agents in CI checks.** Added `Copilot` to `.github/contributors.yml` under `maintainers` and bypassed the contributor account age check in `.github/workflows/pr-checks.yml` for automated agent PRs to allow successful workflow runs. ### Docs - Added `docs/models.md` -- user guide for LLM model selection (`astrid models list/current/set/unset`), provider discovery (openai and openai-compat capsules), install-time onboarding, and the no-model error path. Relates to #961. ### Security - **Closed the cross-principal host-state bleed a shared capsule runtime opened, replacing the load-time owner fallback with a neutral fail-closed placeholder (corrects the earlier #1069/#1083 restoration).** A content-addressed runtime is shared across every principal viewing the same WASM hash and is loaded under `PrincipalId::default()`. `default` is an **ordinary** principal that merely holds admin, so the previous `effective_kv`/`effective_secret_store`/`effective_home`/`effective_tmp`/`effective_capsule_log` **owner fallback** meant a principal-less, owner, or overlay-construction-failed invocation resolved to `default`'s **real** KV namespace / secrets / home — an "anything can read `default`" bleed. Two live paths were exploitable: (1) the guest-pulled `ipc::recv` path never installed `invocation_secret_store`, so a non-`default` publisher's `has_secret` / secret reads fell through to `default`'s secrets; (2) both the interceptor and recv paths skipped installing an overlay when the caller equalled the load-owner, and the degrade path (overlay construction failing) left the overlay `None` and fell back to the owner. **Fixes:** the shared runtime's load-time `kv`/`secret_store`/`home`/`tmp`/`capsule_log` are now NEUTRAL placeholders holding no real principal's data — `kv` is a fresh, physically-isolated in-memory store on a reserved namespace (`neutral.fail-closed:capsule`, unproducible by any real principal since principal ids are `[a-zA-Z0-9_-]`), `secret_store` is a new deny-all `DenySecretStore` (`exists`→false, `get`→None, `set`/`delete`→error), and `home`/`tmp`/`capsule_log` are `None`. A new separate `kv_backend` handle carries the real shared backend used **only** to build per-invocation overlays. EVERY invocation carrying a present, parseable principal — the owner/`default` **included** — now installs its own `{principal}:capsule:{id}` overlay via one shared `install_principal_overlays*` helper (used by both the interceptor and recv paths so they cannot drift); a caller with no parseable principal (system/lifecycle events: `watchdog.tick`, `capsules_loaded`) installs no overlay and resolves to the neutral placeholder. The degrade path (overlay construction failure for principal B) now fails **closed** to the neutral placeholder, never the owner. The registry additionally REJECTS registering a hash already loaded under a different owner (`register_for` now errors, forcing cross-principal shares through `register_existing`), so no code path can create a shared instance owned by a real non-default principal whose fields would become a fallback. New tests pin: recv-path secret isolation (a non-`default` publisher resolves its OWN secret store, not `default`'s), no-cross-principal fallback / degrade to neutral (not `default`, not B-reads-another), every caller (owner included) gets its own explicit overlay, overlay writes land on the real backend while the neutral store stays empty, and the deny-all secret store. No WIT / host-ABI change — kernel-internal host-state + storage placeholder + registry guard only. Corrects #1083; closes #1069. Relates to #977. - **A shared failed capsule runtime is now restarted exactly once and rebuilt for every principal view, instead of firing N redundant restarts and leaving non-requesting principals with a dead view.** The health monitor iterates `cloned_values_with_principal()`, which yields one `(principal, Arc)` pair PER VIEW — so a failed shared runtime (issue #1069) with N views produced N failure entries and N restart attempts, and the restart tracker keyed by `principal/capsule_id` tracked N independent restart budgets for one instance. Failures are now deduplicated by capsule id (one restart per shared runtime) and the restart tracker keys by capsule id alone. Separately, `restart_capsule` for a non-last view previously released only the requesting principal's view, left the still-failed runtime alive, and (because the hash was still loaded) merely re-attached that view via `register_existing` — a no-op that left a dead runtime. It now captures all viewing principals, tears the shared runtime down completely, then rebuilds it and re-attaches every view, so no principal is left pointing at a dead runtime. Kernel-internal only. Relates to #1069. - Updated the locked `ammonia` transitive dependency to `4.1.3`, clearing `RUSTSEC-2026-0193` / `GHSA-9jh8-v38h-cvhr` from the RustSec audit. - **Hardened the public supply-chain evidence path for repository trust scanners.** CI now commits the workspace `Cargo.lock`, runs dependency automation through Dependabot, pins GitHub Actions to immutable SHAs, declares least-privilege workflow token permissions, adds CodeQL scanning and public API compatibility checks, verifies the lockfile before RustSec audit, reviews dependency changes on PRs, and signs plus attests release tarballs/checksums with keyless Sigstore/GitHub provenance bundles. - **Sensitive per-action capsule host calls (filesystem read/write/delete, outbound network connect/bind, process spawn) now land on the kernel's durable, signed, hash-chained audit log — allowed, failed, OR denied.** Previously only kernel admin-API requests reached the chain; the fs/net/process host functions emitted off-by-default `tracing` observability lines (debug level) that never persisted, and a security-gate *denial* left no trace at all (the gate early-returned before any audit envelope). A new synchronous `HostAuditSink` seam closes this: the WASM host engine — which has no `astrid-audit` dependency and no custody of the runtime signing key — reports a neutral, primitives-only event (`FileRead`/`FileWrite`/`FileDelete`/`NetConnect`/`NetBind`/`ProcessSpawn` × `Allowed`/`Failed`/`Denied`) to the sink, stamped with the host-resolved `effective_principal` (never guest-supplied data); the kernel implements the sink (it holds both the log and the key), maps the event onto a signed `AuditAction`, and appends it synchronously on the host fn's existing blocking thread. The append is NOT routed over the broadcast event bus (a lag-droppable record is not provable) and a persistence failure is logged and swallowed — audit degrades to "continue + alert", never panicking or blocking the host call (mirroring the admin-audit path). New `AuditAction::{NetConnect, NetBind, ProcessSpawn}` variants (kernel-internal, not WIT). Denials are recorded exactly once: the gate records the `Denied` event before its early return, so the success-path envelope is never also reached. Producer-side (capsule) and consumer-side (kernel) tests pin the principal stamping, event/outcome mapping, the `Denied`→`Failure`+`Denied`-proof shape, and that the signed chain still verifies after high-frequency appends. Follow-ups (documented, not silent gaps): `FileWrite` content-hash fidelity at this seam (a zero placeholder is recorded today), KV host-op gating+audit, reserved-namespace hardening of the live audit bus topic, and the chain-as-spine + content-addressed value-store sink optimization for high-volume workloads. Review hardening: (1) the per-chain append is now atomic — `AuditLog::append_inner` reads the chain head, signs the entry against it, persists, and advances the head all under the `chain_heads` write lock, so two concurrent same-`(session, principal)` appends can no longer both read the same parent hash and fork the signed chain (which made `verify_chain` report `valid = false` under ordinary concurrent host-call load); signing under the lock serializes same-chain appends, which is correct because the hash chain's order is the product. (2) Guest-controlled strings (`path` / `host` / `addr` / `command`) are truncated to 1024 bytes (UTF-8 char-boundary safe) at the kernel sink before signing and persisting, closing a disk/CPU amplification path where a zero-capability capsule drives unbounded audit growth by passing multi-megabyte arguments to gate-denied host calls. (3) The fs/net/process error arms that previously returned via `?` before any audit now record a `Failed`-outcome entry: a failed sandbox build or `spawn`/`spawn-background` exec, a resource-table push failure *after* a child has already forked (`spawn-background`), and a resource-table push failure *after* a TCP connect already succeeded (`connect-tcp`) — so a fork or a fork-then-abort never leaves the chain with no trace. Regression tests cover concurrent same-chain append integrity, the sink-boundary truncation, and a gate denial driven through the public `connect-tcp` host fn. Refs #994. - **Made capsule-session's per-principal KV isolation an explicit, tested invariant (closes #977).** `capsule-session` keys its store as the principal-less `session.data.{id}`, so cross-principal isolation comes entirely from the host KV namespace (`{principal}:capsule:{capsule_id}`), not from the capsule's keys — correct today, but previously implicit. `HostState::effective_kv` now documents this contract and the exact boundary of its owner-store fallback (correct only when no caller is in scope or the caller is the owner), and new regression tests pin it: two principals writing the identical `session.data.default` key resolve to different namespaces and never collide, the accessor prefers the per-invocation store and falls back only as specified, and the absent-principal in-scope case falls back to the owner store (the documented edge that holds safe because every producer of a principal-scoped topic stamps an authenticated principal). The `debug_assert_invocation_field_set` doc now records why a host-wide fail-closed assert on the absent-principal case is intentionally NOT added: principal-less system/lifecycle handlers (capsule-react's `astrid.v1.watchdog.tick`, capsule-registry's `astrid.v1.capsules_loaded`) legitimately fall back to the owner/global store, so such an assert would fire on sound paths. No WIT / host-ABI change — docs + tests only. Closes #977. - **A capsule can now reach a loopback/private LLM endpoint (LM Studio on `127.0.0.1:1234`, Ollama, a LAN IP) through operator consent instead of a hand-edited `config.toml`, without weakening the SSRF airlock (closes #1028).** Two composed mechanisms ship together. **(A) A host-stamped, capsule-unforgeable transport-origin marker plus runtime elicitation.** A new host-only `origin: MessageOrigin { System | LocalSocket | RemoteGateway }` field on the kernel-internal `IpcMessage` records where a request entered the system — stamped exactly like `device_key_id` (host-derived internal-bus metadata, **no WIT change**; the guest-facing `record ipc-message` is unchanged and `to_wit_message()` keeps dropping it, so it stays invisible to capsules). The kernel stamps `LocalSocket` only for a handshake-bound Unix-socket connection (an unbound/unauthenticated local connection stays `System` — fail-closed, non-local — parallel to the `anonymous` principal), `RemoteGateway` at the gateway bus-publish sites, and propagates the *originating* request's origin through guest publish/fan-out and `publish-as` — so a `RemoteGateway` request flowing through a fan-out capsule (react → openai-compat) **cannot be elevated to `LocalSocket`**. Absent/unknown/legacy/CLI-proxy-wire frames decode to `System` via `#[serde(default)]` + `#[serde(other)]`. At the airlock-rejected egress arm for an IP-literal local endpoint, `astrid:http` now consults transport origin: a `LocalSocket` request that is not already pre-blessed/granted elicits one-shot operator consent via the existing `request_approval` primitive (`ApprovalRequired` on `astrid.v1.approval`, resource = `host:port` only); on approve/approve_session it caches a **per-principal, per-capsule** session grant (`AllowanceStore`; the grant carries the requesting `capsule_id`, so neither principal A's grant exempts principal B nor capsule A's grant exempts capsule B reaching the same `host:port`), and on approve_always it also persists to that principal's capsule-keyed `profile.network.capsule_egress[]` map on disk (a flat per-principal list would have let a remembered grant for one capsule silently exempt every other capsule that principal runs). Because the in-memory `AllowanceStore` starts empty on every daemon boot, the egress fast path **also consults that persisted `capsule_egress` map for the effective principal and current capsule** before eliciting — so an `approve_always` grant actually survives a restart (the "remember across restarts" contract) instead of being silently forgotten and re-prompted. That persisted consult preserves both isolation axes (it reads only this principal's profile and only this capsule's bucket, so a remembered grant for capsule A / principal X never permits capsule B / principal Y) and is reached only after the upstream origin gate and SSRF airlock, so it short-circuits the elicitation, never the origin/airlock checks. Any non-`LocalSocket` origin (`System`/`RemoteGateway`/unbound) **never elicits and fails closed**, so a remote gateway caller driving the same react/openai-compat egress path can neither see nor answer a local-egress prompt. A consent-granted endpoint re-enters the exempt path, refusing redirects identically to a pre-blessed one. The consent round-trip reuses the shared `astrid.v1.approval[.response.*]` channel rather than a distinct egress namespace — a documented, low-severity residual (only the trusted operator uplink and MCP broker hold publish rights on that response topic; an arbitrary untrusted capsule cannot), deferred because a distinct namespace would require an out-of-repo operator-surface publish-ACL change in lockstep. V1 covers IP-literal local endpoints (`127.0.0.1`, `[::1]`, `192.168.x`, `10.x`, `169.254.x`); hostname endpoints (`localhost:1234`) still require a pre-bless. **(B) A CLI guided pre-bless.** `astrid init` / `astrid capsule config` / capsule install now detect when an entered provider `base_url` points at a loopback/private/link-local address and prompt the operator (`[y/N]`) to add the `[security.capsule_local_egress]` exemption keyed by capsule id — the operator is unambiguously local *by construction* in the CLI process, so this is guided pre-bless, not runtime elicitation. Free-text/non-resolving hosts are skipped. No WIT / host-ABI change — the marker is host-only, threaded through kernel-internal IPC, gateway routes, and CLI config. New unit + regression tests cover the origin serde floor, bound/unbound socket stamping, fan-out origin propagation (no elevation), pool-reset clearing, the per-principal/per-capsule consent fast path / elicit / approve-always disk persist, per-principal AND per-capsule grant isolation (a `react` grant does not exempt `openai-compat` reaching the same endpoint — in-memory session grant AND persisted `capsule_egress`), the persisted-grant fast path surviving an empty store on a fresh daemon while still eliciting for the wrong capsule or wrong principal, the fail-closed non-local paths, `is_local_address` detection, and the operator-config write. **Hardening:** the global SSRF escape hatch is no longer a bypass of the new consent gate. `ASTRID_TEST_ALLOW_LOCAL_IP` is now **gated behind `cfg(test)`** — the daemon's release binary never even reads it, so a stray test env var in production can no longer disable the airlock (and with it the consent gate) for every capsule. `ASTRID_ALLOW_LOCAL_IPS` stays recognized in production for operators/CI but is now **deprecated**, emitting a one-time warning that is honest about its blast radius (it disables the airlock for ALL capsules AND exposes loopback/private endpoints to REMOTE `RemoteGateway` API callers, bypassing the per-capsule consent gate) and directing operators to the per-capsule `[security.capsule_local_egress]` allowlist; it will be removed in a future release. A new end-to-end regression test locks the anti-forge propagation invariant: a `RemoteGateway`-stamped request driven through the real fan-out hop chain (caller_context origin → `publish` → `publish_inner` → republished bus message → downstream caller_context) is observed AS `RemoteGateway` at the egress site and `consent_local_egress` DECLINES — it can never be reset to `System`/`LocalSocket` across a republish. **Revocation caveat (documented, no behaviour change):** neither exemption source is revoked live — the operator `[security.capsule_local_egress]` allowlist is a load-time snapshot and a persisted `approve_always` grant lives in a process-lifetime profile cache with no TTL/watcher — so removing an entry or grant only takes effect after a **daemon restart**. This caveat is now stated at the consent gate (module doc) and printed by the CLI add-path so an operator editing config to revoke is not falsely reassured (a live-revocation verb is tracked separately). Closes #1028. Relates to #961, #1026; references the airlock work #965. - **The host `elicit()` waiter now enforces that an answer comes from the same principal the input is being collected for, closing a cross-principal elicit hijack.** A capsule's lifecycle `elicit()` mints a `request_id` and the prompt SSE stream forwards it verbatim to every connected client, so any authenticated caller who observed an in-flight `request_id` could publish an `ElicitResponse` on the derived reply topic and answer — or cancel — another principal's elicit (now also reachable from the new `POST /api/agent/elicit-response` endpoint). The waiter previously keyed only on the reply-topic suffix and the payload variant, never on who sent the reply. It now stamps the originating principal on the outgoing `ElicitRequest` and only unblocks on a reply carrying a matching principal; a mismatched (or unstamped) reply is logged and rejected, and the wait continues on its original deadline so a flood of forged replies cannot extend or DoS the legitimate one. The in-process CLI install answerer now echoes the request's principal back on its reply so interactive `astrid capsule install` onboarding keeps working; socket-driven answerers (TUI, chat) already carry the host-verified principal via the uplink's `publish-as` self-stamp. - **The `astrid:http` SSRF airlock now covers IP-literal request URLs and redirect hops, and surfaces the typed `airlock-rejected` error instead of a generic connection failure.** The host's `SafeDnsResolver` is consulted by reqwest *only* for hostnames, so a request whose authority is already an IP literal (`http://127.0.0.1:1234`, `http://[::1]`, `http://192.168.x`) connected straight through with the airlock never running — a capsule holding `net = ["*"]` (e.g. `openai-compat`) could reach loopback/private/metadata addresses over HTTP. The same gap let a public, allow-listed host bounce a capsule onto an internal service via a `302 Location:` pointing at an IP literal (a redirect-SSRF that returned the internal response body). Both `http_request` and `http_stream_start` now (a) run a **pre-flight airlock** on any IP-literal request URL before issuing it, and (b) install an **airlock redirect policy** that re-checks every hop, refusing IP-literal targets that fail the airlock and capping the hop count; hostname targets remain airlocked at DNS-resolution time. When the resolver or the redirect policy blocks a request, the error is now mapped to the existing `astrid:http@1.0.0` `airlock-rejected` variant (previously a blocked hostname surfaced as an opaque `connection-error`, indistinguishable from "server down"), so a capsule can tell an SSRF rejection from a real connection failure. Separately, `is_safe_ip` now decodes IPv6 transition/translation forms that embed an IPv4 address — the NAT64 well-known prefix (`64:ff9b::/96`), 6to4 (`2002::/16`), and Teredo (`2001:0::/32`, server + obfuscated client) — and rejects any embedding a private/loopback/metadata IPv4 (e.g. `64:ff9b::a9fe:a9fe` → 169.254.169.254), plus deprecated site-local (`fec0::/10`); transition addresses embedding a public IPv4 are unaffected. The `net.connect-tcp` path already airlocked resolved literals and is unchanged. The process-wide `ASTRID_ALLOW_LOCAL_IPS` / `ASTRID_TEST_ALLOW_LOCAL_IP` escape hatch still disables the airlock for all capsules (unchanged); a sanctioned per-capsule local-endpoint allowance remains open work on #965. Eleven new unit tests pin the literal pre-flight, the redirect classification, the resolver trip flag, and the IPv6 embedding cases. No WIT / host-ABI change — the `airlock-rejected` variant already existed. Closes #1020. Refs #965 (the local-LLM allowance follow-up). - **Operators can now sanction a specific capsule reaching a loopback/private LLM endpoint via a per-capsule local-egress allowlist, instead of the all-or-nothing global SSRF bypass (closes #965).** A new operator config map `[security.capsule_local_egress]` keys a capsule id to a list of `host:port` / `host:*` endpoints that are exempt from the `astrid:http` SSRF airlock **for that capsule only** — the sanctioned way to point e.g. `openai-compat` at LM Studio on `127.0.0.1:1234` or Ollama on a LAN address. Previously the only escape hatch was `ASTRID_ALLOW_LOCAL_IPS`, which disables SSRF protection for *every* loaded capsule. The allowlist is resolved once by the daemon from config, stored on the kernel, and each capsule is handed its own slice at load (snapshotted onto every pooled `HostState`, like `capability_names`). The exemption is decided at **pre-flight**, where the request's host *and* port are known, and the matched hostname is propagated to `SafeDnsResolver` as a scoped `exempt_host` so a hostname endpoint (`localhost:1234`) resolves through to its loopback address while staying **port-specific** (`localhost:9999` is still airlocked) and **host-scoped** (redirect hops to other hostnames are still airlocked). This is **operator config**: a capsule's own (untrusted) `Capsule.toml` cannot set it (mirrors the `EnvScope` untrusted-manifest invariant), and a project/workspace config layer cannot introduce or widen it (`merge::restrict` `block_workspace_override`, with regression tests). It only *exempts* endpoints from the airlock; it never grants network access the capsule's manifest `net` allowlist doesn't already declare, and an empty/absent entry is fully fail-closed. Scope is the HTTP egress surface (`http_request` + `http_stream_start`); the `net.connect-tcp` path is unchanged and a parallel allowance there is a follow-up. Reuses the manifest `net_connect` `host:port` matcher so semantics are identical. New unit + config-merge regression tests cover host:port matching, port-specificity, the operator-only merge guard, and the resolver exempt-host behaviour. No WIT / host-ABI change — kernel-internal threading + config only. Closes #965. ### Added - **The HTTP gateway can now expose a principal's conversation threads over two authenticated routes: `GET /api/agent/sessions` (paginated thread list with metadata) and `GET /api/agent/sessions/{id}/messages` (one thread's full transcript).** Previously the only agent-facing route was `POST /api/agent/prompt` (the SSE invocation stream); a dashboard or automation client had no way to enumerate a principal's past threads or read a transcript back. Both routes are a **thin proxy to the `capsule-session` capsule over the in-process event bus**, mirroring the request/reply-over-bus shape `bus_admin.rs` uses for kernel admin ops but targeting capsule topics: the gateway generates a fresh `correlation_id`, subscribes FIRST to a **per-correlation scoped** response topic (`session.v1.response.{verb}.` — no request-id filtering needed, the scoped topic isolates the reply, with a defensive `correlation_id` re-check on the body), publishes the principal-stamped request (`session.v1.request.list` / the existing `session.v1.request.get_messages` verb), and awaits one reply with a 15s timeout. **The principal stamp is the only authority:** the caller's verified `CallerContext.principal` (plus `device_key_id` when the bearer is device-scoped) is stamped on every outbound request, the kernel scopes `capsule-session`'s KV reads to that principal's namespace, and the path `{id}` / query params are payload data only — they never substitute for the principal and never reach a topic segment, so a malicious `id` can neither cross into another principal's threads nor hijack another request's reply. `list` takes `limit` (default 50, max 200; over-cap → 400), an opaque `cursor`, and `include_archived` (default false) and returns `{ sessions, next_cursor, total }` (`total` = the principal's thread count when cheaply known, else `null`); transcript validates `id` (non-empty, ≤256 chars, no control chars → 400) and returns `{ session_id, messages }` with `messages` passed through as opaque JSON. A non-existent thread returns an **empty** `messages` list (not a 404 — the capsule cannot distinguish "never existed" from "empty", so empty avoids leaking thread existence). A closed bus → 500; a capsule that doesn't reply in time → 502 (timeout, distinguishable from a gateway fault). To support a mixed 1.0/1.1 fleet, `list` first probes the loaded capsule set — via an in-process, capability-free `CapsuleTopicProbe` (a live-registry read, deliberately **not** the capability-gated `GetCapsuleMetadata`, which would 403 ordinary callers and leak the capsule inventory) — for a handler of `session.v1.request.list` and returns an immediate **501 Not Implemented** when no session capsule implements the 1.1 `list` verb — rather than hanging to the bus timeout on a verb nobody handles; the transcript route needs no gate (its `get_messages` verb exists in session 1.0). Both routes are registered in the OpenAPI doc and each increments a count-only metric (no principal/session id in any label). The gateway adds no WIT or IPC-contract change itself (transport only); it consumes the new `session.v1.request.list` verb defined by the `astrid-bus:session` 1.1 contract, shipped separately in the wit + capsule-session changes. Closes #972. - **The HTTP gateway now exposes full conversation-thread management plus a per-principal live conversation feed, completing the dashboard/multi-device surface over `capsule-session`.** Four new thread routes — `GET /api/agent/sessions/{id}` (one thread's metadata; **404** when the thread is not in the caller's namespace), `PATCH /api/agent/sessions/{id}` (update `title`/`archived`/`meta`), `DELETE /api/agent/sessions/{id}` (`{ deleted: bool }`), and `GET /api/agent/sessions/search?q=&limit=&cursor=&include_archived=` (full-text search, `q` required → 400 on empty, `limit` default 20/max 100) — proxy to the `capsule-session` `get_meta`/`update`/`delete`/`search` 1.1 verbs over the same subscribe-first, per-correlation-scoped, principal-stamped request/reply-over-bus primitive the list/transcript routes use. **PATCH is by-presence:** the gateway reads the body as a raw JSON object and forwards **only the keys the client actually sent** (a minimal patch of `correlation_id` + `session_id` + whichever of `title`/`archived`/`meta` were present), so an absent key leaves the field unchanged, a present key sets it, and `""` clears it; unknown body keys are dropped and `session_id` is always the gateway's path value (never the body), so a client cannot smuggle another field into the capsule's update. `update`/`delete`/`search` are gated on a 1.1 session capsule (`ensure_list_supported` → **501** when none is loaded); `delete` deliberately does **not** 404 on `deleted:false` (the principal-scoped delete is idempotent). The `SessionSummary` schema gains the frozen `title`/`last_message_preview`/`archived`/`meta` fields. **`GET /api/agent/stream` (#973) is the new per-principal live SSE feed** — the multi-device sync channel: it opens **two** routed subscriptions, both scoped to `Some(Some(caller.principal))` — `agent.v1.*` (in-flight turn events) and `session.v1.event.*` (thread lifecycle created/updated/deleted) — and merges them into one stream (`event: agent` / `event: session_event`, each wrapping `{ topic, data }` so a client can disambiguate sub-kinds), emitting an initial `ready` event with a 15s keep-alive and a 1-hour connection cap (clients reconnect). **Cross-principal isolation is the load-bearing #973 property and is enforced structurally at the bus:** the inner `Some(principal)` scope drops any foreign-principal event at enqueue, so a second device only ever sees its own principal's conversation — a co-resident (buggy or hostile) publisher on those topics cannot leak another principal's threads onto this feed. The feed is **not** session-scoped (it fans all of the principal's threads) and is **not** 501-gated (it gracefully carries whatever is published; `agent.v1.*` exists in session 1.0). All new routes are registered in the OpenAPI doc, principal-stamp every outbound request (the stamp is the only authority — `{id}`/query/body never substitute for it), and each increments a count-only metric (`astrid_gateway_agent_sessions_{get_meta,update,delete,search}_total`, `astrid_gateway_agent_stream_total`; no principal/session id in any label). Transport only — no WIT change, no IPC-contract change, no RFC; consumes the `astrid-bus:session` 1.1 verbs and the `session.v1.event.*` lifecycle events shipped separately. Closes #973, refs #972. - **Headless and offline distro seeding: `astrid init` gains a non-interactive `--yes` path and a signed, self-contained `.shuttle` bundle, so a distro (including an LLM provider) can be seeded in a Docker build or an air-gapped environment with no stdin prompts and no GitHub access at build time.** Previously `init` prompted on stdin (`select_capsules`/`collect_variables`) and fetched `Distro.toml` plus every capsule from GitHub, so it could not run in a container build. `--yes` takes group defaults (a new `default = true` marker on a distro capsule) and resolves variables from `--var KEY=VALUE` > `ASTRID_VAR_` > the manifest default (hard error if a required one is unset; secrets never logged), with `--offline`/`--allow-unsigned`/`--accept-new-key` alongside. `astrid distro seal --key ` produces a deterministic `.shuttle` (gzipped tar of `Distro.toml` + resolved `Distro.lock` + `Distro.sig` + pre-built `capsules/.capsule`); `astrid init ./x.shuttle` unpacks it (traversal/symlink/size-hardened), verifies the ed25519 signature against a per-distro TOFU-pinned key (`~/.astrid/trust/.pub`, with a compiled-in `OFFICIAL_KEYS` pre-pin and a hard fail on an unexpected key change), enforces the `manifest_hash` binding that ties `Distro.toml` to the signed lock (mandatory when signed), checks every capsule's BLAKE3 against the lock, and installs entirely offline. Capsule version pinning is now real: `version`/`tag` resolve to a concrete GitHub release tag (replacing the prior unconditional `releases/latest` that ignored the pin and silently built `HEAD`), the lock records the *actually-resolved* ref, and a pinned-but-unresolvable version is a hard error rather than a HEAD build; distros are release-only (`branch`/`rev` are rejected in a manifest — git-ref builds remain available via `astrid capsule install`). Adds `astrid keypair pubkey --format wire` (emits `ed25519:` for `[distro.signing]`) and a `docs/distro-signing.md` runbook. Closes #964. - **`astrid init` now multi-selects LLM providers and, for each, collects credentials and a default model picked from a *live* list — instead of typing a model id blind into one free-text field.** Onboarding previously framed the model as a single free-text shared `[variables]` entry, so the operator had to already know a valid model id for their provider and type it exactly. Two additions close that: (1) a capsule `[env]` field may now declare `options-from = { http, bearer, select, after }`, marking it a *dynamic* SELECT whose option list is fetched live at prompt time — the native installer (which has network; the sandboxed capsule does not) substitutes already-entered values into the `http`/`bearer` templates, performs `GET ` (with `Authorization: Bearer ` attached **only** when the token is non-empty after trim **and** the resolved fetch URL has the same scheme, host, and port as the user-configured provider `base_url` the template is built from — so a capsule cannot point `http` at an attacker host (nor a cleartext `http://` downgrade of an `https://` base_url, even on the same explicit port 443) and exfiltrate the user's key; on a mismatch the bearer is withheld and a warning logged, and the unauthenticated request typically 401s into the free-text fallback), streams the response body under a hard 5 MB cap that aborts the transfer the moment the running total crosses the limit (generous for large aggregator `/v1/models` catalogs, still bounds the OOM vector even for a chunked / unknown-`Content-Length` response that a header-only check could not; an over-limit body errors into the same fallback), and parses the standard OpenAI `data[].id` shape into a deduped, server-ordered list; the `after` keys are collected first so the URL/credential placeholders resolve. Any discovery failure (network error, non-2xx, non-JSON, empty data) degrades gracefully to a free-text prompt with a hint — a discovery miss never aborts the install. (2) During distro install the `group = "llm"` capsules are presented as a provider multi-select ("Which LLM provider(s) do you want to set up?"), and each selected provider then runs its own `[env]` schema prompt after install so capsule-specific fields (`api_key`, `base_url`) and the dynamic `model` select resolve from the installed manifest; shared `[variables]` still template genuinely-cross-capsule values, and the prompt skips already-set keys. The end state is that every selected provider is installed and configured (creds + chosen default model), so the model registry spans them all. Manifest-schema + installer code only — no WIT change, no RFC. The discovery HTTP call reuses the CLI's existing `reqwest` dependency. Closes #1015. - **New `POST /api/agent/elicit-response` gateway endpoint lets an HTTP client answer an agent's follow-up input request.** The agent-prompt SSE stream already forwarded `astrid.v1.elicit` events (a capsule asking for follow-up input during, e.g., an agent-driven capsule install or onboarding), but there was no way for the HTTP client to send the answer back — so the capsule's host-side `elicit()` waiter blocked the full 120s timeout and the prompt SSE waited out its five-minute upper bound. The new endpoint publishes the answer onto the per-request reply topic the host waiter is subscribed to and returns `202 Accepted` immediately; the agent's continuation then streams back over the still-open prompt SSE connection. The body carries the `request_id` from the forwarded `elicit` event plus `value`/`values` (omit both to cancel, matching the host cancel sentinel). The reply is stamped with the caller's bearer-verified principal, which the host waiter checks against the principal the elicit was collected for. - **Agent-loop readiness introspection: a new `GET /api/sys/readiness` route, an `astrid doctor` check, and a boot warning surface whether the installed capsule set can actually serve an agent chat turn — and `POST /api/agent/prompt` now returns an immediate error instead of timing out when the loop is unconfigured.** Readiness is computed name-agnostically from the loaded capsule manifests (no capsule name is hardcoded): some capsule's `[subscribe]` must match `user.v1.prompt`, some capsule's `[publish]` must match `agent.v1.response`, and every required (non-optional) `[imports]` entry must be satisfied by another loaded capsule's `[exports]`. Previously a fresh daemon — which hard-requires only the socket uplink — booted clean yet silently dropped every prompt: the publish no-oped, the client got `ready` + keep-alives + an empty close after a five-minute timeout, with no way to know what was missing. The shared `astrid_capsule::readiness` primitive is the single source of truth (the boot validator's required-import check now delegates to it); a new `KernelRequest::GetAgentReadiness` exposes the detailed view (gated `capsule:list`) for the route and `astrid doctor`. The `POST /api/agent/prompt` fail-fast instead reads an in-process readiness probe the daemon wires from the live registry — agent-loop serviceability is global daemon health, not per-principal authorization — so the fast-fail fires for **every** authenticated prompt caller (single- and multi-tenant alike), with no capability check and no socket round-trip, while the inventory-revealing detail stays behind the gated route. The boot warning is computed from the loaded registry after load completes (not the pre-load discovered set), so a manifest that fails to load is not mistaken for a working capability. - **The HTTP gateway can now list and bind a principal's active LLM model over three thin, per-principal-scoped routes — closing the gap where the only model-selection surface was the TUI's interactive `/models` picker.** A script, remote dashboard, or automation client talking to the gateway had no route to discover the available models for a principal or bind one; the route table wired principals/caps/quotas/groups/invites/capsules/env/audit/agent/system but nothing for the model registry. New routes: `GET /api/models` (list the caller's available provider-entries), `GET /api/models/active` (the bound model, or JSON `null` when nothing is bound — `null` is a valid 200, not an error), and `PUT /api/models/active` with `{ "id": ":" }` (bind by id; returns the persisted active entry on success, 400 surfacing the registry's `unknown model: …` / ambiguity message **verbatim** on a bad id, and 400 on an empty `id` before the bus is even touched). The gateway is a **thin transport**: it publishes the existing registry IPC (`registry.v1.{get_providers,get_active_model,set_active_model}`) stamped with the caller's verified principal, awaits the reply on a **principal-scoped routed subscription** (`subscribe_topic_routed_scoped(.., Some(Some(principal)))`), and relays the registry's own success/error unchanged — it never resolves ids, enumerates models, or persists selection (that stays the registry capsule's job). **Per-principal isolation is the core security property and is enforced structurally:** the principal is the verified `CallerContext.principal` from the signed bearer (never a body/query field), the request is stamped with it (routing it into the caller's registry KV scope and making it the value the reply is matched against), and the reply is awaited on a route scoped to that principal so a foreign-principal reply is dropped at enqueue — a co-resident principal can neither read nor set another's active model. The three handlers and the `SetActiveModelRequest` schema are registered in the OpenAPI doc (`GET /api/openapi.json` lists `/api/models` and `/api/models/active`). A per-request model override on `POST /api/agent/prompt` is **deliberately excluded** (no capsule consumes a per-prompt `model` today; shipping it would forward a silently-dropped value). No WIT change, no IPC contract change, no RFC. Closes #1009. - **Capsule CLI verbs are now reachable at the root: `astrid [args…]` resolves the same daemon-backed capsule verb as `astrid capsule `, with a typo guard that preserves clap's "did you mean …?" hint for mistyped built-ins.** Previously the only way to invoke a capsule-contributed `kind = "cli"` verb was the namespaced `astrid capsule `; the root `Commands` enum had no external-subcommand catch-all, so an unrecognised first token was a clap parse error. A capsule verb (e.g. `identity-export`) can now be run directly as `astrid identity-export …`, the same shorthand `gh`/`git`/`kubectl` give their plugin verbs, while `astrid capsule ` stays the canonical, unshadowable form. The shorthand is a second entry point into the **unchanged** resolution path (`commands::capsule_verb::run_external`): same provider resolution, same `cli.v1.command.run.` dispatch, same per-principal RBAC scoping, same exit-code mapping — it adds no new authority. Built-in root verbs always win because clap matches every declared `Commands` variant before the `external_subcommand` catch-all, so a capsule can never shadow `status`, `mcp serve`, `version`, etc. Adding the catch-all intentionally disables clap's built-in "did you mean …?" engine for root tokens, so a new pure typo guard restores it: `commands::verb_suggest::nearest_builtin` (Levenshtein `≤ max(2, len/3)`, exact matches and non-near-misses excluded, ties broken alphabetically for deterministic output) flags an unrecognised token that is a near-miss of a built-in (`astrid statuss` → "did you mean 'status'?") and exits `2` **without booting the daemon** — so a typo storm or a tab-completion misfire cannot repeatedly auto-start the daemon, and a capsule cannot register a near-miss verb to harvest fat-fingered admin commands. Only a token that is not a near-miss of any built-in (`astrid identity-export`, `astrid models`) is forwarded to capsule resolution. The known residual tradeoff: a **future** core built-in added to `Commands` will begin shadowing a capsule's root shorthand of the same name — acceptable because `astrid capsule ` remains the stable, unshadowable fallback. No change to `RESERVED_CAPSULE_VERBS`, no WIT change, no IPC contract change, no RFC. Closes #1008. - **Pair-device tokens now carry a per-device capability scope, so a device added to an existing principal can hold a *subset* of the principal's capabilities instead of its full authority — and the scope is enforced on BOTH auth transports.** Previously `AuthConfig.public_keys` was a flat `Vec` of ed25519 keys, every paired device was capability-identical to the principal, and a guest/use-only device could not be expressed; the only revocation granularity was whole-principal `agent delete`/`disable`. Each entry is now a `DeviceKey { key_id, pubkey, scope, label, created_at }` carrying a `DeviceScope` of `Full` (no attenuation) or `Scoped { allow, deny }` (deny wins, same grammar as grants/revokes). Legacy bare `ed25519:` strings migrate transparently to `Full` (no behaviour change for existing principals). At pair-device issue the operator supplies the scope (`--scope use-only|full`, or explicit `--allow/--deny`, or a friendly HTTP `scope`/`allow`/`deny` body); the kernel validates `requested ⊆ issuer effective caps` against the issuer's *attenuated* effective set, a scoped child inherits the issuer's denies (monotonic narrowing), and minting an unattenuated `Full` device requires the issuer to itself be unattenuated AND hold the new `self:auth:pair:admin` capability. The authenticating device's `key_id` reaches the kernel cap-gate tamper-evidently on both paths — host-derived from the per-connection registry for the socket/CLI keypair handshake (it rides the existing `ingress`/`publish-as` enforcement, no WIT change), and bound inside a signed 5-segment bearer for the gateway HTTP path — where `authorize_request` resolves the device's `DeviceKey` from the principal's own profile and intersects `principal_caps ∩ device_caps`, failing closed if the `key_id` resolves to no registered device. So a `use-only` device can drive `POST /api/agent/prompt` but its `PairDeviceIssue` returns 403 even though the principal holds `self:auth:pair`, regardless of whether it connects over HTTP or the socket. New `pair-device list`/`revoke` admin ops (CLI `astrid pair-device list|revoke `, HTTP `GET`/`DELETE /api/sys/principals/{id}/devices[/{key_id}]`) make individual devices inspectable and evictable: revoke removes the `DeviceKey` so the handshake can no longer match it and the kernel gate fails closed, and the gateway evicts a live device-scoped bearer immediately via a per-`key_id` revocation watcher. Audit rows for issue/redeem/list/revoke carry the non-secret `key_id` and scope, never a raw key or token. No WIT change, no RFC, no capsule-cli change. Closes #947. - **The `astrid mcp serve` shim now elicits interactive consent on a grant-on-use signal and, on Grant, re-sends the original tool call — instead of returning a bare access error.** Consumer half of grant-on-first-use (#998/#1001): the kernel access-gate DROPS an ungranted `tool.call` and the broker replies a `grant_required` signal (`{ request_id, capsule_id, principal, tool_name, call_id }`) rather than a terminal result. The shim turns that into a binary **Grant / Deny** elicitation ("Grant this capsule to the identity?") — binary, not the four approve-verbs, because the kernel grant is always persistent (`profile.capsules`), so "allow once" is not expressible. This MIRRORS the ingress flow (gate → consent → re-send), NOT capability-approval (park → consent → resume): the original call was dropped, so on approve the shim re-sends it with a fresh `req_id` and the identical name+arguments (now passing the gate), and the re-sent reply still flows into the capability-approval gate so a tool that is both ungranted and capability-gated resolves in sequence. On approve the shim publishes `{ req_id, request_id, decision:"approve" }` on the new `astrid.v1.request.mcp.grant.respond` front door and reads `granted` from the `{ kind:"grant.respond" }` ack; the kernel persists the capsule on the principal. **Marker discipline (the divergence from ingress):** the broker holds a per-`(principal, capsule)` pending marker consumed on EVERY respond, so the shim ALWAYS responds — a `deny` is published on decline / cancel / no-elicitation-capability / elicit error too, or the marker would stick and a later call would get a benign "already pending" terminal instead of a fresh prompt. **Fail-secure:** any non-accept path publishes `deny` and returns a clean "Capsule access was not granted for this tool." MCP error; an accept the broker could not persist (ack `granted:false`) also returns the error rather than re-sending a call that would just trip the gate again; no panic/unwrap on the parse or elicit paths. `astrid.v1.request.mcp.grant.respond` is added to the non-retriable arm of the shim's connection-loss auto-retry allow-set — it mutates state (persists a capsule), so it must never be transparently re-issued. The grant target `(principal, capsule)` is never a respond body field — the kernel derives it from its own observed signal — so it is structurally un-spoofable from the shim. CLI/shim-internal, no WIT change; paired with the sage-mcp broker half. Refs #1002. - **The `astrid mcp serve` shim now elicits interactive consent on a grant-on-use signal and, on Grant, re-sends the original tool call — instead of returning a bare access error.** Consumer half of grant-on-first-use (#998/#1001): the kernel access-gate DROPS an ungranted `tool.call` and the broker replies a `grant_required` signal (`{ request_id, capsule_id, principal, tool_name, call_id }`) rather than a terminal result. The shim turns that into a binary **Grant / Deny** elicitation ("Grant this capsule to the identity?") — binary, not the four approve-verbs, because the kernel grant is always persistent (`profile.capsules`), so "allow once" is not expressible. This MIRRORS the ingress flow (gate → consent → re-send), NOT capability-approval (park → consent → resume): the original call was dropped, so on approve the shim re-sends it with a fresh `req_id` and the identical name+arguments (now passing the gate), and the re-sent reply still flows into the capability-approval gate so a tool that is both ungranted and capability-gated resolves in sequence. On approve the shim publishes `{ req_id, request_id, decision:"approve", capsule_id }` on the new `astrid.v1.request.mcp.grant.respond` front door (the `capsule_id` is echoed back so the broker can clear its per-`(principal, capsule)` dedup marker; it is display/routing only and never a grant target) and reads `granted` from the `{ kind:"grant.respond" }` ack; the kernel persists the capsule on the principal. A `grant_required` signal missing a non-empty `request_id` or `capsule_id`, or otherwise unparseable, is treated as malformed and surfaces a terminal MCP error rather than letting the dropped call masquerade as an empty success. **Marker discipline (the divergence from ingress):** the broker holds a per-`(principal, capsule)` pending marker consumed on EVERY respond, so the shim ALWAYS responds — a `deny` is published on decline / cancel / no-elicitation-capability / elicit error too, or the marker would stick and a later call would get a benign "already pending" terminal instead of a fresh prompt. **Fail-secure:** any non-accept path publishes `deny` and returns a clean "Capsule access was not granted for this tool." MCP error; an accept the broker could not persist (ack `granted:false`) also returns the error rather than re-sending a call that would just trip the gate again; no panic/unwrap on the parse or elicit paths. `astrid.v1.request.mcp.grant.respond` is added to the non-retriable arm of the shim's connection-loss auto-retry allow-set — it mutates state (persists a capsule), so it must never be transparently re-issued. The grant target `(principal, capsule)` is never a respond body field — the kernel derives it from its own observed signal — so it is structurally un-spoofable from the shim. CLI/shim-internal, no WIT change; paired with the sage-mcp broker half. Refs #1002. - **`astrid capsule build` now bakes each capsule's tool descriptors into the installed `meta.json`, and `astrid capsule show` lists them — the tool surface is a static, offline-inspectable artifact instead of a runtime-only describe fan-out.** Previously the only way to learn a capsule's tools (names, descriptions, input JSON schemas) was to load it into a running daemon and collect the `tool.v1.request.describe` fan-out at runtime; an installed-but-not-running capsule was opaque, and the fan-out was the single source of truth. The build command now, after the lean `astrid-build` companion compiles the component and packs the `.capsule`, instantiates the freshly-built component exactly as the kernel would at load time (a new `astrid_capsule::describe_capsule_tools` harness: same loader path, an in-memory KV store, a fresh event bus, a no-op MCP client — no socket, no daemon, no identity/profile wiring), drives its `#[astrid::tool]`-generated `tool_describe` interceptor, and writes the captured descriptors to `tools.json` inside the archive. At install, `tools.json` is read into a new `CapsuleMeta.tools` field (absent ⇒ empty, never a failure — install itself never instantiates WASM and stays daemon-free). `astrid capsule show` then lists each tool's name + first-line description and the count (`Tools (8)`), or `Tools (none captured at build)`. The capture preserves the WASM engine's fail-secure integrity check: it stages a transient `meta.json` carrying the BLAKE3 hash of the very bytes being loaded (the check is satisfied honestly — the wasm matches what its meta claims — not bypassed), and removes it afterward. A capsule with no tools, a non-Rust/MCP-import build, or a capsule built before this capability all yield an empty tool list rather than an error. This is the foundation for later retiring the runtime describe fan-out; the sage-mcp broker is unchanged (a separate follow-up). Closes #978. - **`astrid capsule new ` scaffolds a complete, first-try-compiling tool capsule — with a toolchain preflight and a self-contained authoring guide.** Previously a new capsule author (human or a fresh agent) had to hand-assemble the skeleton from memory or by copying an existing capsule, and the most common first-build failure was a missing `.cargo/config.toml` getrandom backend cfg — without `--cfg=getrandom_backend="custom"`, anything pulling `getrandom` (uuid v4, the `HashMap` `RandomState`) fails to *link* on `wasm32-unknown-unknown` with an opaque error. The new command writes `.cargo/config.toml` (with that fix), a pinned `rust-toolchain.toml`, a `Cargo.toml` (`cdylib` + size-optimised release profile), a `Capsule.toml` (component block + the mandatory tool-bus `[publish]`/`[subscribe]` ACL), a working `src/lib.rs` (a `hello` `#[astrid::tool]` example), a `README.md`, and an `AUTHORING.md`. The `AUTHORING.md` is a from-zero, self-contained guide — a Claude reading only it can author the capsule without touching Astrid's source: the `#[capsule]` + `#[astrid::tool]` pattern with a worked example, the `Capsule.toml` sections (including `[capabilities]` and the `[publish]`/`[subscribe]` ACL, with the fail-closed "empty table = deny all" rule), the getrandom footgun (already wired by the scaffold), the tool-bus topic conventions and WIT references a tool capsule needs, and the build → install → call dev loop. Before generating, the command runs a **fail-friendly Rust-toolchain preflight**: it checks that `cargo`/`rustc` are on PATH and the `wasm32-unknown-unknown` target is installed, and if anything is missing it prints actionable guidance (the rustup install one-liner and `rustup target add wasm32-unknown-unknown`) — and, when `rustup` is present, offers to add the target interactively (or non-interactively via `--install-target`). A missing toolchain never aborts: the skeleton is still written so the author has something to build once they install what they need. The command validates the name as a Rust package name, refuses a non-empty target directory (unless `--force`), accepts `--path ` and `--kind tool` (only `tool` is supported in v1), and prints next-steps. The `wit/` directory is intentionally not generated — it is produced at build time. Closes #912. - **Grant-on-first-use: an ungranted tool call now elicits consent and, on approve, grants the capsule — instead of silently dropping the call and timing the caller out.** Building on the per-principal capsule-access gate (#992/#993), an authenticated non-admin principal that invokes a capsule tool it does not yet hold previously had its call silent-dropped at dispatch, so the caller simply hung until its ~50s timeout with no signal. The dispatcher now, at that access-gate miss, publishes a new `IpcPayload::GrantRequired { request_id, principal, capsule_id }` on `astrid.v1.approval` (a distinct variant from `ApprovalRequired`, so a broker can tell a grant-on-use from a plain capability approval), carrying the kernel-stamped caller principal and the missing capsule id with an unguessable UUID correlation id — a synchronous fire-and-forget publish that adds no lock, `.await`, or blocking call to the dispatch hot path. A new kernel consent handler observes those signals on a single permanent `astrid.v1.approval` subscriber, and for each spawns a short-lived, timeout-bounded awaiter (with a fixed in-flight cap, fail-closed dropping at saturation) that subscribes to the per-request `astrid.v1.approval.response.` topic; on an APPROVE response (the exact `approve`/`approve_session`/`approve_always` set the host approval flow uses) it grants the capsule to the principal by reusing the #993 admin grant machinery (load → set-delta → validate → save → cache-invalidate) under the same `admin_write_lock`, so a grant-on-use and a concurrent `agent modify` cannot race the same profile. **Security:** the grant target `(principal, capsule_id)` comes only from the kernel-observed `GrantRequired` (captured by value into the awaiter); the response payload conveys only a `decision` and is never read for a target, so the grantee is structurally un-spoofable. Response authenticity rides on the existing publish-ACL for `astrid.v1.approval.response.*` (only the uplink and broker may publish it; a tool capsule cannot forge an approve), consumed without a separate provenance check exactly as the host approval flow does. Every failure path — invalid principal, missing/unloadable profile, validation/save error, deny, unknown decision, timeout, uncorrelated response — is a fail-closed `warn!(security_event = true)` no-op that never creates a phantom principal or default-allows. The external consent shim/broker is out of scope here (kernel-side only). Refs #998. ### Fixed - **Rate-limited kernel management requests now receive their error reply on the response topic instead of timing out.** When a management request (`astrid.v1.request..`) tripped the `ManagementRateLimiter`, the kernel router derived the error's reply topic with a no-op `replace("kernel.request.", "kernel.response.")` — a substring that never appears in the real `astrid.v1.request.*` topics — so the `Rate limited` error was published back on the *request* topic and the client, waiting on `astrid.v1.response..`, hung until its own timeout. The rate-limit path now uses the same `astrid.v1.request.` → `astrid.v1.response.` derivation as the normal request path (extracted into a shared `response_topic_for` helper), so the rejection reaches the caller immediately. Kernel-internal, no WIT change. Found in PR review. - **`astrid init` / `astrid distro apply` now enforce a distro's `[distro].astrid-version` floor against the running CLI before any prompting or install, instead of silently proceeding into a broken onboarding on an outdated client.** A distro manifest declares a CLI-version requirement (e.g. `astrid-version = ">=0.6.0"`), but the CLI only validated it as *well-formed* semver (`VersionReq::parse(...).is_err()`) and never compared it to the running binary — so a manifest that bumped its floor, fetched from the repo `main` tip (`raw.githubusercontent.com/.../main/Distro.toml`), would pass validation and break the install mid-flight on an older CLI with a confusing error instead of an actionable one. `run_init` (the path both `astrid init` and `astrid distro apply` take) now calls a new `enforce_astrid_version` immediately after `fetch_and_parse_manifest` and before lock-freshness / select / install; if the running CLI (`CARGO_PKG_VERSION`, the same source `astrid version` prints) does not satisfy the floor it fails fast with `This distro requires astrid {req}, but you are running {version}. Run \`astrid update\` to upgrade.`. For a plain release floor the comparison strips the running version's prerelease/build metadata and matches on the release triple, so a locally-built / dev / prerelease CLI at or above the floor (e.g. `0.6.0-dev.3` vs `>=0.6.0`) is **not** falsely rejected by the default `semver` prerelease rule, while a CLI whose release triple is genuinely below the floor still fails. When the requirement *itself* names a prerelease (e.g. `>=0.6.0-rc.3`) the triple-strip is dropped and the running version is compared as-is under exact semver semantics, so a lower prerelease of the same triple (`0.6.0-rc.2`) is correctly rejected rather than being lifted to `0.6.0` and over-accepted. A manifest with no `astrid-version` imposes no requirement, and a malformed `astrid-version` remains a manifest error. Scope is the `[distro].astrid-version` CLI gate only — the `[distro.requires.astrid]` interface version reqs are untouched. CLI-internal, no WIT change. Closes #1023. - astrid-mcp compiles against rmcp 1.8.0, and rmcp is pinned to its patch range. rmcp 1.8.0 (released 2026-06-23) changed Peer::peer_info() to return Option> instead of Option<&InitializeResult>, which broke the ServerInfo::from_rmcp(&InitializeResult, ...) call in the MCP client and failed the whole workspace build. Because the repo intentionally commits no Cargo.lock, every PR's CI resolves the latest compatible deps. - **Build-time tool capture no longer fails for pure-interceptor capsules (e.g. the broker), so they are marked captured instead of unknown.** `describe_capsule_tools` treated a capsule with no `#[astrid::tool]` as "no tools" only on a `NotSupported` error, but a capsule that has *other* interceptors and no `#[astrid::tool]` — notably the sage-mcp broker itself — has its generated dispatch *deny* the unknown action with `Deny("unknown hook action: tool_describe")`, which surfaced as a hard error. The capsule then installed with `meta.json` `tools: None` (uncaptured). That broke the deterministic tool surface end-to-end: the broker's all-or-nothing static-surface check requires *every* loaded capsule captured (including the broker), so with the broker uncaptured the static path never fired and every daemon fell back to the describe fan-out. `tool_describe` now treats a `Deny("unknown hook action")` as "no tools" (`Ok(vec![])`), exactly like `NotSupported`; any other deny reason is still a genuine refusal and surfaces as an error. Caught by a live end-to-end run. Closes #985. - **`astrid mcp serve` now fails loud instead of silently serving the MCP bridge as the no-capability `anonymous` identity.** When the shim is asked to serve as a principal whose keypair is missing (`keys/.key` absent — e.g. a stale running binary that predates the signed handshake, or a principal that was never minted), the handshake falls to the legacy single-frame path and the daemon binds the connection `anonymous`. The bridge then came up "successfully" yet every `tools/call` failed the ingress-trust and capability checks and, to the client, simply hung/timed out with no diagnostic (this cost real debugging time tracing a governed-Claude tool timeout back to an `anonymous`-bound shim). `SocketClient` now records whether the handshake authenticated as the requested principal (took the signed path) versus fell to the legacy `anonymous` path (exposed as `is_authenticated()`), and `astrid mcp serve` refuses to start when a non-`anonymous` principal did not authenticate — emitting an actionable error (run `astrid agent create ` to mint its keypair, or pass `--principal anonymous` to serve unauthenticated on purpose). Requesting `anonymous` explicitly still serves. CLI/uplink-internal, no WIT change. Closes #950. - **Client-connection lifecycle events are now emitted by the host, not the capsule-cli proxy, fixing an unbounded per-principal connection-count leak.** The kernel connection tracker keys its per-principal `active_connections` count off the principal stamped on `client.v1.connect` / `client.v1.disconnect` bus events (`+1` / `-1`, driving the ephemeral idle-shutdown gate and `astrid who`). The proxy emitted both via `ipc::publish-as`, but the host `publish-as` deliberately derives the effective principal from the connection's handshake-verified identity (the anti-forge boundary, #45/#852) and **clears** that identity on close. The proxy's `client.v1.disconnect` fired *after* the connection — and its verified identity — was already gone, so the host stamped it the reserved no-capability `anonymous`: connect incremented the real principal, disconnect decremented `anonymous` (a saturating no-op), and the real principal's count leaked `+1` per connection, unbounded. (This reproduced on current main and was **not** a capsule-cli version issue — the root cause is the emission site.) The emission moves into the host, which holds the verified principal for the whole connection lifetime: `net.unix-listener.{accept,poll-accept}` record the verified principal (the reserved `anonymous` for a legacy/unauthenticated peer, so the pair still balances) in a new shared `client_connections` registry keyed by the stream resource rep and emit `client.v1.connect`; the `tcp-stream` drop path emits `client.v1.disconnect` stamped with the **same** stored principal and removes the entry — so connect and disconnect for one connection always pair on the identical host-verified principal and the counter balances. Outbound TCP (`connect-tcp`) is never registered, so a capsule-dialed socket never moves the connection counter; only inbound uplink connections do. The emitted principal is always the host-verified one, never a guest/capsule-supplied value, preserving the anti-forge boundary. Requires the capsule-cli proxy to stop emitting the same events (capsule-cli#32) in lockstep, or connections double-count. Kernel/host-internal, no WIT change. Closes #948. - **`agent create` now backfills a missing per-principal keypair onto an existing keyless principal, so upgraders auto-heal instead of staying stuck as `anonymous`.** Per-connection auth (#45/#852) makes `agent create` mint+register a per-principal ed25519 keypair so the principal can sign the socket handshake and be bound to its own scoped identity; a principal with no keypair gets stamped the no-capability `anonymous`, which masks its furnished home and every capability-scoped tool. Principals created BEFORE that feature landed are keyless (`auth.methods = []`, `auth.public_keys = []`, no `keys/.key`), and `agent create` used to hard-error "already exists" on any existing profile — so an upgrader's pre-feature principal (e.g. the plugin's `claude-code`) never got a keypair and stayed permanently `anonymous`, even though the plugin's `astrid-up` re-runs `agent create

` on every boot. A **bare** `agent create ` now surgically backfills the missing credential: it loads the existing profile, and if it carries no ed25519 credential (no `Keypair` method and no `ed25519:` public key) mints the keypair via the same path a fresh create uses (private key to `keys/.key` 0600 in system custody, public key + `Keypair` method registered on the profile) and persists it — so `astrid-up`'s per-boot re-run auto-heals upgraders. The backfill is security-positive and **strictly additive**: it never widens groups/grants and never touches network/process/quotas/home/state — it only adds the missing auth credential. A principal that **already** has a keypair is left untouched (still errors "already exists", never re-minted), and a re-create carrying any profile-shaping input (`--clone`/`--inherit-from`/`--group`/`--grant`) still errors rather than being silently dropped — a backfill is not a re-create. Kernel-internal, no WIT change. #45/#852. - **Local per-connection principal authentication: the socket handshake cryptographically proves which principal a connection acts as, and the kernel now binds that verified identity to the connection's outbound traffic (kills the self-stamp).** Each principal already gets a per-principal ed25519 keypair on `agent create` (private key written to `keys/.key` 0600, public key registered as `ed25519:` on the profile); this extends the kernel-side socket handshake to verify that key over a challenge-response, and records the verified identity per connection. The handshake gains an OPTIONAL second frame: when a client sets `claimed_principal` on its first request, the daemon replies with a random 32-byte challenge nonce, the client returns an ed25519 signature over `astrid-principal-auth:v1:{principal}:{nonce_hex}`, and the daemon verifies it against a key registered in that principal's `AuthConfig.public_keys`. A valid signature yields a verified `PrincipalId`; an invalid one fails the handshake closed (a bad signature is an attack, never a fallback to unauthenticated). A request WITHOUT `claimed_principal` completes in the legacy single round trip with no verified principal, so legacy clients and legacy daemons keep interoperating (the new wire fields are `#[serde(default)]`, no WIT change — the handshake is kernel-internal plain Rust). The verified principal is stored in a per-connection registry on `HostState` (`connection_principals`, keyed by the stream resource rep, shared across the capsule's pooled instances and torn down when the stream drops). The uplink client signs the challenge automatically when `keys/.key` exists for the connecting principal, falling back to the single-frame flow otherwise. The bootstrap `default` principal now also gets a keypair minted at boot so the operator can authenticate as `default`. **Enforcement (the self-stamp fix):** when the uplink (capsule-cli) forwards a client frame to the bus via `publish-as`, the host now stamps the connection's verified principal in place of the capsule-supplied name — so a socket client can no longer name a principal it has not proven, which was the whole point of #45/#852. The framed `tcp-stream.read` that pulls a frame off a kernel-bound connection records that connection's verified principal on `HostState` (`ingress_principal`); the immediately-following `publish-as` consumes it (the uplink's run loop reads a frame then forwards it before reading the next, so the binding lives exactly as long as the in-flight frame, and a non-data read clears it). A connection with **no** kernel binding is stamped the reserved no-capability `anonymous` identity (see the Security entry "Unauthenticated socket connections..." below — this closes the unbound self-stamp); the operator and every agent authenticate via their auto-minted keypair, so they are bound and act as themselves. capsule-cli is **unchanged** (the host derives the principal from the connection, ignoring any capsule-supplied name), and there is no WIT change — the derivation is entirely host-side. This makes the crypto-authenticated client path enforce end-to-end. #45/#852. - **Capability-token lifecycle management: human "Always Allow" is now permanent, plus admin verbs to mint / revoke / list `mcp://` tokens.** "Always Allow" previously minted a capability token with a hard 1-hour TTL — so a decision the user expressed as *always* silently expired after an hour and the agent re-prompted. `handle_allow_always` now mints a permanent token (`ttl: None`, valid until explicitly revoked); the bounded-grant case is served instead by the new admin mint `--ttl`. New admin verbs let an operator pre-grant tool access so an agent never hits a per-use approval elicitation: `astrid caps token mint [--permission

] [--ttl ]`, `astrid caps token revoke `, and `astrid caps token list `. Tokens are signed by the runtime ed25519 key — the same key the approval interceptor's validator trusts as issuer — so a minted token authorizes immediately on the secure path and survives daemon restarts (persistent scope). To reach the signing key from the admin handlers without loading it from disk twice, the runtime key is now loaded once at boot, shared (`Arc`) with the audit log, and exposed as `Kernel::runtime_key`. Minted tokens are **revocable** (revocation is global and final), **principal-scoped** (carry the principal in the signed payload per #668 — a token minted for Alice never authorizes Bob), **admin-gated** (new `caps:token:{mint,revoke,list}` capabilities, covered by the `admin` group's `*`; a scoped `agent` principal must not hold them), and **audited** (every verb records an `admin.caps.token.*` audit entry through the existing admin-audit path). `permission` defaults to `invoke`; an unknown permission string or a non-existent principal is rejected with a bad-input error and no token is created. Tokens are kernel-internal — no WIT change, no RFC. Closes #929. - **`astrid agent create --clone ` replicates an existing agent's full profile and state in one atomic admin call.** `--inherit-from` copies state only (env/KV/secrets) and deliberately leaves the capability profile empty, so there was no first-class way to *replicate* an agent — duplicating one meant rebuilding its groups, grants, revokes, egress, process allow-list, and quotas by hand via `caps grant` / `agent modify` / `quota set`. `AgentCreate` gains `clone_from: Option` and `allow_admin_clone: bool` (both `#[serde(default)]`, so older serialized requests deserialize unchanged). When `clone_from` is set, the kernel builds the new principal as a full replica of the source — groups + grants + revokes + network egress + process-spawn allow-list + quotas — and then performs the same env/KV/secret state copy `inherit_from` does. Deliberately **NOT** copied: the source's `auth` (each principal keeps its own keys/authenticators — cloning is profile+state, never credentials) and its `enabled` flag (a fresh clone is always enabled, even if the source was disabled). `--clone` is mutually exclusive with the profile/quota-shaping flags (`--group`, `--egress`, `--process-allow`, `--inherit-from`, `--memory/--timeout/--storage/--processes`): the CLI enforces this via clap `conflicts_with` and the kernel rejects a request that sets both — defense in depth against a hand-built request. **Admin-source guard:** cloning a source that resolves to the universal `*` (the built-in `admin` group, a `*` grant, or a custom `unsafe_admin` group — resolved through the live `GroupConfig`, not a literal scan) is rejected unless the operator passes `--unsafe-admin`, mirroring the acknowledgement on `caps grant '*'` and `group create --caps '*'` — so `--clone default` cannot silently mint a second admin. The source is validated to exist and not be the new principal itself before any write runs. The HTTP management API constructs `clone_from: None` (an API-level clone is a follow-up). Closes #927. - **Gateway `POST /api/capsules` now resolves GitHub sources (`@org/repo`, `github.com/org/repo`, `https://github.com/org/repo`) to a local archive in the uplink before install; the daemon still never fetches URLs.** Previously the gateway forwarded `source` verbatim to the kernel's path-only `InstallCapsule` handler, which rejects every remote-shaped source — so an integrator that only speaks gateway HTTP could not install a registry/GitHub capsule even though the CLI could (the CLI resolves GitHub sources client-side and hands the kernel a local path). The gateway, an uplink like the CLI, now does the same: for a GitHub-shaped source it fetches the repo's latest release, selects the matching `.capsule` asset (the lone one, or the one named via a new optional `capsule` request field for a multi-capsule release), streams it to a temp file with a hard 50 MB cap, and calls the existing `InstallCapsule` with that LOCAL path (`workspace` forced `false`). The kernel install path is unchanged and still rejects URLs — the fetch lives ONLY in the gateway. The five pure GitHub source-parsing/asset-selection helpers move into `astrid-capsule-install::github_source` (a network-free module the CLI and gateway share); the kernel and the install crate gain no network dependency. The gateway implements a narrower path than the CLI on purpose: no clone-and-build fallback, no local-Cargo auto-build, no multi-asset "install all" — it requires resolving to exactly one `.capsule`. New 404 (no release / no `.capsule` asset) and 400 (ambiguous multi-capsule release, or archive over 50 MB) responses. Closes #960. - **The `astrid mcp serve` shim now elicits interactive user consent when the sage-mcp broker reports an untrusted ingress, replacing the static operator allow-list.** Previously the broker gated its state-mutating `tools/call` front door on an operator-maintained `trusted_ingress_ids` list; an ingress that was not pre-pinned was hard-denied. The broker now replies an `ingress_approval_required` signal for a not-yet-trusted ingress, and the shim turns it into a user consent prompt via the MCP elicitation capability ("Allow Astrid tool calls from this session?"). On accept the shim forwards `astrid.v1.request.mcp.ingress.respond` `{ req_id, accept }`, the broker records trust keyed on the kernel-stamped caller `source_id` (never a body field), and the shim re-sends the original call. Fail-secure: a client without elicitation, a decline / cancel, an elicit error, or a broker that could not persist the grant all return an MCP error to the client and record no trust. Manual end-to-end verification of the live elicit flow is pending (needs an elicitation-capable client, Claude Code ≥2.1.76). Paired with the sage-mcp broker change. Closes #917. ### Removed - **Build-time tool baking (`tools.json`) is removed — it is superseded by the kernel's load-time describe (#987), which needs no static artifact and no rebuild.** With every capsule's tools now reaching a consumer via the live `tool_describe` probe at load, the build-time machinery was redundant. Removed: `astrid capsule build` instantiating the compiled component to capture descriptors and inject a `tools.json` into the archive (it reverts to plain compile + pack — no wasmtime, no integrity-meta staging, no MCP-hybrid guard); the build-time `describe_capsule_tools` and its ephemeral-instantiation helpers (the shared `describe_loaded_capsule` the kernel uses is **kept**); `CapsuleMeta.tools` and the install `tools.json` read; `astrid capsule show`'s tool listing; the kernel's now-vestigial baked-tools check (it always probes); and the build-only `astrid-audit` dependency on `astrid-capsule`. The one capability given up — offline / pre-install tool inspection (listing a capsule's tools without a running daemon) — is deferrable and re-derivable later if the consent/disclosure plane needs it. Verified live: with no `tools.json` produced or installed anywhere, a daemon still serves a tool capsule's full surface via the broker's static path. Closes #989. ### Changed - **Principal profile field validation now uses typed domain wrappers internally while preserving the public string-shaped profile API.** `GroupName`, `CapabilityPattern`, and `CapsuleGrant` distinguish group memberships, direct capability patterns, and capsule grants at validation and authorization edges, with a borrowed `ValidatedProfileFields` view for policy checks. `PrincipalProfile` remains TOML/JSON/admin-API compatible (`Vec` fields), so callers keep the existing wire shape while kernel/admin paths validate incoming strings before persistence and capability checks fail closed on malformed profile fields. Closes #1033. - **IPC topics are now a typed `Topic` newtype with per-family constructors, replacing scattered `format!` strings and duplicated `const *_TOPIC`/`*_PREFIX` literals.** `astrid_types::Topic` is a `#[serde(transparent)]` newtype over `String` (so the JSON shape and the guest `string` ABI are byte-identical to before — no wire-format change, no WIT change, no version bump). `IpcMessage.topic` and `SelectionRequired.callback_topic` are now `Topic`; `IpcMessage::new` takes `impl Into`. Typed constructors (`Topic::elicit_response`, `approval_response`, `kernel_request`/`kernel_response`, `admin_request`/`admin_response`, `reload_capsule_request`/`reload_capsule_response`, `session_response`, `cli_command_run`/`cli_command_result`, `user_prompt`, `agent_response`, `client_connect`/`client_disconnect`, `audit_entry`, …) are the single source of truth for each family's exact wire string; a `from_raw` escape hatch covers the WIT bindgen boundary and genuinely dynamic topics. Construction sites across the kernel router, uplink clients, capsule host fns, gateway, CLI, and `astrid-emit` were migrated, and the duplicated topic-prefix consts (`REQUEST_PREFIX`, `RESPONSE_PREFIX`, `ADMIN_INPUT_PREFIX`/`ADMIN_RESPONSE_PREFIX`, `APPROVAL_TOPIC`, `CLIENT_*_TOPIC`, the mcp shim's `RESPONSE_PREFIX`) were collapsed onto the constructors. Read-side match/subscribe-pattern literals (including wildcard patterns and the deliberately capsule-local `AUDIT_TOPIC` pinning anchor) are unchanged. Unit tests pin every constructor's output and prove `serde` transparency, guarding the byte-identical-on-the-wire invariant at build time. Internal refactor only. - **Group config validation now uses typed domain wrappers internally while preserving the public string-shaped groups API.** `GroupConfig` and `Group` remain TOML/JSON/admin-API compatible (`HashMap` and `Vec` fields), but load, mutation, and capability-check edges now convert group names and group capability entries into borrowed `GroupName` / `CapabilityPattern` views. This rejects invalid quoted group keys such as `groups."ops/team"` before persistence and makes group inheritance fail closed if an invalid in-memory config is constructed manually. Closes #1043. - **The deterministic tool surface is now automatic — the kernel describes any un-baked capsule at load, so no rebuild is needed to retire the describe fan-out.** Build-time tool baking (#978) only takes effect once a capsule is rebuilt and re-released; until then it is uncaptured and the broker falls back to the racy fan-out, so activation was gated on a fleet rebuild. Now `publish_capsules_loaded` **probes each loaded capsule once** — invoking its existing `tool_describe` interceptor (the same hook the dispatcher already routes) and injecting the captured descriptors into the `capsules_loaded` payload. The kernel **invokes-and-forwards** — it never interprets the descriptors; the broker still owns all policy. A describe failure leaves `tools` absent for that capsule this cycle (the consumer falls back to its fan-out). Net: every loaded capsule — including un-rebuilt and third-party ones — contributes a complete, deterministic tool surface from the signal the broker already consumes, with **no rebuild, no SDK change, and no broker change**. This shifts the kernel from a pure opaque-forward (#980) to a probe-at-load (PCI/driver-probe-style: invoke a known hook, forward the opaque result). (With this in place, the build-time `tools.json` baking became redundant and is removed — see the Removed entry above.) Closes #987. - **A capsule's baked tool surface now records whether it was *captured* at build, distinguishing "authoritatively no tools" from "not captured yet".** #978 baked tool descriptors into `meta.json`, but an empty `tools` was ambiguous — a capsule that genuinely has no tools (the cli socket-owner, run-loop capsules) looked identical to one built before tool-baking. A consumer (the sage-mcp broker, next) can't retire the runtime describe fan-out without telling these apart, or it would silently drop tools from any un-rebuilt capsule. `CapsuleMeta.tools` is now `Option>`: `None` = surface not captured (discover at runtime), `Some([])` = captured and authoritatively no tools, `Some([..])` = the captured tools. `astrid capsule build` writes `tools.json` whenever the WASM capture **succeeds** — even for a zero-tool capsule, so the file's presence is the marker — **except** when the capsule declares an `[[mcp_server]]` (its tools come from the MCP engine, not `#[astrid::tool]`, so the WASM capture is incomplete and it is left unmarked → `None` → fan-out). Install reads `tools.json` into `Some` (absent/corrupt → `None`); `astrid capsule show` now distinguishes "not captured at build" from "captured, no tools". Backward-compatible (old `meta.json` with no `tools` key → `None`). Closes #983. - **The `astrid.v1.capsules_loaded` broadcast now carries each loaded capsule's installed metadata, so a sandboxed consumer can derive a deterministic tool surface from the change signal instead of a racy `describe` fan-out.** A WASM-sandboxed capsule (e.g. the sage-mcp broker) has no channel to learn the loaded-capsule set or its tools from the kernel — `ListCapsules`/`GetCapsuleMetadata` are CLI-only `KernelRequest`s and it cannot read the install dir's `meta.json` from inside the sandbox — so tool discovery has had to go through a `tool.v1.request.describe` broadcast that races a timeout (the first-prompt-after-boot non-determinism). Now that tool descriptors are baked into each capsule's `meta.json` at build time (#978), the kernel surfaces that static surface over the signal consumers already receive: the `capsules_loaded` payload keeps `status: "ready"` (so existing subscribers — the `astrid mcp serve` shim, the TUI — that treat it as a bare signal are unaffected) and **additively** gains `capsules: [{ name, meta }]`, where `meta` is the capsule's `meta.json` forwarded as an **opaque** JSON value. The kernel does not parse or interpret that metadata (no tool awareness, no typed tool field on any kernel type) — it surfaces metadata about the capsules it owns, the way a Linux uevent carries a device's attributes and leaves all interpretation to userspace; the consumer extracts whatever it needs. This gives a deterministic tool source without the kernel gaining tool knowledge and without widening the consumer's own capabilities. The per-capsule `meta.json` is read off the registry lock and is best-effort (a missing/unreadable/non-JSON file contributes a `null` `meta` and never blocks the broadcast). Kernel-internal (the event payload is opaque `RawJson`); no WIT change, no RFC. Foundation for the sage-mcp broker switch that retires the fan-out. Closes #980. - **`astrid capsule remove` now unloads the capsule from a running daemon — a removal takes effect live, no restart needed.** Install (#958) and upgrade (#968) already went live, but `remove` was disk-only: it deleted the capsule from disk yet left it registered in the running kernel, so the removed capsule kept receiving events and its tools lingered in the `astrid mcp serve` tool surface (callable, erroring) until a restart. A new targeted `KernelRequest::UnloadCapsule { id }` unregisters a single capsule from the running registry and explicitly unloads its WASM component (there is no async `Drop`, so this is done eagerly to avoid leaking the component and any MCP subprocesses), then publishes `astrid.v1.capsules_loaded` so the shim's MCP `notifications/tools/list_changed` fires and a connected agent sees the tools disappear live. `astrid capsule remove` sends this best-effort after its existing removal: the on-disk removal stays **authoritative and dependency-checked** (the sole-provider guard is unchanged) and the live unload only mirrors it into the running daemon — **non-fatal** as with install/upgrade (no reachable daemon is silent; a capsule that was never loaded live is a quiet no-op rather than a false "unloaded"; a declined or timed-out unload only prints a note; the remove's exit status is never affected). The verb is gated behind a new, distinct **`capsule:remove`** capability — removal is destructive and globally-scoped, so it is separately grantable/revocable rather than reusing `capsule:reload`; it is held by the `admin` group via `*` and deliberately **not** granted to the `agent` group (unlike the restorative `self:capsule:reload`). The kernel verb is dependency-blind by design — the CLI runs the sole-provider check before nudging, and kernel-side dep-safety is deferred to any future direct/recursive caller. The client-supplied id is validated (`CapsuleId::new`) before it is used as a registry key, the call is rate-limited, and it is kernel-internal (no WIT change). Paired with the sage-mcp broker cache-eviction so a removed (or upgraded) capsule's tools leave the surface immediately instead of lingering for the descriptor-cache TTL. Closes #970. - **`astrid capsule install` / `update` now hot-*swap* an already-loaded capsule in a running daemon — a reinstall or update goes live without a restart, not just a fresh add.** #959 hot-*added* a freshly-installed capsule via a best-effort broadcast `ReloadCapsules`, but `load_all_capsules` skips already-registered capsules, so iterating on a capsule (rebuild → reinstall) or `astrid capsule update` still needed a restart to take effect. A new targeted `KernelRequest::ReloadCapsule { id }` resolves as **add-or-restart**: a registered capsule is hot-swapped to the new on-disk bytes (`Kernel::restart_capsule` unregisters and re-loads from the source dir, and the loader reads the content-addressed WASM by the hash a reinstall has bumped — so unregister-then-reload picks up the new bytes), while an unregistered one is loaded fresh. The install nudge now targets this verb per just-installed capsule id (superseding the broadcast) and moves into `install_capsule`, so the `update` and TUI install paths inherit live hot-swap too. Either branch publishes `astrid.v1.capsules_loaded`, so the `astrid mcp serve` shim's MCP `notifications/tools/list_changed` fires and a connected agent sees the change live. Best-effort and **non-fatal** as before — no reachable daemon leaves the standalone install untouched (the capsule loads on the next daemon start), a declined or timed-out reload only prints a note, and the install's exit status is never affected. `ReloadCapsule` is capability-gated (`capsule:reload`, the same capability as `ReloadCapsules`, so no new authority), rate-limited, and kernel-internal (no WIT change). Live *remove* (unload) lands in #970. Closes #968. - **`astrid capsule install` now hot-loads the new capsule into a running daemon, so a freshly-installed capsule is usable without a restart.** The on-disk install is deliberately standalone and daemon-independent (it must work with no daemon running) and stays that way — but it never told a live daemon about the new capsule, so the capsule's tools weren't available until the daemon was restarted (friction for the build-a-capsule → use-it loop). After a successful install the CLI now sends a best-effort `KernelRequest::ReloadCapsules` to a reachable daemon: the daemon re-scans the install dir and loads the newly-present capsule, and the resulting `astrid.v1.capsules_loaded` is already turned by the `astrid mcp serve` shim into an MCP `notifications/tools/list_changed`, so a connected agent sees the new tools live. The nudge is best-effort and **non-fatal** — no daemon (socket absent or unreachable) leaves the standalone install untouched (the capsule loads on the next daemon start), and a daemon that declines or times out only prints a note; the install's success and exit status are never affected. It is wired into the manual `astrid capsule install` dispatch arm only (not `update`, `remove`, the distro-batch install, or the TUI, which has its own `/refresh`), and `ReloadCapsules` stays capability-gated (`capsule:reload`) kernel-side, so this adds no new authority. Scope: loads a **newly-installed** capsule — re-installing a capsule that is already loaded (same id) is skipped by the loader's already-registered guard, so swapping an already-loaded capsule's code still needs a restart (in-place live upgrade is a separate, later change). CLI-only, no WIT change. Closes #958. - Extracted the inline `#[cfg(test)] mod tests` blocks from `dirs.rs`, `secret.rs`, `kernel_router/mod.rs`, `manifest_gate.rs`, and `policy.rs` into sibling test files to keep each production file under the 1000-line file-size cap; pure mechanical move, no behaviour change. Closes #956. - **CI `Test` job strips debug info so linking the `astrid-capsule` test binary no longer exhausts the runner.** The `astrid-capsule` test binary statically links the entire dependency tree (wasmtime, aws-lc, ring, surrealkv, …); after a GitHub `ubuntu-latest` runner-image rotation the final link began dying with `collect2: fatal error: ld terminated with signal 7 [Bus error]` (an OOM/disk-pressure failure), turning every `Test (ubuntu-latest)` run red and cancelling `macos-latest` via matrix fail-fast — independent of the change under test (it reproduced on a main commit that touched neither capsule code). The Test job now sets `CARGO_PROFILE_DEV_DEBUG=0` / `CARGO_PROFILE_TEST_DEBUG=0`, dropping debug info (the dominant contributor to both linker peak memory and `target/` size) for the test build only. CI-scoped via Cargo profile env overrides — local and dev builds keep full debug info. Closes #954. - **Manual `astrid capsule install @org/repo` against a multi-capsule release now installs every capsule the release ships by default, with `--capsule ` to pick one.** #921/#922 threaded the distro capsule `name` into `pick_capsule` so a monorepo release could select the right archive, but left the *manual* install path with no selector: a release shipping several `.capsule` assets passed no hint and hit `pick_capsule`'s "no capsule name to pick one" hard error, so there was no way to install such a release by hand at all. The manual path now defaults to installing **all** capsules in the release — `install_from_github` collects every `.capsule` asset (via a new pure `capsule_assets` helper over the release JSON) and, when no selector is given, downloads and unpacks each one. Installation is **best-effort**: it prints `Installing ...` per archive, reports any that fail and keeps going, then returns an error naming all failures if any did — never silently swallowing a failed install. A new `--capsule ` flag opts back into single-capsule selection (`.capsule` via the unchanged `pick_capsule`), and a single-asset release installs that one capsule exactly as before. The distro path (`install_capsule_batch`) is **unchanged** — it keeps selecting by the distro capsule `name`. The `update` path now passes the capsule being updated as the selector so a monorepo source refreshes only that one capsule, not the whole release. The clone+build fallback (no release assets) stays single-capsule; making `astrid-build` handle a virtual-workspace root is a separate gap. Closes #925. - **Guest `ERROR`-level logs now surface in the daemon log, not only the per-capsule log file.** When a run-loop capsule's `run()` returns `Err` before signaling ready, the daemon log showed only a contextless `Capsule run loop exited before signaling ready` — for the sole-socket-owner uplink (the cli proxy) that takes the whole daemon down, and diagnosing it meant hours of guessing. The reason was never actually lost: the SDK `#[astrid::run]` macro logs `run loop exited with error: {e}` at ERROR level before returning, but `sys::log` writes guest logs to a per-capsule file (`effective_capsule_log()`) and only fell back to the daemon's tracing subscriber when there was no per-capsule file — so the reason landed in a separate file operators don't check during a crash. ERROR-level guest logs are now emitted to the daemon's tracing subscriber **in addition** to the per-capsule file (lower levels stay file-only when captured, preserving the daemon log's signal-to-noise). The silent run-loop crash is now a one-line diagnosis (`ERROR …host::sys: run loop exited with error: … plugin=`) 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>` 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` 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. - **Capsule-identity v5 namespace is a dedicated Astrid constant, not `Uuid::NAMESPACE_OID`.** Follow-up to the deterministic-capsule-identity change (#910): `NAMESPACE_OID` is reserved for ISO OIDs, so deriving capsule ids from it is a semantic mismatch (and a negligible-but-real collision surface with OID-derived uuids elsewhere). Derivation now uses a fixed, Astrid-owned namespace (`CAPSULE_ID_NAMESPACE`). This changes the derived `capsule_uuid` / `source_id` values from the pre-release #910 form, so any `trusted_ingress_ids` pin must be recomputed against the new namespace (pre-release, so no compatibility guarantee broken). Closes #913. - **`astrid update` (self-update) is now a coherent, staged, in-place upgrade.** It updates the *actual running binary in place* — so there is one `astrid` and `which astrid` never diverges — instead of always writing a shadow copy to `~/.astrid/bin` that could leave a brew install running the old version. Homebrew installs are detected (resolved path under a `Cellar`) and **deferred to `brew upgrade astrid`** rather than shadowed. The flow now: download → verify the release `SHA256SUMS` (integrity) → confirm (unless `-y`) → **atomically swap** `astrid` + `astrid-daemon` with a `*.bak` backup and **rollback on failure** → restart a running daemon so the new code takes effect → sync distro + capsules. New flags: `--check` (report availability without installing), `-y`/`--yes` (skip the prompt), and `--source owner/repo` (plus `ASTRID_UPDATE_REPO` / `ASTRID_UPDATE_API`) to rehearse the entire flow against a fork, pre-release, or local mock — there was previously no way to test the binary update path locally. The update banner is install-aware (`brew upgrade` vs `astrid update`). The legacy `self-update` name remains as a hidden alias. Closes #904. - **Capsule-contributed CLI verbs (`astrid capsule [args...]`).** A capsule can now declare a top-level CLI verb by adding `kind = "cli"` to a `[[command]]` entry in its `Capsule.toml` (the default `kind = "slash"` is unchanged — existing manifests parse and behave identically). The CLI resolves the verb against the daemon's command registry, dispatches it to the providing capsule over IPC as a non-interactive one-shot (`cli.v1.command.run.` → `cli.v1.command.result.`, 30s budget), and exits with the capsule's reported exit code. `astrid capsule run ` disambiguates when two capsules provide the same verb. CLI-verb names are validated at manifest parse time (fail closed: `[a-z][a-z0-9-]*`, 1-32 chars, cannot shadow a built-in `astrid capsule` subcommand). The kernel routes but never interprets the run/result payloads — the contract is capsule-space. Install surfaces any new CLI verbs a capsule adds. Refs #891. - Split `host_state.rs` `impl HostState` method groups (the per-principal `effective_*` overlay accessors, the recv-path invocation-context installer, and the per-connection principal binding accessors) into `#[path]` sibling modules to stay under the 1000-line file-size cap; pure mechanical move, no behaviour change. Closes #952. ### Fixed - **Daemon stop/restart no longer orphans the `state.db` lock, and the MCP shim survives a daemon restart.** A crashed/wedged daemon left `astrid stop` deleting only the socket — the orphaned process kept the singleton lock, so the next `astrid start` died silently on "Database … is already locked". The daemon now records its PID **and canonical executable path** in `run/system.pid`; `stop`/`restart` resolve the live process's executable (`/proc//exe` on Linux, `proc_pidpath` on macOS) and signal it (SIGTERM → grace → SIGKILL) **only when the PID is live and the exe matches** — guarding against PID reuse, so on any mismatch or inability to verify it fails secure (no signal; `restart` bails) rather than risk killing an unrelated recycled-PID process. Daemon boot stderr is now captured to `log/daemon-boot.log` instead of `/dev/null`, so a lock-acquisition failure is no longer silent. Separately, the `astrid mcp serve` shim now detects connection-loss on the response path (not only on send) and pre-heals before the next request; auto-retry is gated to idempotent requests (`tools/list`) — a `tools/call` is never auto-retried, since it may have executed. `run/system.pid` and `log/daemon-boot.log` are ephemeral (recreated each boot); no persistent-format change, no migration. Closes #939. - **`astrid init --distro ` now installs the correct capsule when one source ships several.** Capsule install (`install_from_github` and the `clone_and_build` fallback in `astrid-cli`) selected the *first* `.capsule` it found — the first release asset ending in `.capsule`, or the first archive in the `astrid-build` output dir — and the distro capsule `name` was never threaded into that choice. Fine for one-capsule-per-repo, but a monorepo distro lists several `[[capsule]]` entries sharing one `@org/repo` source (the `sage` distro: `sage` / `sage-mcp` / `sage-install`, all from `@unicity-astrid/sage`), so every entry resolved to the same first archive — silently installing the wrong capsule. The capsule `name` is now threaded through `install_capsule_batch` → `install_capsule_inner` → `install_from_github` / `clone_and_build`, and a new `pick_capsule` helper chooses the archive named `.capsule` when a source yields several. A lone `.capsule` stays unambiguous (single-capsule repos are byte-identical to before); several archives with no matching name is a hard error rather than a silent wrong install. Releasing per-capsule `.capsule` assets for monorepos is a separate follow-up. Closes #921. - **Multi-subscriber fan-out is dispatched concurrently instead of as one serial chain, so every subscriber reliably receives the event.** When more than one capsule subscribed to a topic with a handler, the dispatcher ran them all as a single sequential interceptor chain on one task, serialized per-(capsule, principal) chain lock. For a fan-out — independent responders, e.g. the 6-way `tool.v1.request.describe` describe broadcast — a slow leading member starved the later ones, so the requester collected responses from only a non-deterministic subset (~3 of 6) within its window, and re-firing the request contended on the chain lock rather than parallelizing. Matches that all share one priority (which have no defined order, and are every multi-subscriber topic today: tool-describe, the prompt hook fan-out, and LLM-provider discovery) are now dispatched independently on their own per-(capsule, principal) consumers and run concurrently; one subscriber's `Final`/`Deny`/error/slowness no longer suppresses or stalls another. A genuinely ordered set (members at distinct priorities) keeps the sequential, short-circuiting chain. Kernel-internal; no host-ABI / WIT / IPC change. Closes #902. - **Routed IPC receive no longer discards burst events.** The routed-receiver fast path drained the whole queued batch into a buffer but returned only its first event, silently dropping the rest — they were already dequeued from the route entry but never handed back. A subscriber that received a burst (e.g. a fan-out where every responder replies at once) therefore lost all-but-one event of each batch per `recv`. The fast path now pops exactly one event (`drr_drain` → `drr_pop_one`) and leaves the remainder for the paired `try_drain`, making routed delivery loss-free. This was the residual cause of the tool-describe fan-out reaching only ~4-5 of 6 responders even after the concurrent-dispatch fix above; with both, a single fan-out now reaches every responder deterministically (#902). - **Kernel router no longer logs spurious `Failed to parse KernelRequest` warnings for non-kernel traffic.** The router subscribes to the broadcast `astrid.v1.request.*` namespace, which it shares with capsule traffic — the sage-mcp broker's `astrid.v1.request.mcp.*` front door, and any future capsule-to-capsule request topics — and warned on every payload that failed to deserialize as a `KernelRequest`. Since `KernelRequest` is `#[serde(tag = "method")]`, a payload without a `method` discriminator was never addressed to the kernel: it is now ignored at `debug` level, and the `warn` fires only when a payload *is* shaped like a kernel request (`method` present) yet fails to parse — a genuinely malformed management request. Replaces an earlier topic-specific skip with a general discriminator check. Kernel-internal; no host-ABI / WIT / IPC change. Closes #908. - **Capsule identity (IPC `source_id` / per-(capsule,topic,principal) route key) is now deterministic across restarts.** `capsule_uuid` was a per-load random `uuid::Uuid::new_v4()`, so it changed on every daemon boot and is surfaced by no command — making any confused-deputy allow-set keyed on a capsule's `source_id` (e.g. sage-mcp's `trusted_ingress_ids`) impossible to configure, which left MCP tool *calls* permanently fail-closed. It is now `uuid::Uuid::new_v5(NAMESPACE_OID, package.name)` — stable across restarts and computable offline. Derived from the capsule **name**, not its wasm content hash, on purpose: a content hash would change the identity on every capsule update, re-introducing the same churn at upgrade time (resetting `trusted_ingress` pins, route keys, and audit continuity). Threat-model-safe: `source_id` is kernel-stamped from the real publishing capsule and is never guest-settable, so a predictable value cannot be forged. Closes #910. - **The `astrid mcp serve` MCP shim now reconnects after a daemon restart instead of going stale for the life of the session.** The shim held a single long-lived `SocketClient` for its whole lifetime; when the daemon restarted — rebinding `system.sock` and rewriting the session token — the shim kept writing into a dead fd and every bridged tool call failed with `failed to publish broker request: Broken pipe` until the MCP server was manually reconnected. The shim now re-dials the live daemon (re-reading the current token, preserving the session id via the new `SocketClient::reconnect()`) and retries once when a broker publish fails. The retry is gated to *send* failures only: a failed send means the request never reached the broker, so there is no risk of double-executing a routed tool; a failure after a successful send is surfaced rather than retried. Kernel-internal; no host-ABI / WIT / IPC change. Closes #915. - **The system capsule's `list_interfaces` / `read_interface` introspection tools now find the WIT they read.** Those tools read interface contracts from `home://wit/`, but that directory was never populated — the actual WIT content lived only in the BLAKE3-keyed content store at `~/.astrid/wit/`, which no VFS scheme reaches from capsule-space (the capsule's `fs_read` grant is `home://` only). So both tools failed with "WIT directory not found". Capsule install now mirrors each capsule's content-addressed WIT into the installing principal's `home://wit/` directory, named by basename (`read_interface` rejects names containing `/`, so nested paths like `deps/astrid-contracts/astrid-contracts.wit` are flattened). The mirror is idempotent (byte-identical files are skipped) and best-effort (a mirror failure logs a warning but does not roll back an otherwise-successful install). Already-installed capsules pick this up on their next reinstall. Closes #906. ### Security - **The unwired `SecurityInterceptor` and the no-op `[security.policy]` config controls are removed, so neither the code nor the docs imply a unified runtime gate that does not exist — the canonical model is the decomposed one.** `astrid-approval`'s `SecurityInterceptor` was a complete, unit-tested type whose own doc comment called it "the single entry point for all security checks" with "all actions flow through `intercept()` before execution", yet it had **no production caller** anywhere in the kernel — the five-layer intersection flow never ran. Separately, the entire `[security.policy]` config section (`blocked_tools` plus `approval_required_tools`, `allowed_paths`/`denied_paths`, `allowed_hosts`/`denied_hosts`, `max_argument_size`, and `require_approval_for_delete`/`for_network`) was read **only** by that dead interceptor: an operator who listed a tool in `blocked_tools` or a path in `denied_paths` got no runtime effect. A control that silently does nothing is worse than an absent one because it is trusted, so both are removed — the `interceptor` module, the `SecurityPolicy`/`PolicyResult` types, the `PolicySection` config struct, their re-exports, the `[security.policy]` defaults, the env mappings, and the workspace-tighten merge rules and tests for those fields. The real containment floor is unchanged and intact: the WASM sandbox (no ambient authority), the fail-closed manifest allowlist gate on every host call (`check_file_read`/`check_net_connect`/`check_host_process`, with path-traversal and SSRF defenses), the IPC publish/subscribe ACL, principal-scoped capability tokens, and the budget ledgers — and that decomposed model is now what the `astrid-approval` docs describe. Cryptographic capability delegation and per-action attenuated tokens are marked **future**, not shipped. A new catch-all regression suite (`canonical_security_model.rs`) locks the live guarantees of every surviving mechanism — capability resource/permission/principal scoping, expiry and global revocation; allowance session/workspace/principal scoping, atomic single-use, and `..`/shell-operator rejection at the pattern layer; atomic budget reservation; and overlay-VFS principal isolation — so the removal cannot silently weaken the floor. Per-action audit coverage and the distro-blessing install-time trust model remain open follow-ups. Refs #991. - **A spawned native process can no longer read the operator's home credential stores (extends the #856 sandbox read hole).** The `host_process` sandbox masks `~/.astrid/{keys,secrets,var}`, but the operator's own credential directories under `$HOME` were still exposed by bwrap's `--ro-bind / /`: a spawned agent could read `~/.ssh` (SSH private keys), `~/.aws`, `~/.gnupg`, `~/.netrc`, `~/.docker`, `~/.kube`, and `~/.config/gcloud`. The sandbox mask now covers these credential directories too — Linux empty `--tmpfs` overlay, macOS Seatbelt deny — filtered to those that exist on disk (a missing `--tmpfs` mount point would abort the spawn). Verified on Linux (bubblewrap): `--ro-bind / /` reads a planted `~/.ssh/id_rsa`, the mask hides it while the agent's workspace stays readable. This is a credential **deny-list**, not full home isolation — hiding arbitrary sibling projects under `$HOME` is the separate allow-list change (the rest of #856), and a per-agent opt-out for an agent that legitimately needs one of these is future work. Refs #856. - **Unauthenticated (unbound) socket connections can no longer act as a named principal — they are stamped a reserved, no-capability `anonymous` identity (closes the unbound self-stamp the per-connection enforcement left).** The per-connection enforcement (see Added) stamps the kernel-verified principal for connections that authenticate, but an *unbound* connection's `publish-as` still fell back to the principal it *claimed* — a migration convenience that let an unauthenticated local socket client name (and act as) `default` or any principal. The uplink's `publish-as` now derives the principal entirely from the connection: a kernel-verified binding stamps that principal; an unbound connection stamps the reserved `anonymous` identity, which has **no profile**, so every capability check fails closed (an unproven claim earns no privilege). `anonymous` is reserved — `agent create` refuses the name (alongside `default`) — so it can never be created or granted capabilities. The capsule-supplied name is ignored entirely. No live consumer relies on the old fallback: the operator CLI and every agent authenticate (their ed25519 keypair is auto-minted) so they are bound and unaffected; the HTTP gateway publishes on the in-process event bus (it bypasses the capsule-cli proxy) and is untouched; the only thing the old path served was the self-stamp itself. No WIT change. Refs #45/#852. - **A spawned native process can no longer read another principal's private keys, the secret store, or the kernel state DB (the #856 sandbox read hole).** The host-process sandbox wrapped agent-run native tools (e.g. `claude -p`) on Linux with bwrap's `--ro-bind / /`, which mounts the *entire* host filesystem read-only — so any spawned process could read `~/.astrid/keys/.key` (→ forge the handshake and impersonate any principal), `~/.astrid/secrets/` (→ steal stored credentials), and `~/.astrid/var/state.db` (→ read every principal's KV). `SandboxCommand::wrap_with_injections` now overlays an empty `--tmpfs` on each of those three subpaths on Linux, and emits the equivalent `(deny file-read*)` rules on macOS Seatbelt (already `(deny default)` outside its allowlist, which excludes `~/.astrid` — so these are belt-and-suspenders that still hold if the allowlist ever widens). `~/.astrid/run/` (socket + token) and `~/.astrid/etc/` (config) stay reachable so a spawned agent can still reach the daemon; masking the socket too — so a sandboxed agent cannot self-stamp via a fresh unbound connection — lands together with the fd-passed pre-authenticated connection in #45/#852. Fail-secure: a spawn whose Astrid home cannot be resolved is refused rather than run unmasked. This mirrors the MCP-server spawn path, which already masks the whole home via `with_hidden`. Verified on Linux (bubblewrap in a container): `--ro-bind / /` reads a planted key, `--tmpfs` masks it while `run/` stays readable. (The 400-line sandbox unit-test module moved to `sandbox/tests.rs` so `mod.rs` stays under the file-size cap.) Closes #856. - **New agents no longer inherit the `default` principal's env, KV, and — critically — secrets by default; inheritance is now opt-in.** `agent.create` unconditionally copied the `default` principal's `.config/env/` JSON, every per-capsule KV namespace, **and every per-capsule secret file / API key** into every newly created principal, with no way to opt out. A freshly minted, supposedly low-trust sub-agent therefore started life holding the root operator's credentials — a silent credential leak into every created agent and the opposite of least privilege. Inheritance is now **opt-in**: by default a new agent inherits **nothing** (a clean, least-privilege home tree), and `AgentCreate` gains an `inherit_from: Option` field (`#[serde(default)]`, so older serialized requests deserialize as the secure `None`). When the operator names a source via `astrid agent create --inherit-from `, the new agent receives a **full copy** (env JSON + KV namespaces + secret files) from THAT named principal — not from `default` unless `default` is named explicitly. The source is validated before any copy runs: it must already exist (a non-existent source is rejected, never a silent no-op into an empty agent) and must not be the new principal itself. The HTTP management API constructs `inherit_from: None` (non-inheriting); an API-level inherit option is a follow-up. The existing `security_event` audit log on every secret copy is preserved. Closes #923. - **`astrid:process@1.0.0` spawn now supports host-verified, read-only file injection — an agent-neutral way to hand a spawned child unmodifiable bytes.** `spawn-request` gains `file-injections: list` (`{content, placement}`), honored by all three tiers (`spawn`, `spawn-background`, `spawn-persistent`). The capsule supplies the bytes plus how the child should find them; the host owns placement, integrity, and exposure. The motivating consumer is un-overridable per-spawn governance (a supervised agent reads a policy file a prompt-injected session cannot rewrite), but the bytes are content-opaque — the host never parses, validates, or filters them. The write-protection invariant is the whole point: the bytes the child reads MUST NOT be writable by **(a)** the child or any subprocess it spawns, **nor (b)** the spawning principal's capsule `fs_*` surface (which runs in capsule space, OUTSIDE the child's OS sandbox). The host therefore SNAPSHOTS `content` to a host-owned path outside every VFS mount (a private `TempDir`), BLAKE3-hashes the snapshot, VERIFIES the materialized bytes against the pin (closing the copy→expose TOCTOU), records the hash in the spawn audit, and exposes only that host-owned snapshot. Two placement modes, both exposing the same verified bytes read-only: **`env-pointer(env-var)`** materializes the snapshot at a host-owned path, exposes it read-only (Linux `--ro-bind P P` inside the `bwrap` namespace; macOS Seatbelt `file-read*` allow plus a trailing `file-write*` deny on the literal), and sets the named env var on the child to that path — OS-agnostic (Linux + macOS), for agents whose un-overridable tier is reachable via an env-redirected file (Claude `CLAUDE_CODE_MANAGED_SETTINGS_PATH`, Gemini `GEMINI_CLI_SYSTEM_SETTINGS_PATH`); **`fixed-path(path)`** `--ro-bind`s the snapshot at an absolute in-sandbox path — Linux-only (the bwrap remap), rejected on macOS with `invalid-input` (no remap; a host write to a caller-named path would be an escalation), for agents whose enforced tier is a fixed path with no env redirect (Codex `/etc/codex/requirements.toml`). On a platform without an OS sandbox a non-empty `file-injections` is refused (fail-secure). Because the host owns the materialized path in both modes, it never writes to a caller-named host path. No new capability — injection rides `host_process`, into the caller's own child only, and is a strict RESTRICTION surface. Empty / absent `file-injections` is a complete no-op (zero overhead). Closes #881. - **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. ## [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: `"" = { wit = "", handler = "", priority = }`. 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] "" = { wit = "" }`; an ACL-only subscribe pattern (no handler, e.g. an uplink proxy) becomes `[subscribe] "" = { 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 --principal ` global flag (and `ASTRID_PRINCIPAL` env) — an explicit, validated, process-wide CLI principal.** The `astrid` CLI now resolves ONE principal at startup and stamps it on every IPC message it sends over the uplink socket, so the whole process attributes to a single identity. This is the client half of per-connection principal binding (#852): the uplink proxy pins the first principal it sees on a connection and DROPS any message stamped with a different one, so a mixed stream silently loses messages — the CLI must therefore present one consistent principal. Precedence is explicit `--principal` flag > `ASTRID_PRINCIPAL` env > the operator's active-agent context (`cli-context.toml`, set by `astrid agent use`) > the `default` principal; the value is validated through `PrincipalId::new` (1-64 chars of `[a-zA-Z0-9_-]`) ONCE, before any socket connection, exiting with a one-line error naming the constraint on bad input. With no flag and no env var the behaviour is unchanged for existing users (admin commands keep their active-agent attribution; messages that previously omitted a principal now carry the resolved one). The shared `SocketClient` is now bound to a principal at connect time and stamps it on any outbound message that does not already carry an explicit one — so `mcp serve` / `astrid-emit` `publish-as` attribution is left untouched, and the gateway's verified-bearer stamping is unaffected. The `mcp serve --principal` subcommand flag is retained as an explicit per-invocation override and now falls back to the process principal. Refs #852. - **`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.` 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::{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`), `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`: 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`, 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` — 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 ` (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` + `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 ` or `--keypair ` (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/.{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/.wasm` / `wit/.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/`, 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::()` means the `ConnectInfo` 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/.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=`) 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>` 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` 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`) 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>>>` (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` 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>>` 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//.config/env/.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 `/.config/env/.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 `/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` 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::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` 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>` 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 = ".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` 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>` 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` 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 "*"` 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` 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` to `Arc>`** (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` 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`). `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`.** 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`) — 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`, 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) -> list`. Now `func(action: string, payload: list) -> 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 ` 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 ` 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/.env.json` → `home//.config/env/...`), per-capsule KV namespaces (`default:capsule::*` → `:capsule::*`), and per-capsule secret files (`secrets/default//` → `secrets///`) 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: