Introduction
If the agent lives inside the labyrinth, you are one of the people who build its walls. Build them wrong and the whole promise leaks. This handbook is how the walls get built and kept.
This handbook is for people working on Unicity Astrid OS, not with it. If you are building a capsule, the front-door documentation and The Unicity Astrid OS Book are your references. If you are changing the kernel, the SDK, a contract surface, or a reference capsule, read this first.
Unicity Astrid OS is a polyrepo. The kernel, the SDK, the RFCs, and each capsule are their own git
repositories under the unicity-astrid organization. Working on it means knowing which repo
owns what, what may never cross the kernel boundary, when a change needs an RFC, and how
releases are cut. This handbook is the operational law for that work.
Three rules that govern everything
- The kernel is dumb. The kernel routes events, enforces capabilities, and runs the sandbox. It holds no business logic. If the kernel does not route, gate, or validate a thing, that thing belongs in capsule-space IPC, not in a kernel type. This is not a style preference. It is the architecture, and violating it is how the system rots.
- RFCs are for contract changes. An RFC is required when you change the surface between the kernel and user space: the host ABI, the IPC schema, the capability model, the manifest schema, VFS semantics, capsule interface standards, or the SDK public API. Kernel-internal changes that preserve the guest-visible contract do not need one. Know the difference before you open a PR.
- Ground in the code. Documentation, including older READMEs in this very repository, drifts from the implementation. When the two disagree, the code wins, and the documentation is the bug. Verify against the source, not against prose.
How releases and reviews work
Version bumps are always a separate pull request. Every core PR closes a GitHub issue. Commits are GPG-signed. Security-critical crates carry extra review. After any non-trivial change, do an adversarial self-review before requesting review: ask how this fails at three in the morning in production, and what invariant it could violate. The chapters that follow make each of these concrete.
Working on Astrid: The Polyrepo and Git Workflow
Astrid is not a monorepo. The kernel, the SDK, the RFCs, and every capsule each live in their own
GitHub repository under the unicity-astrid organization. Your local checkout layers all of these
as independent git repos under a single parent directory. Understanding that layout is the
prerequisite for every workflow decision that follows.
The Polyrepo Map
Your working directory contains these top-level components, each a standalone git repo:
| Local path | GitHub repo | Current version | Purpose |
|---|---|---|---|
core/ | unicity-astrid/astrid | 0.7.0 | Kernel, daemon, CLI, all crates/astrid-* |
sdk-rust/ | unicity-astrid/sdk-rust | 0.7.0 | astrid-sdk, astrid-sdk-macros, astrid-sys |
astrid-rfcs/ | unicity-astrid/rfcs | n/a | Design proposals for the kernel-to-user-space contract |
capsules/astrid-capsule-agents/ | unicity-astrid/capsule-agents | - | Agent orchestration capsule |
capsules/astrid-capsule-cli/ | unicity-astrid/capsule-cli | 0.1.1 | Unix socket bridge for the CLI frontend |
capsules/astrid-capsule-fs/ | unicity-astrid/capsule-fs | - | Filesystem host-function capsule |
capsules/astrid-capsule-http/ | unicity-astrid/capsule-http | - | Outbound HTTP capsule |
capsules/astrid-capsule-identity/ | unicity-astrid/capsule-identity | - | Authentication and principal management |
capsules/astrid-capsule-memory/ | unicity-astrid/capsule-memory | - | Persistent agent memory |
capsules/astrid-capsule-openai/ | unicity-astrid/capsule-openai | - | OpenAI provider capsule |
capsules/astrid-capsule-openai-compat/ | unicity-astrid/capsule-openai-compat | - | OpenAI-compatible inference routing |
capsules/astrid-capsule-prompt-builder/ | unicity-astrid/capsule-prompt-builder | - | Prompt assembly |
capsules/astrid-capsule-react/ | unicity-astrid/capsule-react | - | Reactive event handling |
capsules/astrid-capsule-registry/ | unicity-astrid/capsule-registry | - | Model registry and active-model selection |
capsules/astrid-capsule-router/ | unicity-astrid/capsule-router | - | IPC topic routing |
capsules/astrid-capsule-session/ | unicity-astrid/capsule-session | - | Session lifecycle |
capsules/astrid-capsule-shell/ | unicity-astrid/capsule-shell | - | Shell command execution (host_process carve-out) |
capsules/astrid-capsule-skills/ | unicity-astrid/capsule-skills | - | Skill loader |
capsules/astrid-capsule-system/ | unicity-astrid/capsule-system | - | System-level capsule |
capsules/astrid-capsule-users/ | unicity-astrid/capsule-users | - | User management capsule |
capsules/astrid-capsule-context-engine/ | unicity-astrid/capsule-context-engine | - | Context window management |
capsules/astrid-capsule-hook-bridge/ | unicity-astrid/capsule-hook-bridge | - | Hook-bridge glue |
Naming convention
Every capsule directory is named astrid-capsule-<name>. The GitHub repo drops the astrid-capsule-
prefix: capsule-<name>. The git remote inside each capsule directory reflects the shortened form.
When you need to push, the HTTPS URL is https://github.com/unicity-astrid/capsule-<name>.git.
You can verify any remote with git remote get-url origin from inside the capsule directory:
cd capsules/astrid-capsule-skills
git remote get-url origin
# https://github.com/unicity-astrid/capsule-skills.git
Each Component Is Its Own Git Repo
This is the most important operational fact. git and gh commands resolve against the repo whose
working tree contains your current directory. You must cd into the target component before running
any git command. A git status at the polyrepo root shows the parent worktree’s view, not the
kernel’s or the SDK’s.
# Wrong: operates on the parent worktree, not the kernel
git log --oneline -5
# Right: operates on the kernel repo
cd core
git log --oneline -5
The kernel workspace (core/Cargo.toml) lists 27 member crates from crates/astrid-approval
through crates/astrid-workspace. The SDK workspace (sdk-rust/Cargo.toml) lists three:
astrid-sdk, astrid-sdk-macros, and astrid-sys. These are all in the same single Git repo per
workspace. The capsules are each their own Git repo with their own Cargo.toml, Cargo.lock, and
rust-toolchain.toml.
Branching from origin/main
Local main is frequently stale. Always branch from the remote:
cd core # or sdk-rust, astrid-rfcs, capsules/astrid-capsule-<name>
git fetch origin
git checkout -b feat/your-feature origin/main
Use descriptive, Conventional-Commits-style branch names:
feat/add-authfix/timeout-bugrefactor/config-loader
Never commit directly to main. Every change, however small, goes on a branch.
For read-only work (exploration, research, answering a question) switch to main and pull:
cd core
git checkout main && git pull origin main
Commit Messages
Astrid follows Conventional Commits. The format is:
<type>(<optional scope>)<optional !>: <description>
The scope is typically the crate name without the astrid- prefix when that is unambiguous, or the
full crate name for clarity. Examples from the kernel history:
feat(emit): astrid-emit, agent-agnostic stdio→bus hook pipe
fix(events): yield more cooperatively under a broadcast storm
feat(cli): astrid quota show usage-vs-budget + cpu ceiling line
feat(runtime): async per-capsule WASM + instance pool + per-principal isolation
ci: stop building the openclaw QuickJS kernel in the Test job
Common types: feat, fix, chore, docs, refactor, test, ci, perf, security.
Breaking changes append ! after the type/scope and carry a BREAKING CHANGE: footer.
GPG-Signed Commits
All commits must be GPG-signed. If git commit returns gpg failed to sign the data, the sandbox
is blocking GPG. Retry the commit with dangerouslyDisableSandbox: true.
Verify signing before pushing:
git log --format="%H %G?" -5
The G? field should be G (good signature) on every real commit. Never push a commit showing N
(no signature) unless you are on a scratch branch that will be squash-deleted.
Pushing via HTTPS
All remotes use SSH (git@github.com:...) but SSH keys are not available in this environment.
Push via HTTPS instead. The mapping is straightforward:
# Kernel
cd core
git push -u https://github.com/unicity-astrid/astrid.git <branch>
# SDK
cd sdk-rust
git push -u https://github.com/unicity-astrid/sdk-rust.git <branch>
# RFCs
cd astrid-rfcs
git push -u https://github.com/unicity-astrid/rfcs.git <branch>
# A capsule (substitute the real short name)
cd capsules/astrid-capsule-cli
git push -u https://github.com/unicity-astrid/capsule-cli.git <branch>
The repo name on GitHub always drops the astrid-capsule- prefix for capsules. The kernel and SDK
repos keep their full names.
The Issue-First Rule for Core PRs
Every pull request to the kernel repo must close a GitHub issue. The CI linked-issue check
(core/.github/workflows/pr-checks.yml:200) fails if the PR body does not contain a Closes #N
reference or a linked issue via the GitHub sidebar. The PR template (core/.github/pull_request_template.md)
enforces this with a checklist item and a required ## Linked Issue section. CI rejects the PR if
any required section is empty.
If no issue exists for your work, create one before opening the PR. The CONTRIBUTING.md is explicit: unsolicited PRs with no linked issue will be closed.
The contributor tier system (core/.github/contributors.yml, enforced by the contributor-gate CI
job) also gates who can touch which crates:
- New contributors require a maintainer to add
newcomer-approvedbefore CI proceeds. - Astrinauts can work on non-core crates: CLI, SDK, capsules, docs, and tests. They cannot
modify
crates/astrid-crypto,crates/astrid-capabilities,crates/astrid-audit,crates/astrid-approval,crates/astrid-vfs,crates/astrid-storage,crates/astrid-sys, orcrates/astrid-core. They also cannot modifycrates/astrid-kernel,crates/astrid-events,crates/astrid-hooks,crates/astrid-config, orcrates/astrid-mcp. Both sets produce a hard CI error for Astrinauts. - Core contributors can touch those crates but security-critical paths (
astrid-crypto,astrid-capabilities,astrid-audit,astrid-approval,astrid-vfs,astrid-storage,astrid-sys,astrid-core) trigger a warning requiring maintainer co-review. - Maintainers have full access.
The capsule repos and the RFC repo do not run the contributor-gate check. The issue-first rule
still applies in practice, but the CI enforcement lives only in the kernel repo.
Filling In the PR Template
The pr-checks.yml workflow validates the PR body on every open, edit, reopen, and synchronize
event. All four sections must contain real content beyond the template placeholder comments:
## Linked Issue
Closes #843
## Summary
Brief description of what the PR does and why.
## Changes
- Bullet list of notable changes.
## Test Plan
### Automated
- [ ] `cargo test --workspace` passes
- [ ] No new clippy warnings
A missing or blank section causes the template job to fail and blocks merge.
In addition, the file-size job rejects any source file that your PR pushes over 1000 lines if it
was under 1000 lines on main. A maintainer can override with the large-file-ok label.
Version Bumps Are Separate PRs
A chore: release X.Y.Z commit is always its own pull request. Never include a version bump in a
feature or fix PR. This applies to the kernel, SDK, and every capsule equally.
Pull Request Creation Is User-Initiated
Do not run gh pr create without being explicitly asked. Commit and push freely when work is
ready, but PR creation is the user’s decision, not the agent’s. PRs generate notifications and
signal intent to reviewers. Opening one without being asked makes that decision on someone else’s
behalf.
If gh pr create fails with you must first push the current branch to a remote, supply the
--head flag:
gh pr create --head <branch-name> --title "..." --body "..."
Building Across the Polyrepo
Build targets differ by component:
# Kernel (host target, Rust 1.95)
cd core && cargo build --workspace
# SDK (host target, Rust 1.94)
cd sdk-rust && cargo build --workspace
# One capsule (wasm32-unknown-unknown, Rust 1.94 per its rust-toolchain.toml)
cd capsules/astrid-capsule-cli && cargo build
# All capsules
for d in capsules/astrid-capsule-*; do (cd "$d" && cargo build); done
Capsules target wasm32-unknown-unknown. Their rust-toolchain.toml pins the toolchain and
declares the target. Do not pass --target manually; each capsule’s .cargo/config.toml selects
the target for you. A capsule built under the kernel’s cargo (a different toolchain, different
target) will not behave correctly.
The Rust edition across the kernel and SDK is 2024. The kernel MSRV is 1.95; the SDK MSRV is 1.94.
The RFC Repo
astrid-rfcs/ (unicity-astrid/rfcs) holds design proposals for the kernel-to-user-space contract.
You need an RFC for:
- Adding, removing, or changing a host function in
astrid-sys - Changing IPC topic conventions or payload schemas
- Modifying the capability token format or validation semantics
- Changing
Capsule.tomlmanifest schema or dependency resolution - Changing VFS path resolution rules or overlay behavior
- Defining a new capsule interface standard
- Breaking changes to
astrid-sdkpublic API
You do not need an RFC for bug fixes, internal refactors that preserve external behavior, documentation improvements, performance optimizations with no observable behavioral change, adding a new capsule that implements an existing interface, or kernel-internal routing and storage changes that keep the guest-visible WIT intact.
To submit an RFC:
cd astrid-rfcs
git fetch origin
git checkout -b rfc/my-feature origin/main
cp 0000-template.md text/0000-my-feature.md
# fill in the RFC
git add text/0000-my-feature.md
git commit -m "rfc: my-feature"
git push -u https://github.com/unicity-astrid/rfcs.git rfc/my-feature
Do not assign an RFC number. A maintainer assigns the next sequential number and renames the file on merge. Discussion happens on the PR.
Scope Discipline
Stay inside the named repo. If a task in capsules/astrid-capsule-cli requires touching a kernel
type, that is a signal to stop, flag the dependency, and not branch across repos to satisfy it. Any
change to the kernel-to-user-space contract surface requires an RFC first. Changes to host
functions, IPC payloads, capability tokens, or the manifest schema that you make inline in a capsule
PR without a corresponding RFC will be rejected.
Checklist Before Pushing
cdinto the right repo.- Branch from
origin/main, not from a stale localmain. - Branch name follows the
feat/,fix/,refactor/,docs/convention. - Commits are GPG-signed. Verify with
git log --format="%G?" -1. cargo test --workspacepasses (orcargo buildfor capsules, which lack a workspace test runner).- No new Clippy warnings.
CHANGELOG.mdupdated under[Unreleased].- Core PR: a GitHub issue exists and the PR body contains
Closes #N. - Version bump (if any) is on its own separate PR.
- Push via HTTPS to the correct GitHub repo.
See also
The Kernel-Is-Dumb Law
The Astrid kernel has one job: route IPC events, enforce capabilities, and manage the WASM sandbox. It contains no business logic, no cognitive loops, no LLM glue, and no domain intelligence of any kind. That constraint is not an aspiration or a soft guideline. It is a hard architectural law that shapes every contribution to core/.
The module-level doc in core/crates/astrid-kernel/src/lib.rs states it plainly:
#![allow(unused)]
fn main() {
//! The Kernel is a pure, decentralized WASM runner. It contains no business
//! logic, no cognitive loops, and no network servers. Its sole responsibility
//! is to instantiate `astrid_events::EventBus`, load `.capsule` files into
//! the Wasmtime sandbox, and route IPC bytes between them.
}
This page explains what the law means, why it exists, what it permits, what it forbids, and how to tell the difference in practice.
The Rule
If the kernel does not route, gate, or validate it, it belongs in capsule-space IPC.
Three activities are the kernel’s exclusive domain:
-
Routing. The kernel receives events on
EventBusand delivers them to subscribers. The router inkernel_router/mod.rslistens onastrid.v1.request.*and dispatchesKernelRequestvariants. The admin router inkernel_router/admin/mod.rsdoes the same forastrid.v1.admin.*. Both are bus subscribers, not HTTP handlers or AI processors. -
Gating. The kernel enforces capability checks before any request reaches a handler. Every
KernelRequestvariant maps to a capability string throughrequired_capability(kernel_router/mod.rs:488). EveryAdminRequestKindmaps throughrequired_capability_for_admin_request(kernel_router/admin/mod.rs:190). The preamble callsauthorize_request, which readsPrincipalProfile, resolves group config from anArcSwap<GroupConfig>, and runsCapabilityCheck::require. A missing or disabled principal is fail-closed: the check returnsPermissionErrorand the request is rejected before any handler runs. -
Validating. The kernel validates the wire types it owns:
PrincipalId(alphanumeric, hyphen, underscore, 1-64 chars),Quotas(upper-bounded integers), capability grammar strings, profile TOML (strictdeny_unknown_fields). None of this validation is semantic. The kernel does not know whether a quota value is right for a particular agent’s use case. It only knows whether the value is well-formed.
Everything else is capsule business.
What the Kernel Struct Looks Like
Kernel (core/crates/astrid-kernel/src/lib.rs:44) carries exactly the fields needed for its three jobs:
#![allow(unused)]
fn main() {
pub struct Kernel {
pub session_id: SessionId,
pub event_bus: Arc<EventBus>,
pub capsules: Arc<RwLock<CapsuleRegistry>>,
pub mcp: SecureMcpClient,
pub capabilities: Arc<CapabilityStore>,
pub vfs: Arc<dyn Vfs>,
pub overlay_registry: Arc<OverlayVfsRegistry>,
pub vfs_root_handle: DirHandle,
pub workspace_root: PathBuf,
pub home_root: Option<PathBuf>,
pub cli_socket_listener: Option<Arc<Mutex<tokio::net::UnixListener>>>,
singleton_lock: Option<std::fs::File>, // private; held for daemon lifetime, Drop releases flock
pub kv: Arc<SurrealKvStore>,
pub audit_log: Arc<AuditLog>,
active_connections: DashMap<PrincipalId, AtomicUsize>,
fuel_ledger: FuelLedger,
fuel_rate: FuelRateLimiter,
pub ephemeral: AtomicBool,
pub boot_time: Instant,
pub shutdown_tx: watch::Sender<bool>,
pub session_token: Arc<SessionToken>,
token_path: PathBuf,
pub allowance_store: Arc<AllowanceStore>,
identity_store: Arc<dyn IdentityStore>,
pub(crate) profile_cache: Arc<PrincipalProfileCache>,
pub(crate) groups: Arc<ArcSwap<GroupConfig>>,
pub(crate) astrid_home: AstridHome,
pub(crate) admin_write_lock: Mutex<()>,
}
}
Every field serves routing, gating, or validation. event_bus is the backbone. capsules is the loaded WASM process table. capabilities and profile_cache are the gate. audit_log is the tamper-evident record of every gate decision. fuel_ledger and fuel_rate are per-principal CPU metering. active_connections counts clients for idle-shutdown logic. There is no llm_client, no prompt_builder, no tool_registry, no conversation_history.
What Never Goes in the Kernel
Domain-specific fields on Kernel or kernel types. If you find yourself wanting to add a field that carries application state rather than routing or security state, stop. That field belongs in a capsule’s KV namespace.
Business logic in KernelRequest variants. The current variants are install, approve-capability, list-capsules, reload-capsules, get-commands, get-metadata, shutdown, and get-status (core/crates/astrid-core/src/kernel_api.rs:25). They are lifecycle and introspection primitives. Adding a variant like RunPrompt or BuildContext would be a hard violation.
Prompt assembly, model selection, tool dispatch, or session management. These are capsule concerns. capsule-session, capsule-prompt-builder, capsule-react, and capsule-skills exist precisely so these concerns have a home outside the kernel.
LLM provider integration. The astrid-capsule-anthropic directory exists locally as a stale remnant of a capsule that was deleted. The kernel contains no provider logic, and capsule-side provider integration does not belong in core/.
Inference about meaning. The kernel routes bytes. It never looks inside an IPC payload to decide what it means for the application. The only payload inspection that happens is capability-checking: does the caller hold the required string? Even that check is mechanical, resolved entirely from profile.toml and groups.toml.
The IPC Boundary
Capsules communicate exclusively through IPC events. Each capsule declares [imports] and [exports] in Capsule.toml. The kernel’s EventDispatcher routes events to capsule interceptors. The kernel does not know and does not care what the interceptor does with the event.
The AstridEvent::Ipc variant carries every capsule-to-capsule message:
#![allow(unused)]
fn main() {
Ipc {
metadata: EventMetadata,
message: IpcMessage,
}
}
IpcMessage carries a topic string, an IpcPayload (typically RawJson), a sender source_id (UUID), and an optional principal. The kernel delivers it. The capsule decodes it, acts on it, and publishes a response. The kernel never decodes the JSON inside RawJson.
This design has a concrete consequence: if you want to add a new capability to the system, you add a capsule that subscribes to a new topic, not a new KernelRequest variant. Adding a topic is a capsule change. Adding a socket topic is also a capsule change: the Unix socket is owned by capsule-cli, which runs an uplink with a hardcoded allowlist, not by the kernel.
The Capability-Enforcement Preamble
The one place the kernel inspects request meaning is the capability gate. The pattern is identical across both routers.
For KernelRequest:
#![allow(unused)]
fn main() {
let method = kernel_request_method(&req);
let scope = resolve_scope(&req, &caller);
let required_cap = required_capability(&req, scope);
match authorize_request(kernel, &caller, required_cap) {
Ok(()) => { /* audit + continue */ },
Err(e) => { /* audit + reject */ return; },
}
}
For AdminRequestKind:
#![allow(unused)]
fn main() {
let scope = resolve_admin_scope(&req.kind, &caller);
let required_cap = required_capability_for_admin_request(&req.kind, scope);
match authorize_request(kernel, &caller, required_cap) {
Ok(()) => { /* audit + continue */ },
Err(e) => { /* audit + reject */ return; },
}
}
authorize_request resolves the caller’s PrincipalProfile from disk via profile_cache, checks the enabled flag (fail-closed on false), loads GroupConfig from the ArcSwap (a lock-free Arc clone), and runs CapabilityCheck::require. Every path produces an AuditAction::AdminRequest entry on both allow and deny, written to the chain-linked audit log and broadcast on astrid.v1.audit.entry.
The kernel’s role in authorization ends here. It does not know why a principal needs system:shutdown or self:capsule:install. It only knows whether they have it.
The PrincipalProfile Is Not a Session Object
PrincipalProfile (core/crates/astrid-core/src/profile/mod.rs:112) is static policy. It carries:
enabled: master gategroups: group memberships resolved to capabilities viaGroupConfiggrantsandrevokes: per-principal overridesauth: accepted authentication methods and bound public keysnetwork: egress allowlistprocess: spawn allowlistquotas:Quotasstruct (memory, timeout, IPC throughput, background processes, storage, CPU fuel rate)
What it does not carry: conversation history, selected model, session preferences, active tool list, anything that varies per-request or per-session rather than per-principal-policy. Those belong in capsule KV or capsule-local state. The kernel reads PrincipalProfile at request time for the capability gate. It does not maintain a mutable runtime view of an agent’s cognitive state.
Adding a new field to PrincipalProfile is only correct when the field represents a policy decision that the kernel actually enforces (routing, gating, or resource validation). A field the kernel reads but does not enforce is dead weight in a security-sensitive type. A field the kernel cannot interpret belongs in a capsule manifest or capsule KV.
Worked Examples
Correct: adding per-principal CPU budget enforcement.
The fuel ledger (fuel_ledger: FuelLedger) and rate limiter (fuel_rate: FuelRateLimiter) fields on Kernel are legitimate kernel state. They gate WASM execution at the invoke_interceptor boundary. The kernel is the only actor that can enforce WASM execution limits because WASM runs inside the kernel’s Wasmtime instance. The max_cpu_fuel_per_sec field on Quotas is a policy value the kernel reads to configure enforcement. This is routing and gating work.
Incorrect: adding a preferred LLM model to PrincipalProfile.
A preferred_model: Option<String> field on PrincipalProfile or Quotas would be domain configuration the kernel cannot interpret. The kernel cannot validate whether "claude-sonnet-4-6" is a real model. It cannot enforce that the capsule uses it. Model selection belongs in capsule-session or capsule-react, read from capsule KV or capsule manifest config, delivered to the LLM capsule via IPC.
Correct: adding a QuotaGet admin request variant.
AdminRequestKind::QuotaGet { principal: PrincipalId } is a read of the policy values the kernel owns. The handler reads profile.toml for the target principal and returns the Quotas struct. That is introspection of kernel-managed policy. The capability gate (self:quota:get or quota:get) and audit trail are already wired.
Incorrect: adding a RunTool admin request variant.
A variant that dispatches a tool call through the kernel would make the kernel an execution engine rather than a router. Tool dispatch belongs in capsule-space IPC: an agent publishes to a topic, a capsule intercepts, the capsule invokes the tool, the capsule publishes the result. The kernel never sees the tool arguments or result.
Correct: the admin router dispatching group and agent management.
AgentCreate, AgentModify, CapsGrant, CapsRevoke, GroupCreate, and similar variants write profile.toml and groups.toml. These files are the kernel’s policy substrate. Managing them is the kernel’s job. Every mutation goes through admin_write_lock, updates the ArcSwap<GroupConfig> atomically, and invalidates the PrincipalProfileCache entry. The kernel enforces these values at runtime; it must also own writing them.
Incorrect: adding a field to Kernel for conversation context.
A context_store: Arc<ContextStore> field on Kernel would make the kernel responsible for conversation state. That is capsule-context-engine’s job. The capsule publishes context read/write events on the bus. Other capsules subscribe. The kernel routes. Nothing in Kernel should know that prompts exist.
The Capsule-Manifest Boundary
Capsule manifests (Capsule.toml) declare [imports] and [exports] that define the IPC surface a capsule participates in. The kernel reads manifests only for:
- Topological sort order at load time (
toposort_manifests) - Uplink detection (the
capabilities.uplinkflag determines load order) - Import/export validation at boot (every required import must have a matching export)
- Interceptor subscription setup (
EventDispatcherwires manifest interceptor patterns to bus subscriptions)
The kernel does not interpret manifest fields as application configuration. A manifest entry that says a capsule handles prompt.v1.request.* is meaningful to the dispatcher as a topic pattern. It is not meaningful to the kernel as a description of what “prompts” are.
Capsule manifests are untrusted input. Operator-only fields that affect cross-principal exposure require skip_deserializing and parser-isolation tests. The kernel’s manifest parser rejects uplinks with [imports] sections as a defense-in-depth check (lib.rs:576), but the primary gate is capabilities.uplink validation at discovery time, not semantic understanding of what the imports mean.
Uplinks Are Not Kernel Modules
Uplinks (CLI, web, Discord) are protocol clients that connect to the Unix socket. The socket is owned by capsule-cli, an uplink capsule with net_bind capability and a hardcoded topic allowlist. The kernel’s role ends at the UnixListener (cli_socket_listener: Option<Arc<Mutex<UnixListener>>>), which is passed as context to the capsule at load time. The capsule performs the handshake, authenticates the session token, and bridges traffic onto the bus.
Adding socket handling logic to the kernel, or adding a new socket topic by changing a kernel constant, is a violation. Adding a socket topic means changing the capsule’s allowlist. That is a capsule change.
The Test Harness Confirms the Boundary
The test_kernel_with_home constructor (lib.rs:925) builds a Kernel with exactly the fields admin handlers touch: event_bus, session_id, audit_log, profile_cache, identity_store, groups, astrid_home, admin_write_lock, and the shared allowance, capability, and KV store handles. It skips socket binding, MCP init, token generation, and capsule discovery. The test harness compiles and passes without any application-level state. That is the surface area the kernel actually needs.
Checklist for Kernel Contributors
Before adding any code to core/crates/astrid-kernel or core/crates/astrid-core:
- Does the kernel route, gate, or validate this? If yes, proceed.
- Does the kernel own the state this reads or writes? Policy substrate (profiles, groups, capabilities, audit log) is yes. Application state (conversations, model selections, tool results) is no.
- Would removing this field break routing, capability enforcement, or the WASM sandbox? If no, the field does not belong in
Kernel. - Does this add a
KernelRequestvariant that performs application work rather than lifecycle or introspection? If yes, it is a capsule IPC topic instead. - Is this a new socket topic? It belongs in
capsule-cli’s allowlist, not in the kernel. - Does this change the kernel-to-user-space contract (host ABI, IPC protocol, capability model, manifest schema, VFS semantics, SDK public API)? If yes, write an RFC before touching code.
See also
The RFC Trigger
An RFC is required for any change to the contract surface between the kernel and user-space. It is not required for anything else. Getting this boundary right matters because RFCs gate ecosystem stability: every capsule author, every SDK consumer, and every third-party tool depends on the surfaces they describe. Changes inside those surfaces are implementation details and move freely. Changes to those surfaces affect everyone on both sides of the boundary and need a specification that lives outside any single crate.
The authoritative statement lives in astrid-rfcs/README.md:
RFCs govern any substantial change to the contract surface between the kernel and user-space: the host ABI, IPC protocol, capability model, manifest schema, VFS semantics, capsule interface standards, and SDK public API.
RFC 0001 (text/0001-rfc-process.md) repeats and expands this into a scope table. Both documents are the ground truth. This page is a reading guide, grounded in the actual code, not a restatement of the documents.
The seven in-scope areas
1. Host ABI
The host ABI is the set of WIT-typed functions the kernel exposes to capsules through the astrid-sys bindings. Every function in wit/host/ is part of this surface.
The versioned WIT files are the canonical specification. Each carries an explicit freeze notice:
// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes
// ship as a new file at a new version path; never edit this file.
The current packages and a representative function from each:
| WIT package | File | Representative export |
|---|---|---|
astrid:sys@1.0.0 | wit/host/sys@1.0.0.wit | random-bytes, log, get-caller |
astrid:ipc@1.0.0 | wit/host/ipc@1.0.0.wit | publish, subscribe, publish-as |
astrid:fs@1.0.0 | wit/host/fs@1.0.0.wit | fs-open, read-file, write-file |
astrid:kv@1.0.0 | wit/host/kv@1.0.0.wit | kv-get, kv-set, kv-cas |
astrid:approval@1.0.0 | wit/host/approval@1.0.0.wit | request-approval |
astrid:identity@1.0.0 | wit/host/identity@1.0.0.wit | identity-resolve, identity-link |
astrid:net@1.0.0 | wit/host/net@1.0.0.wit | TCP, UDP, Unix socket access |
astrid:process@1.0.0 | wit/host/process@1.0.0.wit | spawn (desktop-kernel only) |
astrid:uplink@1.0.0 | wit/host/uplink@1.0.0.wit | uplink-register, uplink-send |
astrid:elicit@1.0.0 | wit/host/elicit@1.0.0.wit | Interactive install-time prompting |
astrid:http@1.0.0 | wit/host/http@1.0.0.wit | Outbound HTTP |
astrid:io@1.0.0 | wit/host/io@1.0.0.wit | poll, input-stream, output-stream |
astrid:guest@1.0.0 | wit/host/guest@1.0.0.wit | astrid-hook-trigger, run, astrid-install |
Adding a function, removing a function, changing a parameter type, changing an error variant, or changing the semantics of an existing call all require an RFC. The evolution discipline is: ship a new file at a new version path (sys@1.1.0.wit), never edit the frozen file.
The astrid:guest@1.0.0 file is also in scope even though it defines exports (functions the kernel calls into the capsule). The shape of astrid-hook-trigger, run, astrid-install, and astrid-upgrade is just as contractual as the imports.
2. IPC protocol
The IPC bus carries JSON payloads over dot-delimited topics. Two things are in scope for the RFC process:
Topic naming conventions. The segment grammar ([a-z0-9._-]+, max 8 segments, max 256 bytes total) is defined in wit/host/ipc@1.0.0.wit. The existing topic namespaces (user.v1.*, agent.v1.*, tool.v1.*, llm.v1.*, session.v1.*, registry.v1.*, client.v1.*) are established contract. Adding a new top-level namespace or changing the vN versioning convention requires an RFC.
Payload schemas. The WIT interfaces under wit/interfaces/ define the typed payload records for bus messages. Examples: astrid-bus:types@1.0.0 (types.wit) defines message, tool-call, tool-call-result, and tool-definition. astrid-bus:session@1.0.0 (session.wit) defines get-messages-request, get-messages-response, clear-request, and so on. astrid-bus:tool@1.0.0 (tool.wit) defines describe-request and describe-response.
Capsules read and write these types. A schema change on either side breaks the other. Any payload field addition, removal, rename, or type change in wit/interfaces/ requires an RFC.
3. Capability model
The capability model determines what a capsule is allowed to do. Two layers are in scope:
Token format and validation semantics. The kernel validates capability tokens on every host call. The astrid:sys@1.0.0 check-capsule-capability host function (wit/host/sys@1.0.0.wit, lines 135-141) is the guest-visible API for capability introspection. Changes to how tokens are structured, how scopes are matched, or what the validation rules are require an RFC.
Capability scope names. The [capabilities] section of Capsule.toml uses a fixed vocabulary: uplink, net_bind, ipc_publish, ipc_subscribe, host_process, fs_read, fs_write, identity, and so on. The capability string a capsule declares must match what the kernel’s ACL evaluator recognizes. Adding a new capability name, deprecating an existing one, or changing the scope semantics of an existing name requires an RFC.
4. Manifest schema
Capsule.toml is parsed into CapsuleManifest at core/crates/astrid-capsule/src/manifest/mod.rs. The struct definition is the manifest schema. The top-level sections are:
[package]- identity metadata (name, version, description, authors, etc.)[[component]]- WASM entry points with id, file path, hash, and component-local capabilities[capabilities]- flat capability declarations (legacy form;[publish]/[subscribe]tables supersede)[imports]/[exports]- namespaced interface declarations (dual-form: flat"ns:iface"or nested[imports.ns])[publish]/[subscribe]tables - cargo-like-manifest form for IPC ACL and interceptor bindings[[tool]]- tools surfaced to the LLM withdescription_for_llm[[interceptor]]- legacy interceptor bindings (superseded by[subscribe]handler field)[[uplink]],[[skill]],[[mcp_server]],[[command]],[[context_file]]- integration declarations[env]- environment variable elicitation with type, request, default, and enum_values
Adding a new top-level section, adding a new field that the kernel reads for routing or gating decisions, or changing how an existing field is interpreted at load time requires an RFC. Adding a field that is purely cosmetic (e.g. [package].homepage) is a lower bar, but the manifest is an untrusted input surface and any new field that crosses the operator/author trust boundary (see EnvScope.scope’s skip_deserializing) needs careful review and an RFC.
5. VFS semantics
The VFS presents capsules with scheme-rooted paths (workspace://, home://, tmp://). The semantics in scope include:
- Path resolution rules: how segments are validated, how symlinks are followed, what constitutes a boundary escape
- The scheme vocabulary itself: adding a new scheme like
cache://requires an RFC - The error contract:
error-codevariants inastrid:fs@1.0.0are part of the ABI
The astrid:fs@1.0.0 host interface (wit/host/fs@1.0.0.wit) is frozen. Its freeze notice states that shape changes ship at a new version path. The existing file-handle resource, its read-at / write-at / sync-data / sync-all / stat / set-len methods, and the full set of path-based operations are contractual.
Notably: the freeze comment explicitly calls out that fs-canonicalize is not a security primitive (the kernel re-resolves every path on every call). This is a semantic guarantee. Changing that guarantee requires an RFC even if no WIT file changes.
6. Capsule interface standards
Interface standards govern how capsules talk to each other. The WIT records in wit/interfaces/ define typed contracts that any capsule can implement or depend on. Examples: the tool interface (astrid-bus:tool@1.0.0) specifies the describe-request/describe-response handshake that every tool-providing capsule must honor. The session interface (astrid-bus:session@1.0.0) specifies the request/response protocol for conversation history.
Any capsule implementing these interfaces produces a binary that other capsules depend on. Adding a field, removing a field, or changing the topic naming convention for these inter-capsule protocols requires an RFC.
The astrid:guest@1.0.0 interceptor world specifies the action string and list<u8> payload calling convention for astrid-hook-trigger. The capsule-result record (its action and data fields) is also a standard: every capsule that exports astrid-hook-trigger must return values the kernel knows how to interpret. Changing this contract requires an RFC.
7. SDK public API
The SDK (sdk-rust/astrid-sdk) is the primary dependency for capsule authors. Its module layout mirrors std: fs, net, process, ipc, kv, http, elicit, identity, approval, and types. The public re-exports, function signatures, and error types in each module are the SDK API.
Breaking changes include: removing a public function, changing a function signature, renaming a public type, changing the semantic contract of an existing function (e.g. changing when an error variant is returned), or reorganizing the module layout. All of these require an RFC.
Non-breaking additions (a new convenience method, a new optional parameter via a builder pattern) do not require an RFC, but they do require a semver minor bump and a CHANGELOG entry.
What does not require an RFC
RFC 0001 is explicit (text/0001-rfc-process.md, lines 63-70):
- Bug fixes to existing implementations
- Internal refactoring that preserves the external contract
- Documentation improvements
- Performance optimizations that preserve existing behavior
- Adding a new capsule that implements an existing interface
- Kernel-internal changes that do not cross the ABI boundary
The key test is: does the guest-visible WIT surface change? If no capsule needs to recompile, and no Capsule.toml needs to change, and no IPC payload schema changes, the change is kernel-internal and needs no RFC.
Concrete examples of kernel-internal changes that do not require an RFC:
- Changing how the kernel dispatches IPC events internally (the bus routing algorithm, the broadcast fan-out implementation, the per-principal message queue data structure)
- Changing the storage backend for the KV store (e.g. switching from one embedded database to another) as long as the
astrid:kv@1.0.0semantics are preserved - Adding a new metrics endpoint to the gateway HTTP server
- Refactoring how the kernel validates capability tokens internally, as long as the external validation rules for capsules are unchanged
- Changing the WASM instance pool size, the blocking vs IO semaphore split, or the fuel ledger accounting algorithm
- Updating the daemon’s shutdown sequence or signal handling
None of these touch the WIT files in wit/host/ or wit/interfaces/, none change CapsuleManifest, and none change what capsule authors write in their Capsule.toml or their Rust code.
The practical test
Before opening a PR, run through this checklist:
- Does this change add, remove, or modify a function in any file under
wit/host/? If yes, RFC required. - Does this change add, remove, or modify a type or record in any file under
wit/interfaces/? If yes, RFC required. - Does this change add, remove, or modify a field in
CapsuleManifestthat the kernel reads for routing, gating, or capability decisions? If yes, RFC required. - Does this change alter the topic naming convention or payload schema for any IPC topic used across capsule boundaries? If yes, RFC required.
- Does this change alter how capability scope names are matched or validated in a way visible to capsule authors? If yes, RFC required.
- Does this change add or remove a VFS scheme, or change the path resolution or error semantics visible to capsule code? If yes, RFC required.
- Does this change remove a public symbol from
astrid-sdkor change a function signature? If yes, RFC required.
If the answer to all seven is no, the change is implementation-internal and can proceed without an RFC.
Filing the RFC
The RFC repository is at astrid-rfcs/. The template is astrid-rfcs/0000-template.md. Process:
- Fork the RFC repo and copy the template to
text/0000-my-feature.md. - Fill in all required sections: Summary, Motivation, Guide-level explanation, Reference-level explanation, Drawbacks, Rationale and alternatives, Prior art, Unresolved questions, Future possibilities.
- For any RFC that touches a host function or an IPC payload schema, the Reference-level explanation must include exact function signatures, input and output types with field types and constraints, error handling contracts, and ordering or concurrency guarantees. The spec must be precise enough for an independent developer to implement a conforming component from that section alone.
- Open a pull request. The RFC number is assigned by a maintainer at merge time, not at PR time.
- Once merged (status: Active), implementation proceeds in
astrid-sdkbehind a feature flag. Each RFC that defines types maps to a feature flag, e.g.features = ["rfc-1"].
The lifecycle states are Draft (PR open), Active (merged, being implemented), Final (implemented and stable), Withdrawn (closed without merge), and Superseded (replaced by a newer RFC, noted in the header).
See also
Contribution Tiers and Security-Critical Crates
Astrid is a security-critical runtime. Every change that lands in core/ is reviewed against its threat model, not just its correctness. The project uses a four-tier contributor system enforced by CI to protect the security boundary while keeping the door open for new contributors who follow the process.
The Four Tiers
The canonical source is .github/contributors.yml (core/.github/contributors.yml). Anyone not listed there is treated as new by default. CI reads this file directly on every PR.
| Tier | Entry point | Scope |
|---|---|---|
| New | Default for all unlisted contributors | Must open an issue first, wait for maintainer assignment, and have a maintainer add the newcomer-approved label before CI proceeds |
| Astrinaut | Promoted after a successful first contribution | Can self-claim issues and submit PRs to non-core crates: CLI, SDK, capsules, docs, tests |
| Core | Promoted after sustained quality contributions | Can modify core crates (kernel, events, hooks, config). Security-critical paths still require maintainer co-review |
| Maintainer | Project leads | Full access: security paths, refactors, releases, version bumps |
Promotions happen at maintainer discretion. There is no application process. The quality and consistency of your merged work is the signal.
The contributors.yml structure:
maintainers:
- username: joshuajbouw
since: 2024-01-01
core: []
astrinauts: []
How CI Enforces the Tiers
The contributor-gate job in .github/workflows/pr-checks.yml (lines 50-147) runs on every PR against main. It:
- Parses
contributors.ymlto determine the PR author’s tier. - Fetches the list of changed files from the GitHub API.
- Tests each changed path against two path sets defined in the script:
SECURITY_PATHS="crates/astrid-crypto crates/astrid-capabilities crates/astrid-audit \
crates/astrid-approval crates/astrid-vfs crates/astrid-storage crates/astrid-sys \
crates/astrid-core"
CORE_PATHS="crates/astrid-kernel crates/astrid-events crates/astrid-hooks \
crates/astrid-config crates/astrid-mcp"
The gate logic per tier:
- new: CI fails unless the PR carries the
newcomer-approvedlabel from a maintainer. - astrinaut: CI fails if the PR touches any path under
SECURITY_PATHSorCORE_PATHS. - core: CI passes, but emits a warning if any file under
SECURITY_PATHSis modified. That warning signals to reviewers that a maintainer co-review is required before merge. - maintainer: All checks pass unconditionally.
Security-Critical Crates
These eight crates form the security boundary. Only core-tier and maintainer-tier contributors can modify them. The contributor-gate CI job enforces this at the path level.
astrid-crypto (crates/astrid-crypto)
Provides ed25519 key pairs, signatures, and BLAKE3 content hashing. Every capability token and audit entry is signed by this layer. The crate carries #![deny(unsafe_code)], #![deny(missing_docs)], #![deny(clippy::all)], #![deny(clippy::unwrap_used)], and #![deny(unreachable_pub)] in src/lib.rs (line 33-38).
Public surface: KeyPair, PublicKey, Signature, ContentHash.
astrid-capabilities (crates/astrid-capabilities)
Capability tokens with ed25519 signatures, resource patterns with glob matching, session and persistent token storage, and per-principal authorization checks. Tokens are:
- Signed by the runtime’s ed25519 key.
- Linked to the approval audit entry that created them.
- Time-bounded (optional expiration).
- Scoped (session or persistent).
Carries the same five crate-level deny attributes as astrid-crypto (src/lib.rs, lines 53-58).
astrid-audit (crates/astrid-audit)
Chain-linked cryptographic audit logging. Each entry is signed by the runtime key and contains the BLAKE3 hash of the previous entry, providing tamper evidence. Any modification to historical entries breaks the chain and is detectable by AuditLog::verify_chain. Backed by SurrealKV for persistence. Same deny attributes at src/lib.rs lines 53-58.
astrid-approval (crates/astrid-approval)
Human-in-the-loop approval gates for sensitive agent operations. Contains the ApprovalManager, allowance patterns, session and per-action budget tracking, a security policy (hard-blocked and approval-required tool lists), and a security interceptor combining all layers with intersection semantics. Same deny attributes at src/lib.rs lines 44-49.
astrid-vfs (crates/astrid-vfs)
Virtual filesystem abstraction providing sandboxed operations, capability-based access, and a copy-on-write overlay implementation. The boundary and worktree submodules are pub(crate) only. Note: OverlayVfs::commit and OverlayVfs::rollback have no production caller as of this writing. Same deny attributes at src/lib.rs lines 6-11.
astrid-storage (crates/astrid-storage)
Unified persistence layer with two tiers: raw key-value via SurrealKV (KvStore, enabled by the kv feature) and full SurrealQL via SurrealDB (Database, enabled by the db feature). Backs all system stores: approval, audit, capabilities, memory. The secret module manages keychain and secret store implementations. Same deny attributes at src/lib.rs lines 38-43.
astrid-sys (crates/astrid-sys)
OS microkernel bindings referenced in CONTRIBUTING.md. This crate is listed in the SECURITY_PATHS enforcement set but lives in sdk-rust/ (the standalone SDK), not under core/crates/. PRs against the SDK that touch astrid-sys fall outside the core contributor-gate job; capsule authors and SDK contributors should treat it as security-critical regardless.
astrid-core (crates/astrid-core)
Foundation types and authorization interfaces used throughout the runtime: PrincipalId, SessionId, Permission, Group, capability grammar (capability_matches, validate_capability), elicitation primitives, uplink types, retry configuration, and profile/config types. Because this crate is a dependency of almost every other crate in the workspace, changes to it can silently affect the entire security model. Same deny attributes at src/lib.rs lines 10-15.
What Distinguishes Security-Critical from Core
The CORE_PATHS set covers crates that are important but not themselves on the cryptographic or authorization boundary:
astrid-kernel: runtime dispatch and the Wasmtime sandbox host.astrid-events: IPC event types and the bus.astrid-hooks: lifecycle hook execution.astrid-config: daemon and capsule configuration parsing.astrid-mcp: MCP protocol bridge.
Core-tier contributors can touch these without maintainer co-review. Modifications that approach the security boundary (for example, adding a new host function in astrid-kernel that calls into astrid-capabilities) should be discussed in the issue before the PR is opened.
PR Requirements
All PRs against core/ must pass five CI jobs defined in .github/workflows/pr-checks.yml:
template: Validates that the PR body fills in every required section of .github/pull_request_template.md (Linked Issue, Summary, Changes, Test Plan) and contains a real issue number. Empty or placeholder sections fail CI.
contributor-gate: Tier enforcement described above.
file-size: Rejects any file pushed over 1000 lines by the PR, measured against the base commit. Files already over 1000 lines on the base branch are noted but not blocked. The large-file-ok label (maintainer-only in practice) bypasses this check.
linked-issue: Requires a Closes #N reference in the PR body, or an issue linked via the GitHub sidebar. The check uses both regex on the PR body and the GraphQL closing-issues API.
account-age: Runs for brand-new GitHub accounts (NONE/FIRST_TIMER/FIRST_TIME_CONTRIBUTOR association). Emits warnings if the account is fewer than 30 days old or has no public repos and no followers.
Separately, .github/workflows/changelog.yml requires a CHANGELOG.md entry under [Unreleased] on every PR that changes .rs files or Cargo.toml. The skip-changelog label bypasses it.
The full CI suite (.github/workflows/ci.yml) runs cargo check, cargo fmt --check, cargo clippy -- -D warnings, cargo test --workspace --exclude astrid-openclaw (on both Ubuntu and macOS), MSRV check at 1.95, and cargo audit via rustsec.
Adversarial Self-Review
Before requesting review on any non-trivial change, work through these questions inline.
Failure modes under production conditions. Poisoned locks, killed processes, exhausted memory, interrupted syscalls, corrupted state across fork boundaries. Ask: how does this fail at 3am?
Invariants of the execution context. WASM guests cannot escape the sandbox. Untrusted input cannot reach format!() unsanitized. Capsule manifests are untrusted input; operator-only fields need #[serde(skip_deserializing)] and a parser-isolation test. Identify what cannot happen in your context, then check whether your change makes a violation possible.
Boundary crossings. If your change adds or modifies a host function, a capability check, an IPC topic, or an audit action, those are boundary crossings. Flag them explicitly in the PR summary. Do not bury them in unrelated diffs.
Kernel purity. The kernel is dumb. It routes events, enforces capabilities, and manages the WASM sandbox. It contains no business logic. If a review comment says “this belongs in a capsule,” it is correct.
Adversarial review is not a checklist to be completed. It is a mindset: read your own diff as an attacker would.
What Will Not Be Accepted
These are hard stops, not suggestions:
- PRs with no linked issue and no prior discussion.
- AI-generated bulk submissions that show no understanding of the affected crates.
- Refactors submitted by contributors below maintainer tier. If you see something that needs refactoring, open an issue.
- Changes to security-critical crates from contributors below core tier.
unsafecode without explicit written justification in the PR and maintainer approval. Every security-critical crate carries#![deny(unsafe_code)]in itslib.rs.- Version bumps mixed into feature or fix PRs. Version bumps are always a separate PR.
Reporting Vulnerabilities
Do not open a public issue. Use GitHub Security Advisories to report privately. The project targets acknowledgment within 48 hours and a fix timeline within 7 days. See core/SECURITY.md for the full scope definition, which covers sandbox escapes, capability token forgery, cryptographic weaknesses in ed25519 or BLAKE3, SSRF or injection through host functions, and audit log tampering.
See also
Release Process and Coding Standards
This page documents the release workflow for the core repository (unicity-astrid/astrid), the lint and formatting rules every crate must satisfy, and the security disclosure process. Read it before opening your first PR against a kernel or security-critical crate.
Version Bump Policy
Version bumps are always a separate PR. Never bundle a version bump with a feature or fix PR. The commit type is chore: release X.Y.Z and it touches only Cargo.toml workspace version and the CHANGELOG.md heading change from [Unreleased] to [X.Y.Z] - YYYY-MM-DD. Reviewers must be able to diff a feature PR and see only the intended change.
All workspace members inherit the version from the root Cargo.toml:
# core/Cargo.toml
[workspace.package]
version = "0.7.0"
Every crate in [workspace.members] carries version.workspace = true. Bumping the root version bumps every crate atomically. Do not set a crate-local version unless you have an explicit reason.
Changelog Discipline
The project follows Keep a Changelog with Semantic Versioning. Every PR that touches Rust source or Cargo.toml must include a CHANGELOG.md entry under [Unreleased]. The CI changelog enforcer (dangoslen/changelog-enforcer) rejects PRs that omit this step (.github/workflows/changelog.yml). PRs carrying the skip-changelog label are exempt, reserved for pure documentation and CI changes.
Section headings are ### Added, ### Changed, ### Fixed, ### Removed, ### Deprecated, ### Security, and ### Breaking. Write entries in past tense, bold the feature name, and close with Closes #N where N is the linked issue. Keep entries dense but complete: reviewers use them to understand blast radius.
Release Workflow
Releases are triggered by pushing a v* tag. The .github/workflows/release.yml pipeline runs automatically.
Tagging and Triggering
# After the version-bump PR is merged to main:
git tag v0.8.0
git push origin v0.8.0
The pipeline fires on any tag matching v[0-9]+.*.
Build Matrix
The release workflow builds four targets in parallel (fail-fast: false):
| Target | OS |
|---|---|
x86_64-apple-darwin | macos-latest |
aarch64-apple-darwin | macos-latest |
x86_64-unknown-linux-gnu | ubuntu-latest |
aarch64-unknown-linux-gnu | ubuntu-latest |
The Linux ARM target requires the gcc-aarch64-linux-gnu cross-compiler, installed by the workflow via apt-get. The CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER environment variable is set to aarch64-linux-gnu-gcc for that build only.
Only the astrid binary package is built for release: cargo build --release --target ${{ matrix.target }} -p astrid. The four companion binaries (astrid, astrid-daemon, astrid-build, astrid-emit) are copied from the target output directory, bundled into a tar.gz archive named astrid-{VERSION}-{TARGET}.tar.gz, and uploaded as build artifacts.
Content-Addressed Binaries
At install time the kernel stores every capsule’s WASM binary under a content-addressed path (~/.astrid/bin/<blake3-hex>.wasm) rather than by name. The install machinery in crates/astrid-capsule-install/src/wasm.rs hashes from the source file using BLAKE3, writes the result atomically via a UUID-suffixed temp file and rename, and never writes a .wasm into the per-capsule directory:
#![allow(unused)]
fn main() {
// crates/astrid-capsule-install/src/wasm.rs
let hash = blake3::hash(&bytes).to_hex().to_string();
let store_path = bin_dir.join(format!("{hash}.wasm"));
if !store_path.exists() {
let tmp = bin_dir.join(format!("{hash}.tmp.{}", uuid::Uuid::new_v4().simple()));
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, &store_path)?;
}
}
The UUID-suffix is required because the gateway processes admin requests concurrently (since the bus-direct refactor), and sibling tokio tasks in the same daemon process share a PID. A UUID is the only safe disambiguator.
The runtime resolves a capsule’s WASM via resolve_content_addressed_wasm in crates/astrid-capsule/src/engine/wasm/mod.rs (line 90): it reads meta.json in the per-capsule directory to find wasm_hash, then constructs the path $ASTRID_HOME/bin/<hash>.wasm. WIT files follow the same scheme under ~/.astrid/wit/store/; the top of ~/.astrid/wit/ holds the daemon’s canonical named copies (notably astrid-contracts.wit), kept apart from the hash-named store so astrid capsule wit gc can sweep the store without touching them. The AstridHome directory layout is defined in crates/astrid-core/src/dirs.rs; bin_dir() returns {root}/bin, wit_dir() returns {root}/wit.
GitHub Release
After all four build jobs complete, the github-release job:
- Downloads all
binary-*artifacts. - Extracts the changelog section for the version from
CHANGELOG.mdusingawkand embeds it in the release body. - Computes SHA-256 checksums with
sha256sum *.tar.gz > SHA256SUMS.txt. - Signs every archive and
SHA256SUMS.txtwith keyless sigstore (cosign sign-blob --bundle), producing a.sigstore.jsonbundle per asset. The certificate identity isrelease.ymlat the version tag. - Creates the GitHub release with
softprops/action-gh-release, attaching all four archives,SHA256SUMS.txt, and the sigstore bundles. - Dispatches a
releaseevent to thehomebrew-taprepository so the Homebrew formula can be updated automatically.
The release body includes install instructions for both cargo install astrid (requires Rust 1.95 or later) and the pre-built binary path.
crates.io Publishing
The astrid package (crates/astrid-cli) carries all four binaries as bin targets, so cargo install astrid yields the same toolchain as a release tarball. Cargo requires a published crate’s entire dependency tree on the registry, and the closure of astrid is 24 of the 28 workspace crates. Publishing “just the CLI” is therefore not possible; a release publishes the whole closure:
# After the tag, from the workspace root:
cargo publish --workspace
Modern cargo resolves the inter-crate publish order automatically and uploads bottom-up. The command is resumable: if it dies partway (network, registry rate limit), re-running skips already-published crates and continues.
Four crates are excluded with publish = false and must stay that way: astrid-hooks, astrid-prelude, astrid-integration-tests, and astrid-test. They are not in the CLI’s closure and are not a public surface.
The internal crates that do reach crates.io exist only to satisfy cargo’s published-closure rule. They are not a supported library API: no stability guarantees, and their versions move in lockstep with the astrid CLI release.
CI Pipeline
The standard CI pipeline (.github/workflows/ci.yml) runs six jobs on every push to main or a stream-* branch, and on every PR targeting main. All jobs pin to Rust 1.95.
| Job | Command | Notes |
|---|---|---|
check | cargo check --workspace --all-features | Ubuntu only |
fmt | cargo fmt --all -- --check | Fails on any formatting divergence |
clippy | cargo clippy --workspace --all-features -- -D warnings | All warnings are errors in CI |
test | cargo test --workspace --exclude astrid-openclaw | Ubuntu and macOS; astrid-openclaw excluded (parked) |
msrv | cargo check --workspace at 1.95 | Verifies the declared rust-version is accurate |
audit | rustsec/audit-check | Scans Cargo.lock for known CVEs |
All jobs check out submodules recursively (submodules: recursive) because astrid-capsule’s build.rs stages WIT files from the wit/ submodule before bindgen runs.
Pre-Submission Checklist
Before pushing a branch:
cargo fmt --all
cargo clippy --workspace --all-features -- -D warnings
cargo test --workspace --exclude astrid-openclaw
The PR template (pull_request_template.md) requires all three to pass. CI enforces the same commands and rejects PRs with unchecked boxes or empty template sections.
Lint and Safety Standards
Workspace-Level Lints
Three workspace-wide lint rules are declared in core/Cargo.toml under [workspace.lints]:
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
arithmetic_side_effects = "deny"
Every crate in the workspace inherits these rules via [lints] workspace = true in its own Cargo.toml. The implications:
unsafe_code = "deny": Nounsafeblocks anywhere in the workspace unless explicitly overridden with a documented justification. See the Unsafe Code section below.clippy::allandclippy::pedanticatwarn: Every pedantic lint fires as a warning. CI promotes all warnings to errors via-D warnings, so in practicepedanticisdenyin CI.arithmetic_side_effects = "deny": Integer overflow, underflow, and wrapping arithmetic are compile-time errors. Use checked arithmetic (checked_add,saturating_mul, etc.) or explicit casting when truncation is intentional.
Clippy Configuration
core/clippy.toml applies to the whole workspace:
msrv = "1.95"
cognitive-complexity-threshold = 25
too-many-arguments-threshold = 7
too-many-lines-threshold = 100
type-complexity-threshold = 250
disallowed-methods = [
{ path = "std::env::set_var", reason = "Use safe configuration instead" },
{ path = "std::env::remove_var", reason = "Use safe configuration instead" },
]
std::env::set_var and std::env::remove_var are banned because they mutate a process-global table that is not thread-safe in Rust’s 2024 edition. Tests that genuinely need to vary environment variables must document a // SAFETY: comment explaining why no other thread can observe the mutation (see crates/astrid-workspace/src/sandbox/mod.rs:755 for an example). Production code never calls these functions.
doc-valid-idents is set to recognize Astrid, MCP, WASM, WASI, OAuth, GitHub, macOS, and WebAssembly so clippy does not flag these as typos in doc comments.
Formatting
core/rustfmt.toml is the canonical formatter configuration:
edition = "2024"
max_width = 100
tab_spaces = 4
newline_style = "Unix"
reorder_imports = true
match_block_trailing_comma = true
use_field_init_shorthand = true
use_try_shorthand = true
CI runs cargo fmt --all -- --check and fails on any deviation. Format before committing: cargo fmt --all.
Unsafe Code
The workspace-level deny means unsafe is forbidden by default. The only legitimate exceptions in the codebase:
- Integration tests calling
std::env::set_var(Rust 2024 edition made itunsafe). These carry a// Safety:comment explaining the single-threaded invocation pattern. Seecrates/astrid-integration-tests/tests/gateway_e2e.rs:59. - Tests calling
std::env::remove_varfor sandbox policy probing. Seecrates/astrid-workspace/src/sandbox/mod.rs:755.
Neither case is production code. No production crate uses unsafe. To introduce unsafe in production code, you need a Maintainer review, a detailed soundness argument in the code comment, and a linked issue tracking the technical debt.
Security-Critical Crate Attributes
Beyond the workspace defaults, the seven security-critical crates add crate-level deny attributes in their lib.rs:
#![allow(unused)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
#![deny(clippy::all)]
#![deny(unreachable_pub)]
#![deny(clippy::unwrap_used)]
#![cfg_attr(test, allow(clippy::unwrap_used))]
fn main() {
}
The security-critical crates are: astrid-crypto, astrid-capabilities, astrid-audit, astrid-approval, astrid-vfs, astrid-storage, and astrid-core. Only Maintainer-tier contributors may modify these crates; Core-tier contributors touching them cause a CI warning requesting maintainer co-review, but the check is not blocking (see .github/workflows/pr-checks.yml).
#![deny(missing_docs)] means every public item must have a doc comment. #![deny(unreachable_pub)] means every pub item must actually be reachable from the crate root. Together they push the public API surface to be both documented and intentional.
#![deny(clippy::unwrap_used)] bans .unwrap() in production code. Use ?, expect("reason") when the invariant is local and obvious, or explicit pattern matching. Test code relaxes this via the cfg_attr line.
Doc Comment Style
Doc comments must not contain em-dashes. Use periods, commas, parentheses, or the word “and” instead. This applies to all /// and //! comments throughout the codebase.
Example of correct style:
#![allow(unused)]
fn main() {
/// Resolve the home directory.
///
/// Checks `$ASTRID_HOME` first, then falls back to `$HOME/.astrid/`.
///
/// Returns an error if neither `$ASTRID_HOME` nor `$HOME` is set.
pub fn resolve() -> io::Result<Self> {
}
File Size Limit
Individual source files must not exceed 1000 lines. The pr-checks.yml workflow measures every file changed by a PR against its line count on the base branch and fails if any file crosses 1000 lines that was under the limit before the PR. The large-file-ok label overrides this check and is available only to Maintainers for refactors where the boundary is difficult to draw incrementally.
When a file approaches the limit, split it into a submodule directory. The crates/astrid-capsule/src/manifest/ split (from a 1000-line single file into mod.rs, capabilities.rs, and topics.rs) is the canonical example.
PR Requirements
All PRs must:
- Be linked to an existing issue via
Closes #Nin the body. Thelinked-issueCI job enforces this. If no issue exists, open one first. No unsolicited PRs. - Have all four template sections filled: Linked Issue, Summary, Changes, Test Plan. The
templatejob inpr-checks.ymlrejects PRs with empty sections. - Update
CHANGELOG.mdunder[Unreleased]. The changelog enforcer fails the PR otherwise. - Pass
cargo test --workspaceandcargo clippy -- -D warnings.
New contributors additionally require a maintainer to add the newcomer-approved label before CI proceeds on their PR.
Toolchain
# core/rust-toolchain.toml
[toolchain]
channel = "1.95.0"
components = ["rustfmt", "clippy"]
The workspace pins to Rust 1.95.0. The MSRV is set to match: rust-version = "1.95" in [workspace.package]. Do not use stabilized features from later versions. The CI MSRV job (cargo check --workspace at 1.95) verifies this on every PR.
The Rust 2024 edition is in use (edition = "2024" in [workspace.package] and in rustfmt.toml).
Security Disclosure
Reporting a Vulnerability
Do not open a public GitHub issue for security vulnerabilities. Use GitHub’s private vulnerability reporting to submit a report. This keeps the issue private until a fix is ready.
Include in your report:
- A description of the vulnerability.
- Steps to reproduce.
- The affected components (crate name and module).
- A severity assessment if you have one.
The project aims to acknowledge reports within 48 hours and provide a fix timeline within 7 days.
In-Scope Vulnerabilities
core/SECURITY.md defines scope explicitly. In scope:
- Sandbox escapes: a WASM guest accessing host resources outside its granted capabilities.
- Capability token forgery or privilege escalation.
- Cryptographic weaknesses in ed25519 signing, BLAKE3 verification, or the audit chain.
- SSRF or injection through capsule host functions.
- Audit log tampering or bypass.
Out of scope:
- Denial of service through resource exhaustion (covered by the capability limits and quota system).
- Vulnerabilities in upstream dependencies (report to the upstream project).
- Issues requiring physical access to the host machine.
Threat Model Questions
Apply these questions to every non-trivial change before requesting review. They are the adversarial self-review gate:
- What is the threat model?
- How can this be abused?
- What cryptographic proof exists?
- Is this auditable?
- Does it fail secure?
The fail-secure invariant is non-negotiable. The sandbox fails closed. The capability store degrades to “always ask” rather than “always allow”. Audit logging continues and alerts rather than silently dropping entries. A change that introduces a soft fallback on a security boundary requires explicit Maintainer sign-off with a written rationale.
Build Provenance Metric
The gateway crate captures build provenance at compile time via crates/astrid-gateway/build.rs. It runs git rev-parse --short=12 HEAD and the rustc --version of the invoking toolchain and exposes them as ASTRID_GIT_SHA and ASTRID_RUSTC_VERSION environment variables, which src/metrics.rs reads via env!. Both fall back to "unknown" when the .git directory is unreachable (source-tarball builds), so env! never fails.
The metric astrid_build_info{version, git_sha, rustc} is a gauge pinned at 1.0, exposed on the unauthenticated GET /metrics endpoint. Its value is the label set. Dashboards use it for build-provenance joins:
astrid_build_info{git_sha="abc123def456"}
This metric is intentionally unauthenticated: it carries no principal, path, or secret, and its value is the same for every request to the same daemon process.