# JUJU Architecture

## 0.7.1: two tool surfaces, one invocation pipeline (ADR-029)

[src/tools/surface.ts](../src/tools/surface.ts) defines the compact surface:
17 domain gateways plus `juju_discover`, built ON TOP of the unchanged
[catalog](../src/tools/catalog.ts). [src/tools/context.ts](../src/tools/context.ts)
now exposes `invokeToolDefinition` — the single pipeline (principal → scope →
workspace/epoch → byte budget → strict target schema → handler → envelope →
audit) used by BOTH the direct MCP registration and gateway dispatch, so a
gateway can never skip a check. HTTP defaults to compact, STDIO to full;
config `toolSurface` or `--tools` overrides. `surfaceStats` powers the
`[juju] mcp tool surface` startup diagnostic, and
`schemas/tools.compact.json` is emitted beside `schemas/tools.json`.

## 0.7.0 additions

The catalog has 74 tools. Five assistance tools are implemented in
`src/tools/assistanceTools.ts` / `src/services/assistance/` and use the existing
TypeScript worker, planner/journal, job manager and state store. Twenty multimodal
tools live in `src/tools/multimodalTools.ts` / `src/services/multimodal/` with one
service instance per workspace/epoch. Bootstrap owns their lifecycle and shutdown.

Media operations use fixed native argv through the existing job manager, private
bounded source copies and owned temporary assets. Screen views wrap the existing
desktop consent/snapshot service. Browser operations use isolated Chromium,
bounded observations and origin-vetted requests. Game/workflow operations compose
one observed action at a time; no unattended reasoning loop or model API is added.
The result envelope can carry actual image or audio blocks. Saved workflows are
client-authored data, never permission. OAuth/ACL/trust/sandbox settings are unchanged.

See [ASSISTANCE](ASSISTANCE.md), [MULTIMODAL](MULTIMODAL.md) and
[RELEASE_0.7.0](RELEASE_0.7.0.md) for current contracts and limits. Earlier sections
below retain their versioned architectural history; old catalog counts are historical.


> **0.2.0 update (supersedes older workspace-only grant wording below):** New
> OAuth grants authenticate the JUJU installation; every request still checks
> a separate current-workspace/client scope ACL. Legacy tokens remain bound to
> their original root until a fresh login. Bare `juju` now starts HTTP and a
> private token-protected config listener on 127.0.0.1:21731; subprocess clients
> must explicitly use `juju stdio`. `--allow --all` and `--bypass` are run-only
> execution overrides, never authentication or workspace-ACL bypasses.
> See [ADR-017](adr/017-installation-identity-local-config.md) and the
> [0.2 setup guide](../README.md#quickstart) for the current contract.

JUJU is a local-first, single-owner, project-scoped coding server that speaks the
Model Context Protocol (MCP). It is a Node.js/TypeScript ESM package whose product
surface is the `juju` CLI. One `juju` process serves exactly one workspace directory
over a loopback-bound HTTP listener fronted by an embedded OAuth 2.1 / OIDC
authorization server; the machine owner reaches the same process over a private local
IPC socket to grant consent, set trust, approve actions and stop the server.

This document describes the implementation in `src/`; every claim is anchored to the
code it describes. Where the code documents a limitation, so does this document. In
particular: **JUJU does not provide an OS sandbox.** Its path policy is a pure-Node
check-then-use guard with documented TOCTOU races against a hostile same-OS-user
process, and commands started through the exec/task tools run with the owner's real
privileges.

---

## 1. System context and request path

Two entirely separate channels reach a running process. Remote MCP traffic arrives
over HTTPS through a tunnel the owner operates; owner control arrives over a Unix
domain socket that never touches the HTTP listener.

```
   MCP client (e.g. ChatGPT / compatible)
            │
            │  HTTPS + OAuth Bearer access token (ES256 JWT)
            ▼
   https://juju.example.com/…            ← issuer origin (owner-configured publicUrl)
            │
            │  owner-operated tunnel (Cloudflare named tunnel, etc.)
            ▼
   127.0.0.1:21730                        ← http.Server bound to loopback ONLY
            │
   ┌────────┴─────────────────────────────────────────────┐
   │ Express app (src/server/appServer.ts)                 │
   │   trust proxy = loopback                               │
   │   Host allowlist ── Origin allowlist                   │
   │   /healthz  /.well-known/*  (+ /auth /token /interaction when unlocked)
   │   /mcp:                                                 │
   │     express.json(limit)                                 │
   │       → requireBearerAuth(verifier)   ← OAuth resource server check
   │         → MCP handler (createMcpHandler, legacy:'stateless')
   │           → McpServer + registerCatalog(services)       │
   │             → tool handler                              │
   └───────────────────────────┬───────────────────────────┘
                               ▼
   scope check → workspace-context check → trust-mode/approval gate
                               ▼
   Tool services  (files · search · changes · jobs · git · intelligence · overview)
                               ▼
   WorkspaceFS path policy  ──►  files under the realpath root · job spools · SQLite state


   Local terminal (owner)
            │  newline-delimited JSON
            ▼
   AF_UNIX socket  (0600 file inside a 0700 dir)   ← src/ipc/server.ts
            │
            ▼
   ipcDispatch: status · stop · trust.set · approvals.* · auth.* · recover.*
            (consent, trust, approvals — never reachable from the public listener)
```

The listener is bound with `httpServer.listen(port, '127.0.0.1', …)` — loopback-only by
construction; the tunnel is the sole hop, and `trust proxy` is `loopback` so its
`X-Forwarded-*` headers count for protocol/IP but never for issuer identity
([src/server/appServer.ts](../src/server/appServer.ts)). The default port is `21730`
([src/config/globalConfig.ts](../src/config/globalConfig.ts)). Host and Origin are
validated against allowlists before any handler runs; a `null` Origin is always denied,
an absent Origin passes.

The MCP endpoint uses the official `createMcpHandler` in `legacy: 'stateless'` mode: a
fresh `McpServer` is built per invocation by a factory that calls
`registerCatalog(server, services)`. There is no protocol session, and `Mcp-Session-Id` is
never used as a workspace or job identifier.

---

## 2. Process and lifecycle model

**One process = one workspace root = one endpoint profile.** A process serves a single
realpath'd directory and, when unlocked, a single issuer origin. The AI cannot change
the root through any tool.

The lifecycle, orchestrated by `startServer` in
[src/server/appServer.ts](../src/server/appServer.ts):

1. **CWD capture at the CLI entry.** The very first statement evaluated in
   [src/cli/main.ts](../src/cli/main.ts) is `const invokedCwd = process.cwd()`, before
   anything can `chdir`. That value flows into `startServer` as `invokedCwd`.
2. **Realpath root.** `resolveWorkspaceRoot`
   ([src/workspace/root.ts](../src/workspace/root.ts)) resolves `fs.realpathSync` of the
   candidate and records its `dev`/`ino`. The root is never derived from `__dirname`,
   the package directory, an npm cache, `$HOME`, or a Git root, and there is no automatic
   climb to a monorepo root. The filesystem root, the home directory, and unusually broad
   roots (`/Users`, `/home`, `/Volumes`, `/mnt`, `/media`) are refused unless
   `--allow-unsafe-root` is passed.
3. **Workspace identity.** `mintWorkspaceId(installSecret, root)`
   ([src/workspace/identity.ts](../src/workspace/identity.ts)) is
   `sha256(installSecret ‖ NUL ‖ root)` truncated to `ws_…`. The install secret is a
   per-install random value minted once into the `meta` table
   ([src/store/store.ts](../src/store/store.ts)), so the id is server-minted, stable per
   (canonical root, install), reveals nothing about the path, and cannot be forged by a
   client. `workspaceEpoch` is a fresh random id per boot (`mintEpoch`). If the path now
   resolves to a different `dev`/`ino` than last recorded, boot logs the change but
   proceeds.
4. **Lock state.** If no `publicUrl` is configured, `locked = true`: the server binds and
   answers `/healthz`, but every `/mcp` request returns `401` with a `setup_required`
   body and no auth, interaction, or admin surface is mounted. Configuring a public URL
   sets `issuer = url.origin` and `resource = issuer + '/mcp'` and unlocks the MCP and
   OAuth routes.
5. **Boot reconciliation and interrupted jobs.** Before any mutation is accepted, the
   `Applier` reconciles interrupted changesets (§7), and jobs left `running` under a prior
   epoch are marked `interrupted_on_restart` (`store.markInterruptedJobs`).
6. **Graceful shutdown.** The `start` command installs `SIGINT`/`SIGTERM` handlers that
   call `server.close()`, which closes the HTTP listener (bounded wait), the MCP handler,
   the job manager (signals owned process groups, bounded wait), the intelligence worker,
   and the IPC server, then unlinks the socket and closes the database. An IPC `stop`
   command triggers the same path.

`startServer` returns a `RunningServer` handle exposing the version, root, workspaceId,
epoch, port, lock state, public URL, IPC path, config dir, the assembled services, and
`close()`.

---

## 3. Layered module map

Responsibilities, one line each, linking the real files.

**Config** — trusted global settings and repo-safe hints.
- [src/config/paths.ts](../src/config/paths.ts) — config-dir resolution (platform default or `JUJU_CONFIG_DIR`), 0700 subdir tree, per-workspace IPC socket path.
- [src/config/globalConfig.ts](../src/config/globalConfig.ts) — the only config that grants authority; `publicUrl`, port, host/origin allowlists, secret-deny globs, env allowlist; fails closed on corrupt JSON.
- [src/config/projectConfig.ts](../src/config/projectConfig.ts) — repo `.juju.json`: strict, hints-only, can only narrow (never widen) permissions; invalid files are ignored and reported.
- [src/config/limits.ts](../src/config/limits.ts) — resource budgets (read/tree/search/preview/job/plan/approval/semantic caps).

**Workspace** — identity and the filesystem boundary.
- [src/workspace/root.ts](../src/workspace/root.ts) — realpath root rules and unsafe-root refusals.
- [src/workspace/identity.ts](../src/workspace/identity.ts) — install-secret-derived workspaceId and per-boot epoch.
- [src/workspace/fs.ts](../src/workspace/fs.ts) — `WorkspaceFS`, the shared path policy every service routes through (§4).
- [src/workspace/ignores.ts](../src/workspace/ignores.ts) — three-layer exclusion engine: secret-deny, protected, ordinary (`.gitignore`/`.jujuignore`).

**Security** — the authority model and child-process hardening.
- [src/security/policy.ts](../src/security/policy.ts) — scopes, action classes, and the trust-mode decision matrix.
- [src/security/approvals.ts](../src/security/approvals.ts) — per-action local approvals bound to principal/workspace/epoch/digest/expiry.
- [src/security/env.ts](../src/security/env.ts) — child-env whitelist and trusted-PATH filtering for jobs.
- [src/security/redact.ts](../src/security/redact.ts) — redaction applied to stored handoff payloads.

**Auth** — embedded OAuth AS + resource-server verification (§10).
- [src/auth/keys.ts](../src/auth/keys.ts) — ES256 JWKS and cookie keys, generated once at 0600, never silently regenerated.
- [src/auth/provider.ts](../src/auth/provider.ts) — `oidc-provider` configuration (PKCE required, resource indicators, workspace-scoped grant reuse).
- [src/auth/adapter.ts](../src/auth/adapter.ts) — SQLite persistence adapter for provider models; `Client` resolves against static registrations.
- [src/auth/verifier.ts](../src/auth/verifier.ts) — access-token verification: issuer, exact audience, ES256, expiry, live grant state, workspace binding.
- [src/auth/interactions.ts](../src/auth/interactions.ts) — owner-consent interaction pages and the IPC-gated approve/complete flow.
- [src/auth/clients.ts](../src/auth/clients.ts) — static OAuth client registration (exact redirect URIs, no wildcards).
- [src/auth/constants.ts](../src/auth/constants.ts) — the single owner account id.

**Store** — durable state.
- [src/store/db.ts](../src/store/db.ts) — SQLite open, WAL, migration-under-`BEGIN IMMEDIATE`, read-only CLI open (§6).
- [src/store/store.ts](../src/store/store.ts) — typed repository over every table.

**Transport / composition** — HTTP wiring and boot sequence.
- [src/server/appServer.ts](../src/server/appServer.ts) — the composition root: config load, root/identity, service assembly, Express wiring, MCP handler, IPC dispatch, shutdown.
- [src/cli/main.ts](../src/cli/main.ts) — the `juju` binary: `start`/`init`/`doctor`/`status`/`stop`/`trust`/`approve`/`auth …`/`recover`.

**Tools** — the MCP surface.
- [src/tools/catalog.ts](../src/tools/catalog.ts) — the fixed-order catalog of 33 tools and `registerCatalog`.
- [src/tools/context.ts](../src/tools/context.ts) — `AppServices`, tool definition shape, `registerTool` (scope/workspace/audit), and the `policyGate`.
- [src/tools/envelope.ts](../src/tools/envelope.ts) — the uniform response envelope and bounded text fallback.
- Tool groups: [projectTools.ts](../src/tools/projectTools.ts), [semanticTools.ts](../src/tools/semanticTools.ts), [changeTools.ts](../src/tools/changeTools.ts), [gitTools.ts](../src/tools/gitTools.ts), [jobTools.ts](../src/tools/jobTools.ts), [metaTools.ts](../src/tools/metaTools.ts).

**Services** — the actual work, all path-guarded by `WorkspaceFS`.
- [src/services/files/readService.ts](../src/services/files/readService.ts) — `read_files`/`list_files`: raw-byte SHA-256, line ranges, explicit truncation, bounded trees.
- [src/services/search/searchService.ts](../src/services/search/searchService.ts) — `search_code`: ripgrep or bounded JS scan over a policy-validated candidate list; opaque single-use cursors.
- [src/services/changes/planner.ts](../src/services/changes/planner.ts) + [applier.ts](../src/services/changes/applier.ts) + [types.ts](../src/services/changes/types.ts) — preview/apply/rollback with a durable journal (§7).
- [src/services/jobs/jobManager.ts](../src/services/jobs/jobManager.ts) + [spool.ts](../src/services/jobs/spool.ts) — process execution and bounded output spools (§8).
- [src/services/git/gitService.ts](../src/services/git/gitService.ts) — read-only, hardened, workspace-scoped Git status/diff.
- [src/services/intelligence/intelService.ts](../src/services/intelligence/intelService.ts) + [tsWorker.ts](../src/services/intelligence/tsWorker.ts) + [protocol.ts](../src/services/intelligence/protocol.ts) — guarded TypeScript language service in a worker thread (§9).
- [src/services/overview.ts](../src/services/overview.ts) — `project_overview`: bounded, read-only bootstrap; manifests parsed as data, never executed.

**IPC** — the owner control channel.
- [src/ipc/server.ts](../src/ipc/server.ts) — AF_UNIX server, 0600 socket, 1 MiB line cap.
- [src/ipc/client.ts](../src/ipc/client.ts) — one-shot request/response used by the CLI.
- [src/ipc/protocol.ts](../src/ipc/protocol.ts) — request/response and `StatusData` shapes.

**Audit** — every tool call is recorded via `store.audit(...)` into the `audit_events`
table ([src/store/store.ts](../src/store/store.ts)) with principal, workspace, tool,
input digest, duration and result code; retention is swept per the configured window.

---

## 4. WorkspaceFS: the shared path policy

`WorkspaceFS` ([src/workspace/fs.ts](../src/workspace/fs.ts)) is the single path
boundary. Every filesystem touch in JUJU — reads, listings, tree walks, search
candidates, change plans and applies, git path scoping, and even the TypeScript worker —
resolves through it. Its public contract is workspace-relative POSIX paths, with `.` as
the root.

`normalizeRel` performs lexical validation only (no I/O): it rejects empty paths,
over-length paths (`MAX_PATH_LENGTH = 1024`), NUL, backslashes, absolute and `~` paths,
drive-letter forms, and `..` traversal, caps segment count (`MAX_SEGMENTS = 64`), and as
defense-in-depth confirms containment with a `path.relative` check rather than a bare
`startsWith`.

`resolve` then walks the path against the filesystem: it refuses secret and protected
paths up front (regardless of `includeIgnored`), and `lstat`s **every** component from the
root down so that a symlink anywhere on the path is rejected — symlinks are never
followed. Direct file access additionally requires a regular file and refuses hardlinked
files (`nlink > 1`). Reads enforce a byte cap with a typed `FILE_TOO_LARGE` error rather
than a silent cut, and strict UTF-8 decoding turns binary/undecodable input into a typed
`UNSUPPORTED_ENCODING` error. `walk` yields a bounded, deterministic traversal that never
follows symlinks and never yields secret/protected entries. `resolveForCreate` validates
creation targets: the final component must not already exist, every existing ancestor must
be symlink-free, and it reports which parent directories would need creating (all inside
the root).

Routing every service through one object is what makes the guarantees uniform: the only
way to obtain an absolute path is to hand a relative one to `WorkspaceFS` first, so no
caller can skip the symlink walk or the secret check. The class comment is explicit that
this is **not** an OS sandbox — pure-Node check-then-use has TOCTOU races against a hostile
same-OS-user actor, and no formal confinement is claimed.

---

## 5. Tool dispatch and the response envelope

Tools are defined with a common shape (`ToolDef` in
[src/tools/context.ts](../src/tools/context.ts)): name, title, description, input fields,
an output schema for `envelope.data`, MCP annotations, a `requiredScope`, an `ActionClass`,
and a handler. `registerTool` wraps each one:

- **Workspace-context fields** are prepended to every tool's input schema
  (`workspaceId`, `workspaceEpoch`) except the bootstrap `project_overview`, which is
  marked `noWorkspaceContext`. The schema is `strict()`.
- **Output** is always `envelopeSchema(def.output)`.

At call time the wrapper runs a fixed gauntlet:

1. **Principal.** `principalFrom(ctx)` reads the authenticated principal from
   `ctx.http.authInfo` — the `grantId`, `clientId`, `sub`, and token scopes populated by
   the OAuth verifier. Missing auth throws `AUTH_REQUIRED` as defense in depth (the
   transport layer should already have refused).
2. **Scope gate.** `scopeSatisfied(def.requiredScope, principal.scopes)` must hold or the
   call fails `FORBIDDEN`. Scopes are `juju:read` / `juju:write` / `juju:exec`.
3. **Workspace-context enforcement.** For non-bootstrap tools, the supplied `workspaceId`
   must equal this process's id (`WORKSPACE_MISMATCH`) and `workspaceEpoch` must equal the
   current epoch (`STALE_WORKSPACE`, i.e. the server restarted). This forces clients to
   re-`project_overview` after a restart and prevents a stale token/args from acting on the
   wrong instance.
4. **Handler** runs with `{ services, principal, trustMode }`.
5. **Audit** is written regardless of outcome.

Mutating and executing handlers additionally call `policyGate` (§7/§8), which consults the
trust-mode matrix and, when required, `requireLocalApproval`.

Every tool returns the **same envelope**
([src/tools/envelope.ts](../src/tools/envelope.ts)):

```
{ ok, workspaceId, workspaceEpoch, data, error, warnings, truncated, nextCursor }
```

It is returned as MCP `structuredContent`, alongside a `text` content block carrying a
compact JSON serialization of the same envelope, bounded to `toolContentBytes`; when the
text would overflow, a small valid-JSON summary is emitted that points the caller at
`structuredContent`. Errors set `isError` and carry a typed `JujuError`
([src/errors.ts](../src/errors.ts)) with a stable machine code, a `retryable` flag, and an
optional non-secret `recovery` hint and `detail`; anything escaping a handler is mapped to
`INTERNAL_ERROR` with no stack or secret leak.

The effective authority of any call is the **intersection** of three independent
constraints: the OAuth token scope, the workspace binding carried in the token and checked
by the verifier, and the local trust mode — plus a per-action local approval wherever the
matrix demands one.

---

## 6. Durable state (SQLite)

State lives in a single SQLite database opened by
[src/store/db.ts](../src/store/db.ts) with `journal_mode = WAL`, `synchronous = FULL`,
`foreign_keys = ON`, and a busy timeout; the file is chmod'd `0600` on POSIX. WAL is
chosen deliberately so the CLI can perform offline writes (trust changes, client
registration) while a server holds the database, and so a reader like `juju status` never
blocks a booting server.

Migrations run inside a `db.transaction(...).immediate()` — a `BEGIN IMMEDIATE` — guarded
by a `schema_migrations` table. Two processes opening the store concurrently therefore
serialize: the second takes the write lock only after the first commits, re-reads applied
versions, and does nothing, instead of both racing the same `CREATE TABLE`.
`migrateWithRetry` additionally spins briefly on `SQLITE_BUSY`. A database from a newer
JUJU version (a migration version beyond what this build knows) is refused rather than
downgraded. The design fails closed: a corrupt or unopenable store is a typed error, never
a silent fallback to in-memory auth state.

`openDatabaseReadonly` opens the same file `readonly` with `fileMustExist` and **no
migration**. CLI helpers that only need to read the install secret to derive the workspace
id (to find the right IPC socket) use it, so they never take a write lock and never
contend with a server's startup migration.

The tables (schema in [src/store/db.ts](../src/store/db.ts), typed access in
[src/store/store.ts](../src/store/store.ts)):

| Table | Holds |
|---|---|
| `meta` | Key/value; notably the per-install `install_secret` used to mint workspace ids. |
| `workspaces` | Per workspace: id, root, `dev`/`ino`, `trust_mode`, `last_epoch`, timestamps. |
| `policy_versions` | Append-only history of trust-mode changes; its count feeds the approval digest so a policy change invalidates outstanding approvals. |
| `oauth_clients` | Static OAuth client registrations (the provider's `Client` model). |
| `oauth_models` | Backing store for all other `oidc-provider` models (grants, codes, tokens, sessions, interactions) with `grant_id`/`uid`/`user_code`/`expires_at`/`consumed_at` columns for the adapter's lookups. |
| `grants` | Application-level grants, **workspace-bound** (`workspace_id`), with client, account, scopes, and revocation timestamp. |
| `pending_approvals` | Both `action` approvals (local trust-mode gating) and `oauth` interaction approvals, with status/expiry. |
| `change_plans` | Immutable preview plans: `plan_hash`, serialized payload, epoch, expiry, invalidation. |
| `changesets` | Apply/rollback lifecycle rows with status and error. |
| `journal_steps` | Per-file steps of a changeset: op, path, dest, before/after hash, backup path, state. |
| `idempotency_keys` | Reserve→receipt records keyed by (key, principal, workspace, tool) with payload hash. |
| `jobs` | Job records: kind, program, args, cwd, pid, status, exit/signal, timing, timeout, epoch. |
| `audit_events` | Append-only audit log (principal, workspace, tool, digest, duration, result). |
| `handoffs` | Structured, redacted progress notes stored outside the repo. |

---

## 7. Change pipeline

File modification is a **preview → approve → apply → (optionally) rollback** flow with a
durable journal, and a boot-time reconciliation that runs before any new mutation is
accepted. The load-bearing property, stated in the `Applier` header comment, is that a
**multi-file change is NOT one atomic filesystem transaction**.

**Preview** ([src/services/changes/planner.ts](../src/services/changes/planner.ts)). The
`Planner` validates every operation through `WorkspaceFS`, computes raw-byte SHA-256 of the
before state and the full after content, and produces a bounded unified diff per file. The
public operations are `create`, `replace_file`, `replace_exact`, `delete`, and `move`; an
internal `span_edit` op backs semantic rename. `replace_exact` requires the target text to
occur exactly `expectedCount` times (default 1) and fails with `AMBIGUOUS_EDIT` otherwise —
it never fuzzy-edits. The result is an **immutable** `StoredPlan` persisted with a
`planHash` and an expiry; the workspace is never modified during preview.

**Approve.** `apply_changes` calls `policyGate` before doing anything. In `inspect` mode a
`mutate-files` action requires a local owner approval; `edit` and `trusted` allow it under
standing consent (§5, [src/security/policy.ts](../src/security/policy.ts)). Approvals are
granted only over IPC (§10) and are bound to the exact action digest, so changing arguments
or local policy yields a different digest and cannot be satisfied by a stale approval.

**Apply** ([src/services/changes/applier.ts](../src/services/changes/applier.ts)). Under a
single-process mutation lock, the `Applier`:

1. Refuses if any changeset is in `recovery_required` (`RECOVERY_REQUIRED`, mutations
   blocked until `juju recover`).
2. Re-validates the plan: principal, workspace, epoch, invalidation, expiry, and
   `planHash` against the stored immutable payload.
3. Re-checks **every** file's pre-state hash on disk; any drift since preview aborts the
   whole apply with `FILE_CHANGED` before a byte is written.
4. Writes the changeset (`committing`) and all journal steps in one DB transaction.
5. **Phase 1 — backups.** Every pre-existing file is copied to a per-changeset backup
   directory durably (temp file, `fsync`, rename). A backup failure marks the changeset
   `failed` and changes nothing.
6. **Phase 2 — apply.** Each file is written with a per-file atomic sequence: create/modify
   write to a temp file in the same directory, `fsync`, then `rename` over the target
   (creates use `wx`/`mustNotExist`); deletes `unlink`; moves `rename`; parent directories
   are `fsync`'d where the platform supports it.

If a mid-apply step fails, `revertSteps` walks the already-written steps in reverse, but
only restores a file when its on-disk bytes still match what the journal recorded. If
revert is clean, the changeset is `failed` and the original error is thrown. If revert
**cannot** account for some bytes, the changeset becomes `recovery_required` and the call
throws `PARTIAL_RECOVERY_REQUIRED` with an actionable report. JUJU never guesses here,
never runs `git reset`/`clean`/`stash`, and never overwrites bytes it cannot account for.
On success, steps are marked `done`, the changeset `committed`, and the plan invalidated.

**Rollback.** `rollback_changes` first verifies that **every** affected file still holds
the changeset's after-state and that backups still hash-match; any human edit since the
apply refuses the whole rollback with per-file conflicts, protecting newer work. The
restore itself is a new journaled changeset.

**Boot reconciliation** (`reconcileOnBoot`, run at startup). For each `committing`
changeset left by a crash: if nothing was written it becomes `failed`; if every step is
written and verifies on disk it becomes `committed`; anything mixed becomes
`recovery_required` and blocks mutations until the owner runs `juju recover`. Files are
never rewritten during reconciliation.

**Idempotency** (`withIdempotency` in
[src/tools/changeTools.ts](../src/tools/changeTools.ts)). `apply_changes`,
`rollback_changes`, `run_task`, and `exec_command` take a client `idempotencyKey`. The
wrapper reserves the key, runs the side effect, then records a receipt. The same key with
the same payload replays the stored receipt; the same key with a different payload is
`IDEMPOTENCY_CONFLICT`; a reservation with no receipt (an interrupted run) reports an
uncertain state and is never blindly re-run.

---

## 8. Jobs

Command and task execution ([src/services/jobs/jobManager.ts](../src/services/jobs/jobManager.ts))
is deliberately **service-layer, not MCP-session**, lifetime. `run_task`/`exec_command`
return a `jobId` immediately; clients poll `job_status`/`job_output` by id. A dropped HTTP
connection or a client reconnect never kills a job, because jobs live in the `JobManager`
and the `jobs` table, not in any protocol session.

Processes are started with `spawn(..., { shell: false, detached: true })` and explicit argv
arrays — there is no shell-string interpolation. The child environment is a whitelist built
by `buildChildEnv` ([src/security/env.ts](../src/security/env.ts)): a small base set plus an
owner-configured allowlist, with `JUJU_CONFIG_DIR`, `NODE_OPTIONS`, `LD_PRELOAD`,
`DYLD_*` and similar always denied, and `PATH` filtered to trusted entries (absolute,
outside the workspace root) so a repository cannot smuggle executables into resolution.
OAuth material and signing keys never reach children. A bare program name resolves on that
trusted PATH; a path-like program must resolve to a regular file inside the workspace.

Output is captured in a **bounded segmented spool**
([src/services/jobs/spool.ts](../src/services/jobs/spool.ts)): fixed-size on-disk segments
with monotonically growing logical offsets. When a per-job cap is exceeded the oldest whole
segment is dropped and `truncatedBeforeOffset` reports the first retained byte — no
unbounded RAM and no silent loss. A global spool budget drops the oldest finished jobs'
spools once the total cap is exceeded.

Cancellation targets **only the process group this process spawned and still holds a live
handle for**: `SIGTERM` to the owned group, then `SIGKILL` after a grace period. It never
signals a bare stored PID, and after a restart old PIDs are never killed — their identity
cannot be verified (PID reuse), so those jobs are marked `interrupted_on_restart`. Trust
mode governs starts: `exec`-class actions require a local approval outside `trusted` mode,
while `job_cancel` (stopping owned work) is always allowed. Wall-clock timeouts and a
concurrency cap come from [src/config/limits.ts](../src/config/limits.ts). Grandchildren
that detach from the group can survive; that limitation is reported in the tool result, not
hidden.

---

## 9. TypeScript / JavaScript intelligence

Semantic tools (`symbols`, `references`, `preview_rename`, and TypeScript `diagnostics`)
run a real TypeScript language service, hosted out-of-band. `IntelService`
([src/services/intelligence/intelService.ts](../src/services/intelligence/intelService.ts))
owns a Node **worker thread** ([src/services/intelligence/tsWorker.ts](../src/services/intelligence/tsWorker.ts))
and enforces a bounded per-request timeout: a hung worker is terminated and restarted and
the request surfaces `TIMEOUT` instead of stalling the server. The worker is `unref`'d so it
never keeps the process alive.

The worker runs the **bundled** TypeScript (a pinned dependency), never a compiler or plugin
loaded from the repository. `tsconfig.json` is read as data via `ts.readConfigFile`; the
parsed options are sanitized — `plugins` stripped, `noEmit` forced, `outDir`/`declarationDir`/
`tsBuildInfoFile` removed, `incremental` disabled — so nothing in the project can cause the
worker to emit or execute code.

Crucially, the worker re-instantiates the **same `WorkspaceFS` policy** (with the same
`IgnoreEngine`) and routes its `readFile`/`fileExists`/`directoryExists` host hooks through
it, with exactly **one** additional allowlist: TypeScript's own lib directory
(`path.dirname(ts.getDefaultLibFilePath({}))`). Files inside that lib dir are read directly;
everything else must resolve to a workspace-relative path under the root or it is invisible
to the service. `realpath` is the identity function so the service never follows symlinks,
the project file list comes from a bounded `WorkspaceFS.walk` (not TypeScript's own directory
reads), and results that would point outside the root — lib and dependency locations,
out-of-scope references, cross-workspace rename edits — are counted but never disclosed, with
`preview_rename` refusing outright when a rename would need edits outside the workspace or
inside `node_modules`.

---

## 10. Auth topology

When unlocked, one origin hosts **both** an embedded OAuth 2.1 / OIDC authorization server
and the MCP resource server. The two roles are kept distinct by URL:

- **Issuer** = the configured public origin (e.g. `https://juju.example.com`).
- **Resource** = `issuer + '/mcp'` — the exact audience of every access token.

The AS is built on `oidc-provider` ([src/auth/provider.ts](../src/auth/provider.ts)) so the
protocol, PKCE, token formats and rotation are the library's, not hand-rolled. PKCE is
required; dynamic client registration is disabled; access tokens are ES256 JWTs (`at+jwt`)
with a short TTL and refresh-token rotation. Provider models persist through a SQLite adapter
([src/auth/adapter.ts](../src/auth/adapter.ts)); the `Client` model resolves against the
static-registration table so `juju auth add-client` entries — exact redirect URIs, no
wildcards, https or loopback only ([src/auth/clients.ts](../src/auth/clients.ts)) — are live
without a provider restart. Signing and cookie keys are generated once at `0600` and never
silently regenerated ([src/auth/keys.ts](../src/auth/keys.ts)).

**Identity and workspace access are separate since 0.2.** New grants carry a
durable identity-v2 marker and tokens carry `juju_auth: 2`. `loadExistingGrant`
can reuse an unrevoked identity grant for the same client across roots; every
request still intersects token scopes with the current workspace/client ACL.
New paths are denied until the owner grants access. `juju_grant` and `juju_ws`
come from the original grant record across refresh. Legacy grants without both
v2 markers remain bound to their original workspace and require a fresh login
to use the identity model; switching roots does not upgrade them implicitly.

**The verifier** ([src/auth/verifier.ts](../src/auth/verifier.ts)) is the resource-server
check on every `/mcp` call. It validates issuer, exact audience, ES256, `at+jwt`, and
expiry, then checks **live** state in the durable store: the grant must exist and be
unrevoked (a revoked grant is rejected immediately, before the access token would expire),
and checks the current workspace ACL for identity grants. For legacy grants,
both `grant.workspaceId` and token `juju_ws` must equal the active workspace.
Refusals never disclose the workspace root.

**Owner consent is a local-IPC gesture, never a public one**
([src/auth/interactions.ts](../src/auth/interactions.ts)). An authorization request creates an
interaction bound to the browser session cookie; the served page shows a request id and a
human-readable check phrase. The owner verifies the same id, phrase, client, callback and
workspace on the terminal (`juju auth pending`) and approves over the private IPC socket
(`juju auth approve <id>`). Only then can the **same** browser session complete the
interaction — a step protected by a required custom header (`x-juju-interaction`) and
`SameSite` cookies. There is no public endpoint that approves an interaction from a request
id alone; approval state changes solely over local IPC.

The IPC socket itself ([src/ipc/server.ts](../src/ipc/server.ts)) is a `0600` file inside a
`0700` directory. Same-UID processes can reach it — that is the documented single-owner trust
boundary. It is the only channel for consent, action approvals, trust changes, grant
revocation, recovery, status and stop, and it is never mounted on the HTTP listener.

---

## What this architecture does not claim

- **No OS sandbox.** The path policy is a Node-level guard with documented TOCTOU races
  against a same-OS-user attacker; exec/task jobs run with the owner's real privileges
  (surfaced in the startup banner, the `trusted`-mode description, and tool descriptions).
- **No central service.** There is no JUJU account, relay, or hosted component; the owner
  runs the tunnel and holds all state locally.
- **No autonomous loop in the server.** The model lives on the client side.
- **One workspace per process.** Serving another directory means another process with its
  own root, identity, and endpoint profile.

## Direct coding tier (v1.1)

[src/tools/directTools.ts](../src/tools/directTools.ts) adds `write_file`,
`edit_file`, `delete_path`, `move_path`, `make_directory` and `glob_files`;
[src/tools/jobTools.ts](../src/tools/jobTools.ts) adds `run_command`;
[src/tools/gitTools.ts](../src/tools/gitTools.ts) adds `git_log` / `git_commit`.
The file tools do not bypass the change pipeline: each builds a single-op plan
with the `Planner` (`source: 'direct'`) and commits it through the journaled
`Applier`, so backups, `change_history` and `rollback_changes` cover them.
`run_command` is `JobManager.start({ shell: true })` (spawns the system shell
binary with `-c`) followed by `waitForExit(waitMs)` and a bounded inline read
of the spool; when the wait expires the caller receives the `jobId` and the
job continues under its wall timeout. See ADR-014.

## 0.3 layout: WorkspaceHost and the Local Config plane

[src/server/workspaceHost.ts](../src/server/workspaceHost.ts) owns the ACTIVE
`BootstrappedWorkspace` of a `juju start` process. Long-lived components no
longer capture a workspace: the MCP server factory calls
`registerCatalog(server, host.current().services)` per request, the token
verifier and consent router take an `active()` getter, the OAuth provider
takes a workspace-id getter, and OAuth/consent state lives on an
installation-scoped store connection opened by
[appServer.ts](../src/server/appServer.ts). Per-workspace resources (the IPC
socket) are started by the host before a swap and closed after it.
`host.switchTo({ path })` implements the owner-triggered switch
(validate → refuse on running jobs → `switching` gate + bounded drain →
refuse if another process serves the target → bootstrap + readiness → IPC →
commit → tear down old → deny orphaned approvals → audit); see ADR-019.

[src/server/localConfig.ts](../src/server/localConfig.ts) is the owner-only
control plane on 127.0.0.1:21731: loopback + Host/Origin/proxy-header checks,
an 8 h per-process bearer capability, a 120/min flood bound, and a static UI
under [src/server/configUi/](../src/server/configUi/) (copied to
`dist/server/configUi/` by `scripts/copy-ui.mjs`; CSP `'self'` only).
Routes: `GET /api/state`, `POST /api/config` (saved trust, public origin),
`POST /api/access` (per-workspace client ACL), `POST /api/workspace/switch`.
Entries without a host (stdio, tests) get a static single-workspace host whose
switch answers 501 NOT_SUPPORTED.

In 0.3.1, every POST on this private plane also requires `x-juju-workspace`
and `x-juju-epoch` to match the active root/epoch while the host is ready;
otherwise it returns 409 `STALE_WORKSPACE`. UI forms retain their rendered
context, including across confirmation dialogs, and discard unsaved inputs
when a later state read discovers a different workspace/epoch.

HTTP request lifetime and tool-handler lifetime have separate in-flight
leases. The MCP factory captures the workspace and supplies `beginTool`, which
refuses a stale/closing host and holds a lease through policy checks, execution,
audit and response construction. Disconnecting the HTTP client does not end
that lease. The host stays switching through old-resource teardown and close
waits for handlers before closing workspace stores. The HTTP listener destroys
old connections before OAuth stores close. There is no fixed timer that closes
a database underneath a stuck handler; such a handler can delay shutdown.

## v1.2 layout: shared bootstrap, two transports

[src/server/bootstrap.ts](../src/server/bootstrap.ts) builds everything a
serving entry needs — config, store, workspace identity, `WorkspaceFS`, and all
tool services — and [src/server/ipcDispatch.ts](../src/server/ipcDispatch.ts)
implements the owner IPC commands once. Two entries layer transports on top:

- [src/server/appServer.ts](../src/server/appServer.ts) — HTTP + embedded OAuth
  (unchanged model), and
- [src/server/stdioServer.ts](../src/server/stdioServer.ts) — `serveStdio` for a
  local client, setting `services.localPrincipal` (the only place it is set).

[src/server/optionalServices.ts](../src/server/optionalServices.ts) attaches the
LSP adapter ([src/services/lsp/](../src/services/lsp/)) when the global config
registers languages. [src/services/jobs/sandbox.ts](../src/services/jobs/sandbox.ts)
builds the seatbelt/bwrap wrapper and
[sandboxWiring.ts](../src/services/jobs/sandboxWiring.ts) decides when it
applies; `JobManager.start({ shell, sandbox, network })` and
`waitForExit`/`inlineOutput` back `run_command`, `run_commands`, `job_wait`.
[src/services/search/searchWorker.ts](../src/services/search/searchWorker.ts) is
the terminable regex worker. New tool files:
[extraTools.ts](../src/tools/extraTools.ts) (apply_patch, replace_in_files,
read_image, read_instructions) and [agentTools.ts](../src/tools/agentTools.ts)
(todos, environment_info, fetch_url).

## 0.4 desktop extension

`DesktopService` is bootstrapped per workspace and closed with it. Its policy
is stored in private meta by workspace ID. Temporary grants bind the active
epoch/deadline. Since 0.4.1, explicit `--persist` grants have no deadline and
are rebound to the current epoch when that same root starts again. Each write
has a fresh revision, invalidating old snapshots even when the CLI replaces
identical consent outside the service. Snapshots are memory-only; digest/state action
receipts are durable. `NativeDesktopBackend` runs the source-hash-addressed
Swift helper from the private state directory with minimal environment, fixed
argv, bounded stdin/stdout and timeout. Only local setup compiles it.

The five desktop tools append to the existing catalog (48 total). Desktop
content uses juju:exec; status alone uses juju:read. The private IPC dispatcher
handles temporary consent. Owner CLI writes persistent consent or revocation
directly to the same SQLite policy store, so setup/revocation work while stopped;
each operation reads current policy. Local Config exposes the policy and a
disable-and-forget button only. The overview advertises setup instructions without inspecting the
screen. Native APIs and residual limitations are documented in ADR-020.

## 0.4.2 owner shutdown from any CWD

`juju kill` reads the configured installation store without migrating/writing
auth state. It discovers registered workspace sockets (including long-path
fallbacks) and per-instance control sockets in `ipc-instance:*` metadata.
The latter keep multiple stdio processes discoverable even if their root alias
is replaced. Instance entries contain socket keys and workspace identity, not
credentials. Owner control cleanup removes only its own runtime entry.

Discovery checks same-user private socket/parent modes, live status and the
registered root/workspace ID. It probes before shutdown, deduplicates live
processes, prefers instance sockets, and sends an expected PID/epoch with stop.
The CLI never signals a stored PID or arbitrary port owner. Calls, responses
and shutdown waits are bounded. Unresponsive/unverifiable targets are reported,
not called stopped. OAuth state/config survives. Jobs stop accepting work and
cancel owned groups before MCP handler draining; close returns a shared promise.

## Large coding input budgets (0.4.3)

Owner CLI profiles merge only command/file/plan/request budgets into global config.
Active values are advertised by project_overview. Wire schemas expose hard maxima;
UTF-8 runtime checks enforce active limits. HTTP verifies the bearer token before
JSON parsing; STDIO uses the official SDK transport with configured maxBufferSize.
Shell source above 16 KiB lives in a private per-job file and the shell receives
a short path; stdin is free for job_input. Ownership is recorded before spawn;
normal close/cancel/spawn failure cleans the source. Crash residue never autoruns.
Native argv has separate element/aggregate byte caps. Full-file state and hashes
remain, while diffs and command/argument echoes stay bounded. Literal replacements
preflight expansion; patch and bulk-edit results enforce incremental budgets.
See ADR-023 and LARGE_INPUTS for exact limits, script semantics and client limits.

## 0.6.0 permission layers and scheduled execution

Authentication (OAuth or local STDIO principal) -> workspace scope ACL -> required
tool scope -> workspace ID/epoch -> existing trust/action/Desktop policy -> handler.
Unauthenticated or unauthorized clients do not receive workspace context.
project_overview bootstraps workspace context without a chat identifier.

There is no UsageService, usage timer, chat parser/whitelist service or common
chat/usageId schema. Job attribution uses the authenticated grant ID (or the
local STDIO grant). Both transports advertise the same direct-access instructions.
Old migrations 5/6 remain for safe upgrade sequencing; their usage_consents and
chat_permissions tables are inert history. No destructive database reset or
OAuth migration is required. See [ADR-026](adr/026-remove-conversation-confirmation.md).

ScheduleService takes immutable local/remote proposals and launches only after
local digest approval. An internal one-second timer exists only on the active
HTTP host. It pauses during workspace switching and stops before job teardown.
Due slots have a unique durable claim, missed/overlapping ticks are skipped,
and no uncertain job is rerun automatically. Existing JobManager owns subprocess
identity, sandbox wrapping, clean environment, runtime and bounded spools.

## 0.6.1 current-workspace client list

Local Config /api/state includes only registered clients whose decoded ACL has
at least one scope for host.current().workspaceId. Revoked, absent or malformed
ACLs are excluded. OAuth registrations remain installation-wide; no migration,
client deletion or grant change is performed by listing.

The collapsed add-client picker loads GET /api/access/available only on demand.
It requires the private capability, existing local origin/Host/forwarding checks
and the reviewed workspace ID/epoch. It returns unassigned client ID/name/type,
not client secrets, callbacks or other workspace paths/scopes. Adding explicitly
uses the existing access route with addOnly=true, refusing stale additions that
would overwrite an already-active ACL. Workspace switching clears the picker
and invalidates in-flight responses. Existing card updates/revocations remain
bound to the workspace and epoch reviewed when rendered. See ADR-027.

## 0.6.2 client deletion

src/auth/clientDeletion.ts builds safe review summaries and atomically deletes
reviewed client selections. Local Config exposes GET /api/clients/manage and
POST /api/clients/delete exclusively on the owner listener; no MCP tool change.
Manage returns up to 100 IDs/names/counts/hashes, not secrets/callbacks/other paths.
The private JSON body budget is 32 KiB to accommodate a bounded 100-item selection.

The transaction denies related approvals, marks application grants revoked,
removes client/grant-bound provider models and all selected-client ACLs, then
removes registrations and audits each deletion. Historical application grants,
project changes/jobs/schedules and unrelated provider sessions remain. Missing
registrations are rejected by the resource verifier and interaction completion.
Review fingerprints plus workspace binding protect stale owner forms. See ADR-028.
