Ctrl K
ComunidadGuías…

GEMINI.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Nimbus — Gemini CLI Context

## Project Overview

Nimbus is a **local-first AI agent framework**: a headless Bun Gateway that maintains a private SQLite index of the user's data across ~90 cloud services (Google / Microsoft / GitHub / GitLab / Slack / Jira / Notion + observability, CI-CD, security-quality, feature-flags, GitOps, data-BI, deploy, finance, and support tools — full roster: `CONNECTOR_VAULT_SECRET_KEYS` in `packages/gateway/src/connectors/connector-secrets-manifest.ts`), optional `[[filesystem.roots]]` indexing, and the local filesystem via first-party MCP (Model Context Protocol) connectors, and executes multi-step agentic workflows on the user's behalf. Clients (CLI, Tauri 2.0 desktop) talk to the Gateway only over JSON-RPC 2.0 IPC.

**Runtime:** Bun v1.2+ / TypeScript 7.x strict (two pins stay on TypeScript 6: `packages/docs`, because `astro check` refuses TypeScript 7.0, and the root `typescript-compiler-api` alias, which `scripts/cleanup/` and `scripts/structure-audit/check-connector-hosts.ts` import because 7 ships its compiler API only under unstable subpaths, and which `audit:boundaries` hands to dependency-cruiser because that accepts only `typescript` below 7) · **Linter:** Biome · **License:** AGPL-3.0 (GNU Affero GPL; gateway/cli) + MIT (sdk)
**Status:** Phases 1–6 ✅ (Phase 6, Team, closed 2026-07-19). From Phase 6 onward the build order is the Phase 7+ Sequencing Spine overlay (S1 → S5), not the phase numbers. **Spine S1 (Local Brain) ✅** 2026-08-20; **Spine S2 (Local Compute Fleet) ✅** 2026-10-10, then the pre-S3 close-out. **Spine S3 (Open Surface) ✅** 2026-11-04 (opened 2026-10-11): the MCP capture sink (I28, 3 of 3) ✅ 2026-10-15; the extension runtime (I43, 4 of 4) ✅ 2026-10-17; role packs (I47) ✅ 2026-10-26; and Phase 9.5, the marketplace registry, in seven projects — registry protocol and client (I44) ✅ 2026-10-18, publishing ✅ 2026-10-23, the Readwise unbundling pilot (I46, the baked `nimbus-official` registry) ✅, and the trust layer (project 3, I48) ✅ 2026-10-27 in 5 PRs (revoked or taken-down code is refused at install, update and spawn until the owner re-approves; publishers rotate and revoke keys through a registry; the verified-publisher badge; registry maintainer blocks and `nimbus extension report`; `nimbus trust maintain`; registry-verified build provenance; and the Nimbus trust feed `nimbus-agent/nimbus-trust` baked into the release with a verified snapshot). Dependencies and starter packs (project 4) ✅ 2026-10-29 in 5 PRs (a registry install fetches its dependencies from the registry each declaration names, in one approved plan; connector requirements with their status and a post-install walk; `kind: "starter-pack"` packs that never run; updates that add dependencies list them in their approval and install them all or nothing). `nimbus eval` (project 5) ✅ 2026-10-30 in 5 PRs (the `evals/*.eval.json` format; a sandboxed, gateway-free runner with no network and no credentials, invariant I49; `extension.eval` for an installed extension behind one owner approval; registry CI that scores each version and signs the score into the index; and `nimbus eval --installed`, which evaluates every enabled installed extension behind one approval and compares each score with the one its source registry signed for the same version, though no live registry publishes scores yet). The quality layer (project 6) ✅ 2026-11-03 in 12 PRs (permission transparency and evidence lines; registry-signed scores and a baseline; liveness checks published under a separate key (I51); golden suites recorded with `nimbus eval --record`; local network observation and `nimbus extension audit` on all three OSes with warnings; owner-approved narrower network grants (I52)). Author onboarding (project 7) ✅ 2026-11-04 in 4 PRs (optional manifest `funding` and `license` with `nimbus extension sponsor`; `nimbus extension clone`, the gateway's `registry.cloneSource`; `nimbus scaffold extension --from-openapi`; and the docs site's Authors section, <https://nimbus-agent.dev/authors/>). That completed all seven Phase 9.5 projects and closed S3. **S4 (Autonomous Agent) is the active slot** (opened by the owner 2026-11-04): Phase 9.7 Wave 1 ✅ 2026-11-05 (Project A, the model's connector-write door, A1–A5b) — A1 (one connector-write row table and a generated manifest, #1883) ✅ 2026-11-04; A2 (the typed consent preview of a connector write) ✅ 2026-11-04; A3 (the per-write coverage proof: every connector write prompts, is ledgered before dispatch and is refused over federation) ✅ 2026-11-04; A4 (the owner's opt-in: `nimbus connector writes enable|disable|status`, the V83 table, off by default for every connector) ✅ 2026-11-04; A5a (plumbing for the model door: `origin: "model"` actions are never delegated, the preview names the credential, a guarded raw per-server listing, share replay refuses write ids, `status` counts offerable writes) ✅ 2026-11-05; A5b (the door itself: on the local owner's `cli`/`ui` non-demo turn the model is offered a tool per enabled, offerable write, every call an `origin: "model"` action through an executor built without delegation, so the owner approves the typed preview and sees `Requested by: the model`; I53, static D39) ✅ 2026-11-05. Project B (Waves 2–4) has started: B0a (the Wave-2 framework: each wave-2 service is listed on its own mesh slot with its own generation behind a per-service, no-network credential check; the V84 `approved_tool_ids` column, so the model is offered only the writes the owner approved by name and an earlier opt-in offers nothing until re-enabled; the door refuses a post to a ChatOps channel or DM and a write to the tribal knowledge base; the MCP act door shows the typed preview with the proposer in an unverified block); B0b (the before-state reader, spec § 5.4: a row may declare a `before` reader, and on the door's executor only the prompt then shows the target's current state beside the new values, read through the same gate and ledgered, refused rather than prompted if it would need approval, dispatchable only as a read of the same connector, capped at 5 s and shown as `unavailable (<reason>)` on any failure; static D40); B1 (messaging and comments) ✅ 2026-11-05: the model is offered its first 12 writes (Slack post and DM, Teams channel and chat posts, a Discord post as the bot, a GitHub issue or pull-request comment, GitLab issue and merge-request notes, and Jira, Linear, Notion and Confluence comments), each behind the owner's typed preview, seven of them with a before-state that names the target; an opt-in from before B1 offers none of them until the owner re-runs `nimbus connector writes enable`; the model door AND the MCP act door refuse a post to a channel ChatOps listens on, a broadcast mention (Slack `<!here>`-style, GitHub team, GitLab `@all`, Teams `<at>`) and a GitLab quick action (`ERR_CAPTURE_ACTION_REFUSED` on the act door); the comment and Discord writes became MCP-proposable (34 proposable types); B2a ✅ 2026-11-05: a before-state reader may take a second read of the same connector (each its own gated, ledgered read, one 5 s budget, all or nothing) and carry a guard that refuses a write inside the executor's gate before any prompt, fail closed; B2 (issues) ✅ 2026-11-05: the model is offered 25 writes (B1's 12 plus 13 issue writes: create on GitHub, GitLab, Jira and Linear; Jira and Linear update; the Jira transition; GitHub/GitLab labels add, assignees add and state set), each with a typed preview, and nine with a before-state (the Jira transition reads twice and shows From, Transition and To); labels and assignees are add-only (pinned against the installed connectors); a GitLab scoped label (`key::value`) is refused on both doors; a Linear `assigneeId` is allowed on the model door only while the read proves the issue unassigned (the B2a guard) and is always refused on the act door (`actDoorRefusal`); the broadcast and quick-action refusals cover issue titles, bodies and descriptions; seven issue writes became MCP-proposable (41 proposable types at B2); B3 (drafts and pages) ✅ 2026-11-05: the model is offered 34 writes (B2's 25 plus 9: a Gmail, iCloud or Outlook draft, which names every recipient on its own line and sends nothing; Notion page create, page update and block append; Confluence page create, update and append), each with a typed preview; five more rows have a before-state, and a page update or append shows a CAPPED excerpt of the current content (at most 1,000 characters per value, at most five Notion blocks); the KB refusal covers every page row on both doors, and an update or append of a page whose parent is the KB destination is refused on the model door by a before-state guard reading a frozen KB-config snapshot (for Confluence, since connectors 0.5.0, a page with the KB parent among its `ancestors`; a read without well-formed `ancestors` still refuses every page in the KB space), while the act door refuses a service's page update and append types whenever a KB destination is configured for that service; the Outlook draft and the Confluence append became MCP-proposable (43 proposable types, 117 denied, 1 `not_yet_offered`: `jira.issue.transition`). B4a (calendar and Drive) ✅ 2026-11-05: the model is offered 37 writes (B3's 34 plus 3: an Outlook or iCloud calendar event create, whose preview names every attendee in full and shows the time zone, and a Google Drive file create, whose before-state reads the target folder: its name, whether it is a folder, whether it is shared); both doors refuse an Outlook or iCloud event whose time does not show its zone (Outlook `YYYY-MM-DDTHH:MM[:SS]` with `timeZone`, iCloud `YYYYMMDDTHHMMSSZ`) and an iCloud event field or attendee with a line break or control character (a hidden-invitee form), and any of the three whose optional field is set to `null` (the preview would show the unset default the connector never applies); proposability is unchanged (43 / 117 / 1); B4p ✅ 2026-11-05: the gateway pins `@nimbus-dev/connectors` 0.5.0, whose `confluence_page_get` returns the page's `ancestors`, so the Confluence KB-child guard refuses only the KB parent itself and a page with it among its ancestors, in any space, and still refuses every page in the KB space when the read has no `ancestors` or any entry has a null or malformed id. B4b (Obsidian) ✅ 2026-11-05: the model is offered 38 writes (B4a's 37 plus the Obsidian daily-note append, whose before-state shows the end of the note it will write), so no Wave-2 row is left untemplated; approving that append in `nimbus connector writes enable obsidian` is also the one thing that lets the Obsidian connector's sandbox WRITE, and only ONE FILE per vault: today's daily note (UTC), which the gateway creates on the host refusing links, never `.obsidian`, `.git`, another note or a folder (owner ruling: a whole-vault grant could plant an Obsidian plugin); the prompt names the note pattern per vault; enable, disable and the date change restart the connector; on Windows every Obsidian spawn first revokes the profile's earlier write ACEs, read from a ledger kept under the config directory (fail closed), and the sandbox helper serialises every DACL rewrite behind one lock; Obsidian indexing no longer follows or reads through a link. B3r (remote content) ✅ 2026-11-06: the model door and the MCP act door (at submit and at accept) refuse a draft, page, note or file body that would load remote content when opened (an external image, embed, frame or stylesheet: an Outlook HTML draft, Confluence storage, Notion blocks and properties, the Obsidian append, and a Drive file whose type is HTML, XHTML, SVG or other markup or whose name ends in a markup extension, or Markdown for `.md`), by a structural scan that never strips or rewrites (anything that does not parse is refused); plain links stay allowed; B3w: the same rule covers a sent Outlook HTML mail (act door; the model is not offered sends), Teams HTML posts, and GitHub, GitLab and Linear Markdown, where every Markdown image is refused; that completes Project B (Wave 2). C0 (2026-11-06; owner rulings OR-W34-1 and OR-W34-3, from the Waves 3-4 design round) moved the ten templated Tableau, Looker, Power BI, Monte Carlo and Bigeye refreshes and acknowledge/resolve rows to wave 3, so the model is offered 48, and the merges, branch delete, tag create and calendar deletes to wave 4; no mail send is ever offered (`MODEL_NEVER_OFFERED_TOOL_IDS`). C1 (2026-11-06) pins `@nimbus-dev/connectors` 0.6.0: its 11 new writes (GitHub and GitLab branch, pull request and merge request creates and updates, the LaunchDarkly toggle and rollout, the Flagsmith feature state, the two Jira Service Management alert writes) are classified as wave 3, ask the owner like any write and are refused over federation (5 are recoverable, 6 are not; at C1 none was offered or proposable, C2 below changed the six VCS ones); and the eight existing wave-3 file, pull request and incident writes (Drive move, rename and trash, the OneDrive move, the GitHub pull request close, PagerDuty acknowledge, resolve and escalate) got a typed preview and a before-state, so the model was offered 56 at C1 (171 HITL types and 113 write rows; 43 proposable, 128 denied, 12 `not_yet_offered`). C2 (2026-11-06) templated the six VCS writes (branch, pull request and merge request create and update; the update previews show the current title, state and base, a base or target change reads `RETARGETS`, and a GitLab title change that drops or adds a draft prefix reads `MARKS READY` or `MARKS AS DRAFT`) and made the four creates MCP-proposable behind a broadcast mention, forge-Markdown remote content and GitLab quick-action refusal on both doors, so the model was offered 62 at C2 (templated 70, 35 with a before-state; 171 types: 47 proposable, 124 denied, 8 `not_yet_offered`); the two updates stay model-door-only, since the connector overwrites the old text and the act door has no before-state to show. C3 (2026-11-06) templated the last five wave-3 writes, so the model is offered 67 (templated 75, 40 with a before-state, 8 rows with a guard): the LaunchDarkly toggle and percentage rollout and the Flagsmith feature-state set (the environment comes first in each preview; a rollout lists every variation's percentage now and after, by name, and is refused unless it names every variation of the flag) and the Jira Service Management Operations alert acknowledge and close (the preview says Atlassian runs the action asynchronously and that Nimbus does not confirm it took effect). All five stay `not_yet_offered` on the MCP act door (it shows no before-state, and variation ids and alert ids are opaque), so 47 proposable, 124 denied and 8 `not_yet_offered` are unchanged. `nimbus connector auth jira --cloud-id <uuid>` (env `NIMBUS_JIRA_CLOUD_ID`, Vault key `jira.cloud_id`) stores the Atlassian cloud id the alert tools need: optional on an `*.atlassian.net` site, required for Data Center or a proxied site, and a re-auth that changes the site without a new id deletes it, as does `nimbus connector remove jira` (re-auth asks for the e-mail, token and site again, and a set `NIMBUS_JIRA_CLOUD_ID` counts as a given id). PagerDuty writes need the e-mail of a PagerDuty user as the `From` header (`nimbus connector auth pagerduty --token <token> --username <email>`). Wave 3 (Project C) is complete; Wave 4 (Project D) has started: D0a ✅ 2026-11-06 (type-to-confirm: every Wave 4 write, on every door, is approved only by the owner typing its confirm text, `<service>/<target>`, which the executor compares exactly; the model is offered a Wave 4 row only with a `confirm` target; `--yes` and scripted consent deny a Wave 4 request; ChatOps, which cannot collect text, refuses it with a fixed reply; I53 amended, I20 gains the typed-text rule, static D41). The `@nimbus-dev/client` and VS Code extension `typed?` follow-up is deferred (see `docs/roadmap.md`). D0c ✅ 2026-11-07 (typed confirmation over ChatOps: a Wave 4 write asked in Slack or Teams posts a card with a form that collects the confirm text, a Slack modal opened by the card's Confirm button or a Teams Adaptive Card `Input.Text`; only the resource owner's exact text approves, a channel text `approve` never does, and the modal open is a ledgered `chatops.confirmModal` post; I20, I23, I29 and static D17 extended, no new invariant, no migration; the Slack app needs Interactivity enabled and the `views:write` bot scope). GitHub Enterprise Server (a carried-in item) ✅ 2026-11-07: one optional Vault key, `github.api_base`, connects the GitHub connector and GitHub Actions to ONE self-hosted instance at a time (`nimbus connector auth github --api-base https://ghe.example.com`, or a `gh` login on that host through `nimbus connector detect`): https only, GHE.com (data residency) refused, loopback, unspecified and link-local hosts refused, RFC 1918 hosts allowed, DNS rebinding not prevented by the literal check; the gateway's own GitHub fetches, the fetch-host boundary, `resolve`'s `fetchable`, Git remote parsing and the repo-context query follow it, a stored base that no longer validates fails closed (no spawn, no request), a write's consent preview names the instance of the credential that will run (`Instance:`; a team entry may carry its own optional `github.api_base`, and its PAT goes only to that host; the line is a fresh Vault read at prompt time on both the model door and the MCP act door, and every PAT reader reads base, PAT, base again and makes no request if the base changed mid-read), and no invariant was added or retired (I15, I29 and I53 gained stated bounds in `docs/SECURITY-INVARIANTS.md`). D1 ✅ 2026-11-07 (Project D, the first Wave 4 writes for the model; pins `@nimbus-dev/connectors` 0.8.0): the model is offered 94 writes, the 67 of C3 plus 27 Wave 4 writes, each behind a before-state read and the type-to-confirm text (GitHub and GitLab merge, GitHub branch delete and tag create, the Jenkins, GitHub Actions, CircleCI and GitLab pipeline writes, the AWS Lambda invoke, EC2 start and stop and ECS service update, the Argo CD sync and rollback, the Flux reconciles, the MLflow promote and stage transition, the Snowflake tag and comment set, and the OneDrive, Outlook and iCloud deletes). A Wave 4 row is offered only after `nimbus connector writes enable <service> --wave 4` and the owner's approval of it by name, so an earlier opt-in offers none. A Wave 4 model write FAILS CLOSED: whenever the current state of its target cannot be read (an unlisted reader, a timeout, a failed or policy-refused read, a selection that shows nothing, a failed second read), or the row has no reader or no preview, it is refused before any prompt and the model gets `{ refused }`. Thirteen rows now carry a before-state guard (the GitLab merge `sha`, the GitHub merge `sha` the model must send equal to the pull request head, the Argo CD rollback id, the GitHub tag `sha` prefix and the CircleCI branch-and-tag refusal join the earlier eight). The seven mail sends and `bitbucket_pr_merge` (no head-commit precondition) are never offered. Not offered, with reasons: `azure_app_service_restart`, `azure_aks_node_pool_scale` and `gcp_cloud_run_deploy` (owner ruling 2026-11-07: the `az` and `gcloud` reads are code-execution class, since on Windows a model-supplied value could reach a `.cmd` wrapper's argv and a before-read runs before approval, so they cannot back a before-read); the four Kubernetes and GKE rows wait for D3's sandbox grants; the four `iac_*` rows shipped at D2 and the two commit writes at D1c (both below). The confirm text now constrains a field only by the literal text that follows it (a slashed branch or an image reference is accepted; a value holding its following separator is refused) and `=` is allowed in values. Seven more services are listed for the door (Bitbucket, Jenkins, CircleCI, GitHub Actions, AWS, Azure, GCP), each with a credential check. Counts at D1: 115 write rows (wave 2 x38, wave 3 x36, wave 4 x41), 98 templated, 68 with a before-state, 173 HITL types; proposability is unchanged (47 proposable, 126 denied, 8 `not_yet_offered`). Stated bounds: a merge's preview-to-dispatch window for GitLab when the model sends no `sha`; the Lambda reader lists the first page and matches the exact name; the 5 s before-read cap may expire on a CLI cold start (the write is then refused); live reply shapes are not verified against live accounts. No new invariant or schema change. D1c ✅ 2026-11-08 (Project D, the two multi-file commits; no connectors release): the model is offered 96 writes, D1's 94 plus `github_file_commit` and `gitlab_commit_create`, the first two BOUND rows (I54). The gateway runs `github_commit_prepare` / `gitlab_commit_prepare` itself, shows the branch head it read, injects `expectedHeadSha` (the model is never shown that field, and a caller-supplied one is refused), and renders the unified diff of the whole change itself from the model's own file text and the head's text, in full or the commit is refused (at most 20 files, 32 KiB of new text, 32 KiB of old text and 64 KiB of diff; the diff escapes every Unicode control character except LF, every format, line-separator, paragraph-separator, surrogate and default-ignorable character and the braille blank U+2800, and doubles a backslash, so no text can pass for another); nothing the connector says about the change is shown. The owner approves by typing `<service>/commit <target>@<branch>`. On the model door ONLY, a commit touching a CI, workflow, hook, dev container, `CODEOWNERS`, `.gitattributes` or `.gitmodules` path, or whose new text looks like a credential or holds a bidirectional control character, is refused before any read or prompt (the owner's own doors are not restricted). ChatOps cannot approve a commit (no typed channel; refused after one ledgered prepare read), the MCP act door still denies both (its prompt would carry no diff), and federation refuses the two prepare tools like the writes (`isConnectorWriteOrPrepareToolId`). The audit row carries `boundValuesSha256`, `diffSha256` and `filesSha256`; neither it nor the egress ledger holds file text. Counts at D1c: 115 write rows (unchanged), 100 templated, 68 with a before-state, 2 bound, 173 HITL types; proposability unchanged (47 proposable, 126 denied, 8 `not_yet_offered`). Stated bounds: the BEFORE side of a diff is the connector's claim (a minus line can be wrong, a plus line cannot, and the write re-verifies the head; a byte-order mark is invisible); GitLab's head check is not atomic with its commit; a build file the path list omits (a `Makefile`, `package.json` scripts) also runs code; commits to a default or protected branch are not refused (the platform decides); live reply shapes are not verified. No new invariant or schema change. D2 ✅ 2026-11-08 (Project D, the plan-bound infrastructure applies; pins `@nimbus-dev/connectors` 0.9.0; V86; I2, I15, I50 and I54 text extended, static D42(b)/(h) extended and D43 new; no new invariant): the model is offered 98 writes, D1c's 96 plus `iac_terraform_apply` and `iac_cloudformation_deploy`, the first rows that run infrastructure tooling, and only inside directories the owner approved (`nimbus connector writes enable iac --wave 4 --workdir <dir> --cloud aws,azure,gcp [--region <r>] [--net <host>]`, stored in V85 `connector_write_iac_grant`; `status` lists them and `disable iac` drops them; a directory that is not the exact canonical spelling of an approved one, or that is a link, an alias, a root or a Nimbus folder, is refused). A bound IaC write runs five ordered phases on one executor: `preflightPhase` (the typed confirm must render, the channel must be able to collect it, the directory and a granted cloud must be approved, all before any child exists), `preparePhase` (a prepare child makes the plan or the change set), settle (the gateway moves the plan file into its own directory, hashes it itself and has `terraform show -json` render it in a child with no network and no credential; a CloudFormation change set is cross-checked against the caller's own input), consent (the owner types `iac/apply <dir>` or `iac/deploy <stack>` after the whole plan or the whole template, led by the counts of deletes, replaces, creates and updates and closed by the plan hash; a plan too large to show is refused, never cut) and dispatch (the apply receives the settled plan path and hash, never the caller's). `iac_terraform_destroy` is an owner-typed action only and is never offered to the model (OR-D2-2); `iac_pulumi_up` is refused on every door before any prompt (saved update plans are experimental); the MCP act door denies every iac row (it has no plan to show); IAM capabilities on a CloudFormation deploy are refused to the model. The gateway alone discards a CloudFormation change set that a refused or declined prepare left unused (an audited, ledgered, no-prompt write of its own, static D43; never after a dispatch). Each Terraform or CloudFormation call runs in its own short-lived connector child under one of five per-run profiles (`plan`, `show`, `apply`, `cfn`, and D3a's `kube`, which runs every Kubernetes call; built only in `connectors/lazy-mesh/iac-runner.ts`) with the granted clouds' exact hosts and credential (none for `show`); on Linux an iac profile answers `pidfd_send_signal` with ENOSYS instead of killing the process, because Go's runtime probes it (owner ruling OR-D2-5; set only by the runner, static D43(d)); on Windows every run has its own AppContainer profile id, whose grants are released on exit and revoked from a persisted ledger before the next start. Whole-branch review fixes (2026-11-08): the write dispatcher keeps the bound-write capabilities (`createConnectorWriteDispatcher` had dropped them, so every iac write was refused on all three doors; a census test now requires every `ConnectorDispatcher` implementer to forward them or be listed); the model's write source reads the V85 workdir grants itself (the Terraform apply was never offered in production); the dispatcher's iac race is the runner's budget plus its escalation grace plus a margin, so the runner's "may be partly changed" text always reaches the caller; the enable prompt's host line follows the sandbox's live posture; and the settle step rescans for a `.git` the plan child planted, before anything is shown (nothing in the owner's directory is ever deleted, so a refused or declined run can leave files the plan wrote). Stated bounds: on macOS (ports only), on Windows (all or nothing) and on Linux without the sandbox helper (all or nothing) a `local-exec` provisioner or a malicious provider in an approved directory could send the credential to any host (on port 443 on macOS, on any port where the network is all or nothing) (owner ruling OR-D2-1); the per-cloud host lists come from documented endpoints and are UNVERIFIED until a credentialed probe; providers and modules are never downloaded (the owner runs `terraform init`); macOS is first exercised by the CI sandbox probe; the Windows dev-tree round-trip test skips (a sandboxed Bun connector cannot start from a dev tree there; only the compiled gateway can). CI installs a SHA-pinned Terraform. Counts: 117 write rows (wave 2 x38, wave 3 x36, wave 4 x43), 103 templated, 68 with a before-state, 5 bound, 7 prepare ids, 175 HITL types, 98 model-offerable, 12 never offered; proposability: 47 proposable, 128 denied, 8 `not_yet_offered`. D3b ✅ 2026-11-10 (Project D, the deploy triggers and mobile release; pins `@nimbus-dev/connectors` 0.10.0; no schema change and no new invariant or static rule): the model is offered 105 writes, D2's 98 plus seven Wave 4 writes, each behind a typed preview, a before-state read and the type-to-confirm text: the Vercel redeploy and promote, the Netlify build, the Bitrise and Codemagic builds, the TestFlight distribute and the Firebase App Distribution distribute. Six services are newly listed for the door (Vercel, Netlify, Bitrise, Codemagic, TestFlight, Firebase; each in the phase-3 bundle, checked by its spawner's own credential test), and each write is offered only after `nimbus connector writes enable <service> --wave 4` and the owner's approval of it by name, so an earlier opt-in offers none. Production deploys ARE offered (owner ruling OR-D3-1: a Vercel production redeploy, a Vercel promote and a Netlify production publish), behind the before-state and the typed text. On the model door only, the gate refuses before any prompt: a Netlify build whose branch equals the site's production branch (a before-state guard that reads `build_settings.repo_branch` and also refuses when it cannot be read; the model passes `production: true` to publish production, which types its own `publish production` text) and a Netlify build with neither or both of branch and production; a Firebase distribute to tester emails (OR-D3-2: groups only, since an address that is not a tester yet is created and emailed an invitation that cannot be unsent); and a TestFlight distribute to a beta group whose public link is on, that is not one of the app's, or whose link state cannot be read (OR-D3-3: the preview always shows each target group's name, kind and public-link state). The owner's own doors are not restricted. The two latest-build readers (`bitrise_build_latest`, `codemagic_build_latest`) end in `_latest`, which is not a read verb, so they are named by id in `EXTRA_READ_TOOL_IDS` (bare and namespaced, with a sync test that fails on any other `_latest` tool in the installed package). The MCP act door denies all seven (classes `deployment`, `pipeline` and the new `mobile_release`). Counts at D3b: 124 write rows (wave 2 x38, wave 3 x36, wave 4 x50), 110 templated, 75 with a before-state, 5 bound, 7 prepare ids, 182 HITL types, 105 model-offerable, 12 never offered; proposability: 47 proposable, 135 denied, 8 `not_yet_offered`. Stated bounds: a TestFlight group's public link can be switched on between the guard's read and the dispatch (the connector-side check that closes it, and the row's `allowPublicLink` field, come with connectors 0.11.0); a Vercel redeploy cannot show the deployment it replaces (the request names no project); the previews of `vercel_get`, `netlify_get` and `firebase_get` follow the vendors' documented shapes and are not verified against a live account; an owner-door Netlify build has no before-state, so a branch that is the production branch reads as a branch build there; a branch long enough to push the confirm text past 200 characters is refused as too long to type. D3a ✅ 2026-11-10 (Project D, Kubernetes; pins `@nimbus-dev/connectors` 0.11.0; V86 `connector_write_k8s_grant`; no new invariant or static rule number): the model is offered 108 writes, D3b's 105 plus three: `k8s_manifest_apply`, `k8s_rollout_restart` and `k8s_deployment_scale`, each behind a typed preview and the type-to-confirm text, and each only while the owner approved a (context, namespace) pair and approved the write by name. The apply is the SIXTH bound row (I54): `k8s_manifest_plan` makes a server-side dry run, reads the live objects and diffs them inside the connector (no `diff` program on any OS, OR-D3-9), and the GATEWAY settles the identity itself (`engine/k8s-settle.ts`, pure, reached only through the capability's `settle`, static D42(h)): it computes the plan hash over the context, the namespace and the exact manifest (`nimbus-k8s-manifest-v2`) and the diff hash over the diff text it shows (`nimbus-k8s-diff-v1`), requires its own parse of the manifest (`js-yaml`, core schema; anchors, aliases, tags and flow collections refused before the parser runs; a duplicate key throws) to AGREE with the connector's scan and with `kubectl`'s own object list, and binds BOTH hashes; the connector's apply re-runs the plan and refuses `the cluster changed since the plan was approved` when its diff no longer matches (OR-D3-8; the residual window is the milliseconds between that re-read and the apply). Secrets are refused on every door (OR-D3-5); the model door also refuses RBAC kinds and privileged or host-access pod settings (OR-D3-6). The owner approves pairs with `nimbus connector writes enable kubernetes --wave 4 --grant <namespace>@<context>` (repeatable; `kube-system`, `kube-public` and `kube-node-lease` are never approvable; each context is pinned to its API server `host:port` and credential form in V86, and the prompt shows both). Every kubernetes call (the nine tools, the reads a before-state needs included) runs in its own short-lived child under a fifth per-run profile, `kube` (`connectors/lazy-mesh/iac-runner.ts`, `wrapIacServerSpec`, static D43(d)): the network is the ONE approved API server; the only credential is a single-context kubeconfig the gateway builds (certificate files inlined; `exec`, `auth-provider`, `proxy-url`, basic auth, impersonation, `tls-server-name`, insecure TLS, non-https and IPv6 servers refused; cluster and context `extensions` are accepted and dropped, OR-D3-13), so exec-plugin clusters (typical EKS, GKE, AKS) are not supported (OR-D3-4); the ONLY writable path is the call's scratch directory (pinned by a test); the pair is re-checked against V86 for every child; one bound apply runs per (context, namespace) at a time. The typed text is derived by the gateway from its own parse: one object `kubernetes/apply <context>/<namespace> <Kind>/<name>`, several `kubernetes/apply <context>/<namespace> <N> objects <first 12 hex of the plan hash>`; the restart types `kubernetes/restart <context>/<namespace>/<name>` and the scale `kubernetes/scale <context>/<namespace>/<deploymentName> to <replicas>`. A restart of any resource other than a deployment has no reader, hence no before-state, and is refused on every door. ChatOps cannot collect the typed text and is refused before any child; the MCP act door denies all three (class `k8s`). The audit row and the egress ledger hold a manifest's size and SHA-256, never its text. Counts at D3a: 124 write rows (wave 2 x38, wave 3 x36, wave 4 x50), 113 templated, 77 with a before-state, 6 bound, 7 prepare ids, 182 HITL types, 108 model-offerable, 11 never offered; proposability: 47 proposable, 135 denied, 8 `not_yet_offered`. Stated bounds: a REAL apply against a real cluster is UNVERIFIED (the CI stub API server cannot exercise client-side validation, which needs the real OpenAPI document); on Windows the AppContainer cannot reach an API server on this machine (loopback isolation covers local-NIC addresses too), so Docker Desktop, kind and minikube clusters cannot take a sandboxed call there, and the enable prompt says so (OR-D3-12; no loopback exemption); on macOS the host limit is by port and on Windows all or nothing, so the residual there is a compromised `kubectl`, not a manifest; a plan contacts the cluster before the prompt (read-only except admission webhooks); the single-object typed text proves which object, not what changes (the manifest and the diff are above it); `k8s_pod_delete` is never offered to the model and `gcp_gke_workload_restart` stays un-offered (its reader is code-execution class, OR-D1-1). **Phase 9.7 ✅ 2026-11-10** (all four waves; owner rulings OR-97-1 and OR-97-2): the Azure App Service restart, the AKS node-pool scale, the Cloud Run deploy and the GKE workload restart (whose only before-state readers are `az` and `gcloud` reads, code-execution class) and the Kubernetes pod delete joined `MODEL_NEVER_OFFERED_REASONS` as a stated exclusion, so of the 124 write rows 108 are model-offerable and the other 16 are never offered, each with a recorded reason (all but two stay on the owner's own doors: the Pulumi apply is refused on every door, and the change-set discard is reserved for the gateway's own cleanup); the standing-approval and taint integration, and the acceptance criterion that a Wave 4 write is never auto-fired by a standing rule, moved to Phase 10. Phase 10 SA1 ✅ 2026-11-11 (standing approvals, the groundwork PR; migration V87; no new invariant, I4 text extended, I55 reserved): every approval now records where its answer came from (`answerSource`: `human`, `auto` for `--yes`, `script`, `unknown` for an older client, `delegate`, `remote` for ChatOps; client-reported, and a reply naming any other source is a denial), the gate decides through an `ApprovalOutcome` (`approved` / `rejected` / `standing_approved`), and `audit_log` and `egress_ledger` accept a fourth `hitl_status`, `standing_approved`, which no code produces yet (a source pin fails if one does before I55 lands). Phase 10 SA2 ✅ 2026-11-11 (deterministic workflow steps; pins `@nimbus-dev/connectors` 0.12.0; no schema change, no new invariant, static D40(b) amended and D39(e) extended): a saved workflow may hold `query` and `write` steps beside its `run` prompts. A `query` is a closed declared form (service, type, bound predicates, a plain select column, a required limit of at most 500) run as one bound SELECT over the local index; a `write` names one wave 2 or 3 connector write and takes each input as a constant or an id an earlier query selected, never a `run` step's output. Both are validated at `workflow.save` and again at every run, and a workflow with no `run` step needs no model. Every write action runs through `buildWorkflowWriteExecutor` (consent bound to the run's client, no delegation, the before-state runner), so the owner is asked on every one and sees the typed preview and the thread's current state; the action carries a gateway-set `provenance` (workflow id, SHA-256 of the steps, step index, run id and a trigger the IPC dispatcher derives from the connection's declared client kind: `cli` gives `owner_cli`, `ui` gives `owner_ui`, anything else none; a `trigger` or `provenance` param and a `paramsOverride` naming a write or query step are refused with -32602) that nothing reads yet (the audit row records it without the field sources). The Gmail thread archive (`gmail_thread_archive`, `gmail.thread.archive`, wave 2, a before-state that reads the thread) is the 125th row: offered to the model after the owner approves it by name, `not_yet_offered` on the MCP act door. The Gmail catalog scopes gain `gmail.modify` and only the Gmail child gets `GOOGLE_OAUTH_SCOPES`, so a connection made before this must run `nimbus connector auth gmail` again, and `nimbus connector writes status` and the enable prompt say so. Counts at SA2: 125 write rows (wave 2 x39, wave 3 x36, wave 4 x50), 114 templated, 78 with a before-state, 6 bound, 183 HITL types, 109 model-offerable, 16 never offered; proposability: 47 proposable, 136 denied, 9 `not_yet_offered`. Stated bounds: the declared query's selection residual and index staleness; the trigger trusts the client's declared kind on the local socket; existing Gmail connections must re-authorise; the CLI's `workflow run` did not declare its kind until SA3, so its runs carried no provenance (every write still prompted). Phase 10 SA3 ✅ 2026-11-11 (standing approval rules; migration V88; new invariant I55, static D44; I2, I4, I20 and I29 text extended): the owner approves a recurring workflow write ONCE. `nimbus approve add --workflow <name> --step <label|n> [--days N] [--max N] [--window "Mon-Fri 06:00-10:00 Europe/Berlin"]` stores a rule (`standing_rule`, `standing_rule_fire`) that names ONE write step of ONE workflow at one content hash, an expiry of 1 to 90 days (default 30), an action ceiling of 1 to 500 (default 100) and an optional weekday and time-of-day window (OR-10-5 closed the confidence score as superseded by this exact match); the gateway derives the scope from the stored workflow's own step, so the caller names only the workflow and step; creating or resuming a rule is the LOCAL owner's approval on a `cli` connection through an executor with no delegation, honoured only when the answer's source is `human` (`--yes`, a script and an old client are refused; `standing.rule.create` and `standing.rule.resume` are HITL types and never delegable). `standingRuleStep` (`engine/standing-rule-step.ts`) runs once in `approvalPhase`, after the confirm phase and before a delegate or the owner is asked, in nine named steps: policy (`standing_approvals` lock-off, absent = off), an `owner_workflow` provenance from a schedule- or owner-started run and no `origin`, every write row of the type being wave 2 or 3 and not bound, typed-confirm, refused or never-offered (and the type HITL by the frozen set), the payload being exactly the row's own tool and input, every field a constant or a declared-query id, a live rule for that workflow id, content hash, step, tool and type inside its window, the rule's scope and constants equal to the action's, the shared door refusals (`modelWriteRefusal`) passing on the actual input, and `claimFire`, one conditional UPDATE plus the fire insert in a single transaction. A hit is recorded `standing_approved` (audit row and egress row both carry the rule and fire ids, appended before the dispatch) and `nimbus audit standing [<rule>]` lists the fires. The executor's 8th constructor parameter is now `ToolExecutorExtras = { beforeState?, standingRules? }`; only `buildWorkflowWriteExecutor` supplies `standingRules` (static D44(e)). `standing` is in `FORBIDDEN_OVER_LAN`, the renderer allowlist (123) names `standing.list|show|fires|pause|revoke` only, and `nimbus workflow run` / `nimbus run` declare kind `cli`. Deviation from the spec, recorded: V88 carries only the rule tables; the scheduler's columns arrive as V89 with SA4. Counts at SA3: 125 write rows (unchanged), 185 HITL types (the two standing types joined), 11 `ai_v2` policy capabilities; proposability: 47 proposable, 138 denied (both standing types), 9 `not_yet_offered`. Stated bounds: the declared query's selection residual and index staleness; `answerSource` and the declared client kind are client-reported on the local socket; a failed audit or egress append after a claim dispatches nothing and the claim stays consumed; windows cannot cross midnight; a rule fires only on an owner-started run until SA4 adds schedules (it then also fires on a scheduled run). Phase 10 SA4 ✅ 2026-11-11 (scheduled workflows; schema V89; static D44(d); I2, I41 and I55 text extended; the Tauri allowlist 123 → 124): a saved workflow can run on a schedule with no one present. `nimbus workflow save <name> --file <path> --schedule "<cron>" [--tz <zone>]` stores a five-field cron schedule and its resolved IANA zone (`automation/cron.ts`, no dependency: numbers, lists, ranges and steps and the four `@` macros; names, `L`, `W`, `#`, a seconds field and anything that can fire twice within five minutes are refused), accepted only from a connection that declared `cli` or `ui` (the dispatcher threads the connection's kind, never a param; `ERR_SCHEDULE_LOCAL_ONLY` otherwise), and refused for a workflow with a model (`run`) step (owner ruling OR-10-8); `nimbus workflow unschedule <name>` clears it and any save without a schedule does. `automation/workflow-scheduler.ts` ticks every 60 s on an injected clock, runs due workflows oldest first, one run per workflow at a time (`automation/workflow-run-lock.ts`), catches up once for a miss of at most an hour and otherwise skips and records it; every fire that did not run is a `skipped` run with its reason. It is the only producer of the trigger `schedule` and is handed a FACTORY for the executor, `buildUnattendedWorkflowWriteExecutor`, so it holds no client id and no consent coordinator; both workflow builders are one-line calls of one private `newWorkflowWriteExecutor` in `engine/run-ask.ts` (the only `standingRules:` and the only `unattended:` value). `approvalPhase` reads `this.unattended` once, after `standingRuleStep` and before a delegate: no rule means `UNATTENDED_NO_RULE_REASON` and nothing is dispatched, so the runner stops the run as `blocked_on_approval` (never pruned), sends ONE OS notification through `showIgnoringFailure` ("Workflow <name> is waiting for you: step <label> needs your approval (nimbus workflow resume <runId>)") and skips the workflow's later fires until you act. `nimbus workflow resume <runId> [--discard]` (`workflow.resume`: `cli`/`ui` only, in `FORBIDDEN_OVER_LAN`, under the same run lock, refused with `ERR_WORKFLOW_CHANGED` when the workflow no longer hashes to the blocked run's `workflow_sha256`) re-runs the declared queries and continues from the blocked step on the owner's own attended executor with a normal prompt; the blocked run becomes `abandoned` and the new run records `resumedFrom`. `nimbus schedule list [--json]` (`workflow.scheduleList`, the one new renderer-allowlisted method) shows next and last fire and any blocked run. A demo-rooted gateway builds no scheduler (I41). The Phase 10 acceptance criterion holds and is a test (`test/integration/automation/standing-schedule.acceptance.test.ts`). Stated bounds: one catch-up fire; no schedule under five minutes; no model steps in a scheduled workflow; the before-state read of a blocked write has already run (a read); a resume acts on the current matches. S4 closes when ALL of these have shipped: Phase 9.7 (all four waves), all of Phase 10 (Core and Stretch), Phase 17 (On-Call Copilot), and the carried-in items (`docs/roadmap.md` § Carried into S4; owner ruling 2026-11-04). Phase 9.5's open remainders are in `docs/roadmap.md` § Carried into S4 (buildable) and § Owner's open items (hosting, Verified Publisher pricing, the grant program, the acceptance counts). **Several shipped capabilities are deliberately narrower than their names — read the claim in code and `docs/roadmap.md` before relying on it:** the MODEL still cannot invoke a generated tool or a computer-use session (`deps.toolgen` / `deps.computerUse` are unsupplied in `gateway-main.ts`; both are CLI/owner-only); remote vision is images-only and audio/video never leave the machine (I37); Linux fleet idle detection reads power only; `nimbus standup`/`oncall` disclose categories with no substrate in `## Gaps`. Invariants I1–I55; schema V89. Dated log (canonical): [`docs/CHANGELOG.md`](./docs/CHANGELOG.md). Status + acceptance criteria: [`docs/roadmap.md`](./docs/roadmap.md).

**Latest release:** `v10.48.0` <!-- x-release-please-version -->

The version above is rewritten by release-please on every release (it is an
`extra-files` entry in `.release-please-config.json`, the same mechanism that
drives `GATEWAY_VERSION`), and `audit:release-please` fails if it ever disagrees
with the manifest. It is on its own line for that reason: the paragraph above
mentions several versions, and an annotation there could rewrite the wrong one.
Hand-maintaining it did not work — it sat at `v1.20.0` for ten releases, through
a major-version boundary.

Release lineage: `v1.0.0` came from the react-router v8 advisory sweep in #835,
not a product break; `v0.1.0` was the first headless GA (2026-05-09 — Gateway +
CLI + VS Code extension; Tauri desktop deferred to Phase 13).

Companion file: [`CLAUDE.md`](./CLAUDE.md) is the canonical source; both must stay aligned when changing commands, roadmap rows, or non-negotiables.

---

## Non-Negotiables

Architectural constraints, not preferences. Do not suggest changes that violate them:

1. **Local-first** — machine is the source of truth; cloud is a connector.
2. **HITL (human-in-the-loop) is structural** — consent gate lives in the executor, not the prompt; cannot be bypassed or configured away.
3. **No plaintext credentials** — Vault only (Windows DPAPI / macOS Keychain / Linux libsecret); never in logs/IPC/config.
4. **MCP as connector standard** — the engine never calls cloud APIs directly.
5. **Platform equality** — Windows/macOS/Linux equally supported. PRs gate on Ubuntu **plus** a macOS and a Windows leg (`pr-quality-cross-platform`) running the SAME whole-repo `bun test` command as the push matrix; pushes run the full 3-OS matrix. See _CI gating_ below for the one thing the PR legs still do not cover.
6. **AGPL-3.0 core / MIT SDK** — dual license is intentional; do not change license fields.
7. **No `any`** — use `unknown` for external data; TypeScript strict mode is non-negotiable.

---

## Security Invariants

Each live invariant (I1–I55) has a production wiring site + an enforcement test in `packages/gateway/src/security-invariants.test.ts`. **Full rationale, anti-patterns, and the triple rule (wiring + docs + test land in the same commit; retire = delete the row, never leave drift):** [`docs/SECURITY-INVARIANTS.md`](./docs/SECURITY-INVARIANTS.md) + the `nimbus-security-invariants` skill — read before adding/auditing any defense.

- **I1** — child-process env scoping via `extensionProcessEnv()` · `connectors/lazy-mesh/*` spawns
- **I2** — HITL frozen-set membership (`HITL_REQUIRED_BACKING` module-private); two stated exceptions, the gateway's own discard of an unused CloudFormation change set (audited, ledgered, no prompt; static D43) and an action a live standing rule approves under I55 inside `approvalPhase` (audited `standing_approved`, ledgered, no prompt) · `engine/executor.ts` `gate()`
- **I3** — HITL gate consults `action.type` only, never `payload.mcpToolId` · same
- **I4** — `hitlStatus` set only by the consent gate (`approved` / `rejected` / `not_required`, plus `standing_approved`, produced only from I55's check inside `approvalPhase`); each prompted decision records its `answerSource` · same
- **I5** — `checkLanMethodAllowed` intrinsic to `LanServer` · `ipc/lan-server.ts`
- **I6** — LAN (local-network) bind defaults to `127.0.0.1` · `config/nimbus-toml.ts`
- **I7** — Tauri `ALLOWED_METHODS` excludes RCE-class (remote-code-execution) methods · `ui/src-tauri/src/gateway_bridge.rs`
- **I8** — restrictive renderer CSP (no `unsafe-inline`/`unsafe-eval`) · `ui/src-tauri/tauri.conf.json`
- **I9** — bound-param SQL; identifiers via `escapeIdentifier` · `db/write.ts`, `db/repair.ts`, `people/person-store.ts`
- **I10** — constant-time compare for hashes/MACs/pairing-codes/tokens · `util/timing-safe-compare.ts` (canonical)
- **I11** — LLM-facing results via `wrapToolOutput`; a user-MCP tool's server-supplied description is closer-escaped (`wrapToolDescription`) and its input schema rebuilt from a structural allowlist (`sanitiseUserMcpInputSchema`) · `engine/agent.ts`, `engine/tool-output-envelope.ts`, `engine/user-mcp-agent-tools.ts`, `engine/user-mcp-input-schema.ts`
- **I12** — DPAPI passes `pOptionalEntropy` from `<configDir>/vault/.entropy` · `vault/win32.ts`
- **I13** — HTTP writes via `WRITE_ROUTE_ALLOWLIST` + bearer auth · `ipc/http-server.ts`, `ipc/http-write-routes.ts`
- **I14** — SQLite writes via `dbRun`/`dbExec`/`dbStmtRun` (static D12) · `db/write.ts`
- **I15** — every lazy-mesh `ServerSpec` via `wrapServerSpec()` → sandbox (static D10); beyond the filesystem MCP's `dataDir`, only the Obsidian spawn may also WRITE, only today's daily-note FILE in each vault found under `[[filesystem.roots]]` (never `.obsidian`, `.git` or any folder) and only while the owner's Obsidian opt-in approved the append (`connectors/lazy-mesh/obsidian-daily-note-grant.ts`; on Windows a fail-closed revoke from a persisted ledger before every spawn); the one other exception is the `iac` and `kubernetes` connectors' five per-run profiles, built only in `connectors/lazy-mesh/iac-runner.ts` (write access to an owner-approved canonical workdir and per-run directories, the granted clouds' hosts and credential, and on Linux ENOSYS for `pidfd_send_signal`; the `kube` profile reaches ONE owner-approved API server, carries a gateway-built single-context kubeconfig as its only credential and writes only its per-run scratch directory; static D43(d)) · `connectors/lazy-mesh/*`
- **I16** — Ed25519 signature verify at install + every startup for `publisher` extensions · `extensions/{install-from-local,verify-extensions}.ts`
- **I17** — federated answering only in `query-gate.ts` (grant+role+consent+namespace filter, leak-proof shape; static D13) · `federation/query-gate.ts`
- **I18** — IdP token validation only in `identity/verifier.ts`; raw tokens Vault-only; federation consults `isOperatorValid()` (static D14) · `identity/verifier.ts`
- **I19** — team-vault secrets consumed only via `invoke-gate.ts` (`answerFederatedInvoke`, principal-polymorphic: peer **or** local operator) → ephemeral team-credentialed connector; leak-proof result, fail-closed on missing secret (static D15) · `federation/invoke-gate.ts`, `teamvault/team-tool-invoke.ts`
- **I20** — a delegated HITL approval is honored only from a live, in-scope, identity-valid delegate; else fall back to the local owner; `registry.add`, every `extension.*` type, `pack.install`, `connector.writes.enable`, `standing.rule.create`, `standing.rule.resume`, every BOUND connector write (I54: a delegate would approve without the prepared facts) and any action with `origin: "model"` always fall back, whatever delegate is active; a delegate's Wave 4 approval counts only with typed text equal to the confirm text, and a path that cannot carry text falls back to the local owner; ChatOps carries it (D0c): the typed value comes from a Slack modal or a Teams card input, only from the resource owner, and a channel text `approve` never approves a confirm card · `engine/delegated-approval.ts`, `chatops/approval-presenter.ts`
- **I21** — quorum counts only DISTINCT authenticated peers (deny aborts, fail-closed) · `engine/quorum/quorum-coordinator.ts`
- **I22** — signature-verified org policy + monotonic-stricter resolution (tighten-only; fail-closed to last-valid/baseline); enforcement reads `EnforcedPolicy`, never raw policy TOML (static D16) · `policy/policy-gate.ts`
- **I23** — ChatOps operational (non-HITL) posts go only through `chatops/reply-dispatcher.ts` to a server-derived `ReplyTarget`, never a caller-supplied destination; arbitrary-destination posting stays behind the HITL-gated `*.message.post` types; the typed-confirmation modal open (`slack_views_open`) is an operational post through the same ledgered wrapper, never federated (static D17) · `chatops/reply-dispatcher.ts`
- **I24** — a federated preflight (action) request executes only behind the LOCAL owner's HITL gate, never on the caller's say-so; the command is resolved from local config only (fail-closed if missing) and runs sandboxed with validated params as env (static D18) · `federation/preflight-gate.ts`
- **I25** — a tribal-knowledge KB capture writes only to the config-pinned destination, behind the LOCAL owner's HITL gate; the caller picks at most a `--target` (static D19) · `tribal/tribal-write-gate.ts`
- **I26** — connector write actions execute only behind the LOCAL owner's HITL gate (I2); the federated invoke gate refuses every write-classified tool id, and every prepare-class tool id, so a peer never triggers a connector write or a prepare read (static D20) · `federation/invoke-gate.ts`, `connectors/connector-writes/`, `connectors/connector-write-registry.ts`
- **I27** — an outbound share leaves the machine only through `share/share-gate.ts` `createShare()` or `share/share-forward.ts` `forwardShare()`: redaction, the LOCAL owner approves the exact preview (`share.publish`), Vault-key signature, `share_records`, audit; a denial emits nothing; received shares are inert (static D21) · `share/*`
- **I28** — content from an MCP client enters only through `mcp-sink/capture-gate.ts` `submitCapture()`, in a fixed order; a proposed decision/term/link writes nothing until the owner's `acceptCapture()`; a proposed connector action leaves only through `mcp-sink/capture-act.ts` `acceptAction()` and then the executor's own HITL prompt; a proposed action that `actDoorRefusal` refuses (the shared messaging check: a ChatOps channel, a broadcast mention, a GitLab quick action or scoped label, a draft, page, note, markup file, sent Outlook mail, Teams HTML message or GitHub, GitLab or Linear Markdown body that loads remote content, an Outlook or iCloud event whose time does not show its zone, or an iCloud event field or attendee with a line break or control character, or a calendar or Drive create with an optional field set to `null`; then any `linear.issue.update` that sets `assigneeId`; then a page write to the tribal KB destination, and, while a KB destination is configured for that service, any page update or append of that service; injected as `actionDestinationRefusal`) is refused with `ERR_CAPTURE_ACTION_REFUSED` at submit and again at accept (static D32(a)–(g)); no bound row (a commit) is proposable, since the act door's prompt replaces the executor's and would carry no diff · `mcp-sink/*`, `ipc/capture-rpc.ts`
- **I29** — egress-ledger completeness: every outbound path appends one BLAKE3-chained `egress_ledger` row BEFORE the request, and a failed append aborts it — connector dispatch at the executor chokepoint, externally-originated agent briefs (`mcp`/`http`), scheduled sync runs and targeted fetches (`sync`), non-local model calls via decorators on the route table, the Mastra agent, embedders and VLMs (`model`, locality derived from `isLocal`, I34), outbound ChatOps posts and typed-confirmation modal opens (`chatops.confirmModal`), the browser lane, generated tools and remote registry requests. Each class's exact coverage and stated bounds: `docs/SECURITY-INVARIANTS.md` (static D22(a)–(h)) · `engine/executor.ts`, `egress/*`
- **I30** — web-clipper tokens mint only inside a live, single-use, in-memory pairing window the owner opened (`nimbus clip pair`); otherwise `POST /v1/clips/pair/confirm` is 403 · `clips/pairing-window.ts`, `clips/clip-token-store.ts`
- **I31** — disclosure integrity: a synthesized brief never says less than the deterministic render — disclosure-only sections are rebuilt and re-attached (never shown to the model), reserved sections the model invents are stripped, interleaved disclosures are anchor-checked from one definition · `agents/_lib/{synthesize,reserved-sections,brief-contract,brief-disclosures}.ts`
- **I32** — clip `source` metadata is rebuilt by `validateClipInput` from five whitelisted fields, never spread from the caller's object · `clips/clip-ingest.ts`
- **I33** — user-supplied code executes only through `exec/exec-gate.ts` `runExecution()`: refuse on config/policy before consent, assert `canConfine(policy)`, read the script once, network empty by construction (loopback included), the LOCAL owner approves the verbatim body, one audit row per outcome (static D23) · `exec/*`
- **I34** — every `LlmProvider` declares `isLocal` from exactly one source — derived from its base URL for a local runtime, hardcoded `false` for a cloud adapter, never from a vendor id · `llm/base-url-locality.ts`
- **I35** — a computer-use actuation reaches the host only via `computer-use/cu-gate.ts` inside a session envelope the LOCAL owner approved: HITL class derived structurally from the observed target, liveness re-checked after every await before host contact, the terminal lane approves complete lines only (static D26) · `computer-use/*`, `egress/browser-egress.ts`
- **I36** — a browser-reachable file question (a forge coordinate) never reaches the federation: `requireFileParam` refuses `namespace`/`namespaces` alongside it · `ipc/agents-rpc.ts`
- **I37** — a media body reaches a non-local model only through `multimodal/media-gate.ts` with a durable per-artifact grant for that vendor; audio and video have no remote tier by two independent mechanisms (static D27) · `multimodal/*`, `egress/vlm-egress.ts`

- **I38** — an unattended fleet run uses a non-local model only when `[fleet] allow_remote` AND the run's remaining budget cover it, enforced by `wrapFleetSynthesisRouter` on both doors (static D28) · `fleet/*`, `platform/assemble.ts`
- **I39** — a runtime-generated tool reaches the network only through `toolgen/toolgen-broker.ts`'s `handleFetch`, to an owner-approved host with that host's credential; the tool process itself has no network (static D29(a)–(c)) · `toolgen/*`, `egress/tool-egress.ts`
- **I40** — a saved generated tool runs only under a LIVE Ed25519 signature, re-verified by `readVerifiedSavedTool` at boot, at registry load and before every spawn (static D29(d)) · `toolgen/toolgen-saved-store.ts`
- **I41** — a demo-rooted process (`NIMBUS_DEMO=1`, the CLI's `--demo`) never reaches the real install, and the demo gateway is inert (ephemeral vault, no sync/updater/telemetry, connector methods refused) · `platform/{paths,demo-root,demo-boot}.ts`, `ipc/server/demo-gate.ts`
- **I42** — every `mcp_*` (user MCP) or `ext_*` (approved extension) action requires HITL at the executor gate and is never delegable; `--model` tools are offered to the local owner only, under the `user_mcp_model_access` lockoff · `engine/executor.ts`, `connectors/owner-code-server.ts`, `engine/user-mcp-agent-tools.ts`
- **I43** — an installed extension's code runs only through `extensions/extension-runtime.ts` `prepareSpawn`: a live `extension.run` grant, pre-spawn re-verification that trusts no earlier pass, a verified per-spawn copy as its cwd, credentials only via `extensionProcessEnv` (static D33(a)) · `extensions/*`, `connectors/lazy-mesh/*`
- **I44** — registry-sourced extension code is installed or updated only through `extensions/registry/registry-fetch.ts`'s verified chain — pinned root key, non-decreasing sequence (refusing different content at an equal one), index hashes, the index-named publisher key (one the Vault trusts, one a signed rotation from a trusted key reaches, one the current Nimbus verification lists, or one the owner accepted by fingerprint) and its I16 signature — and only from the extension's recorded source registry, and a dependency only from the registry its declaration resolves to; an author's clone (`registry.cloneSource`) gets its bytes through the same chain, with no publisher-key decision and no key stored, since nothing it produces is installed or run · `extensions/registry/*`
- **I45** — every gateway `Bun.serve` listener answers 403 unless the `Host` header names loopback (`rejectNonLoopbackHost`) · `locality/loopback-host-guard.ts`
- **I46** — the only renderer-callable method that can create an extension run grant is `migration.accept`, valid only for a gateway-created migration row verified against the baked official registry · `ipc/migration-rpc.ts`, `extensions/migration/migration-engine.ts`
- **I47** — a role-pack layer is written only by `packs/pack-install.ts`, after the LOCAL owner approved `pack.install` over the pack's exact defaults text (static D36) · `packs/pack-install.ts`
- **I48** — extension code whose publisher key is revoked (itself, or reached by rotation from a revoked key that Nimbus does not vouch for), or which is taken down, by an authority whose scope covers it (Nimbus or the publisher everywhere; a registry root only for code from that registry) is never installed, updated or spawned unless the owner's `extension.run` approval acknowledged every current block by statement id; stored blocks are a monotonic union (static D37) · `extensions/trust/trust-blocks.ts`, `extensions/{verified-extension,approve-run}.ts`
- **I49** — extension code runs for evaluation only through `extensions/eval/eval-runtime.ts` `runEvalSession` or `runEvalBaselineSession`, from a staged copy, under the spec `buildExtensionEvalSpawnSpec` builds: no network, no write, baseline env only (no credential), sandbox id `eval.<serviceId>`; the `__nimbus-eval` role opens no Vault, DB or IPC (static D38); a gateway-hosted eval of an installed extension runs only after the LOCAL owner approved `extension.eval`, never delegated · `extensions/eval/*`, `extensions/installed-eval.ts`
- **I50** — on Linux, when a helper is found, a sandboxed child with a declared network reaches it only through the local forwarder: the outer `bwrap` unshares the network and shows no host socket, the wrapper (outside) re-checks every connection against the policy and its address rules, and a wildcard host exists only in the first-party table (the iac clouds' patterns among them, granted per cloud to the plan, apply and CloudFormation profiles; the `kube` profile's one API server is an owner-configured entry, never a wildcard; static rules: none; source and behavioural pins) · `platform/sandbox/{linux,linux-forwarder,net-forward-host}.ts`, `connectors/lazy-mesh/first-party-manifests.ts`
- **I51** — a registry's signed liveness and baseline evidence reaches the owner only as the `Checked` line of an install approval or `registry.info`, never as a decision: the same install with no file, a failing file, a forged one or a file for another version differs only in that line, and update detection and `fetchVerifiedRegistryPackage` make no liveness request (behavioural), while migration accept and spawn are covered by source pins only · `extensions/registry/{registry-liveness,version-evidence,install-from-registry}.ts`, `ipc/registry-rpc.ts`
- **I52** — audit evidence only narrows: observed network evidence is local and never changes a grant by itself; a stored run grant is equal to or narrower than what its manifest re-resolves to (files equal, network a subset, by ONE rule `checkStoredAgainstResolved` at spawn and at approval), and only the owner's `extension.run` approval (`nimbus extension approve <id> --network <a,b|none>`, never a migration or a delegate) writes a narrower one (no static rule of its own; relies on D33(c)) · `extensions/{extension-run-grants,verified-extension,approve-run}.ts`, `ipc/automation-rpc.ts`
- **I53** — a first-party connector write reaches the model only on the local owner's non-demo `cli`/`ui` turn (`CONNECTOR_WRITE_OFFER_BY_KIND`, `unknown` false), for a service the owner enabled up to that row's wave and a row whose tool id the owner approved by name (V84), while policy allows it and the row is offerable now (never a row in `MODEL_NEVER_OFFERED_TOOL_IDS`: the seven mail sends, the Bitbucket merge, the owner-only iac rows and, since the Phase 9.7 close, the four `az`/`gcloud`-read cloud rows and the pod delete); a post to a ChatOps channel or DM, a broadcast mention, a GitLab quick action or scoped label, a draft, page, note, markup file, sent Outlook mail, Teams HTML message or GitHub, GitLab or Linear Markdown body that loads remote content, an Outlook or iCloud event whose time does not show its zone, or an iCloud event field or attendee with a line break or control character, or a calendar or Drive create with an optional field set to `null`, a multi-file commit that touches a CI, workflow, hook or review-rule path or whose new text looks like a credential or holds a bidirectional control character, a Netlify build whose branch is the site's production branch or whose production branch cannot be read (by a before-state guard; the model passes `production: true` instead) or that names neither or both of branch and production, a Firebase distribute to tester emails, a Kubernetes manifest with an RBAC kind or a privileged or host-access pod setting, and a TestFlight distribute to a beta group whose public link is on, that is not one of the app's, or whose link state cannot be read, and a write to the tribal knowledge base or (by a before-state guard reading a frozen KB-config snapshot) an update or append to a page whose parent is the KB destination are refused at the door; a connector write tool runs only under its own action type (the gate and the dispatcher refuse a tool named by `mcpToolId` under another row's type); every call is an `origin: "model"` action through an executor built without delegation (the local owner approves the typed preview; a denial returns a fixed result), re-checked per call (a Kubernetes row only while the owner approved a (context, namespace) pair), and only `engine/connector-write-agent-tools.ts` builds model tools from the write table (static D39); a row's `before` reader runs only on that executor, as one or two `<service>.before.read` actions through the same gate (audited, ledgered, refused rather than prompted when it would need approval) that the dispatcher lets resolve only to a read of the same connector, never past 5 s together, its replies shown to the owner only, and a reader's guard refuses the write inside that gate before any prompt unless the reads prove it allowed (fail closed); a Wave 4 model write is offered per row, by name, only after `--wave 4`, and is refused before any prompt unless its before-state was read, guard or not (`WAVE4_BEFORE_REFUSAL`; the seven mail sends and `bitbucket_pr_merge` are never offered) (static D40); a Wave 4 write reaches dispatch on ANY door (the model's, the MCP act door, the CLI and desktop, ChatOps) only after the owner TYPES the row's confirm text and the executor finds it exactly equal (`requestConfirmedApproval`, never `requestApproval`; a channel that cannot collect text denies it; `--yes` and scripted consent deny it), and a model-door Wave 4 row is offered only with a `before` reader (or a `bound` prepare step, I54) AND a `confirm` target (static D41) · `engine/{connector-write-agent-tools,run-ask,agent,before-state}.ts`, `connectors/connector-writes/confirm.ts`, `connectors/connector-writes/{model-write-source,model-write-refusals,commit-paths}.ts`, `ipc/server/inline-handlers.ts`
- **I54** — a bound connector write (a row declaring `bound`) is dispatched only with the values its own gate-run prepare step produced, injected by the gateway: `preparePhase` runs the row's `<service>.write.prepare` read through the executor's own gate and dispatcher (audited, I29-ledgered) after the before-state and BEFORE the confirm text and any approval, which only the local owner gives (a bound IaC or Kubernetes row's `preflightPhase` runs before it, so a channel that cannot take a typed confirmation, an unrenderable target or an unapproved directory is refused before any child exists; the capabilities of such a row reach the gate only through the dispatcher's `boundWriteCapabilities`, and a row whose tag needs one the dispatcher lacks is refused); a prepare type sent by any other caller is refused (`PREPARE_OUTSIDE_STEP_REASON`); the write's audit row carries a SHA-256 of the injected values (and, for a commit, of the diff the approver saw and of the exact files sent; file text is in neither the audit row nor the egress ledger; a CloudFormation template or a Kubernetes manifest is recorded as its size and hash only); the bound production rows are the two commits, the three iac rows (Terraform apply and destroy, CloudFormation deploy) and the Kubernetes apply (D3a), bound to a plan, change set or manifest identity the gateway settles itself (a Kubernetes plan hash and diff hash it computes from the input it shows, after its own parse agrees with the connector's scan), and the Pulumi apply is refused before any prompt by a row-level `refuse`; the approver sees a unified diff the gateway renders itself from the caller's file text and the prepare reply, in full or the write is refused, and nothing the connector says about the change is shown; `execute()` works on a private copy of the action; a caller-supplied bound field (payload root or `payload.input`) is refused before any read or prompt; the public `gate()` never binds, only `execute()` injects (`dispatchBoundPhase`); every prepare failure refuses on every door with a fixed reason (static D42) · `engine/{executor,prepare-step,audit-files}.ts`, `connectors/registry.ts`, `connectors/connector-writes/{prepare-tools,diff,commit-bound,commit-limits}.ts`
- **I55** — an action is approved by a standing rule only inside the executor's `approvalPhase`, after the before-state, preflight, prepare and confirm phases and before a delegate or the owner is asked, only when it carries a gateway-set `owner_workflow` provenance from a schedule- or owner-started run (`origin` absent), its type is an eligible wave 2 or 3 connector write (never wave 4, bound, typed-confirm, never-offered, or HITL only by org policy), every input value is a constant or a declared-query id, and a live owner-approved rule names exactly that workflow id and content hash, step, tool and value sources inside its window; `claimFire` takes one action of the ceiling and records the fire in ONE transaction, and the gate records `standing_approved` with the rule and fire ids in the audit row and the egress row before dispatch; creating or resuming a rule is the local owner's approval on a `cli` connection, honoured only for a `human` answer, and the whole `standing` namespace is off the LAN; only the two workflow write executors are given the check, the attended one and the scheduler's unattended one that refuses instead of asking (static D44); the declared-query selection residual is stated · `engine/{standing-rule-step,executor,run-ask}.ts`, `standing/*`, `ipc/standing-rpc.ts`, `automation/workflow-write-step.ts`

**Static complement:** `scripts/structure-audit/check-nimbus-invariants.ts` runs before the test suite (fails first; runtime tests stay authoritative). It enforces the static rules D10–D44: the ones named above, plus rules tied to no invariant: D24 (the SyncContext capability boundary), D25 (connector children spawn hidden), D30 (`ensureFullSqlite`), D31 (`registerListener`), D33(b)/(c) (run-grant table and writers), D34 (connector migration) and D35 (the layered config reader). Each rule's exact scope and stated bounds: `docs/SECURITY-INVARIANTS.md`.

---

## Subsystems

**This repository is the Gateway, not the whole of Nimbus.** It holds six workspaces; the six
surfaces listed below live in their own repos and release independently. That balance moved decisively on
2026-08-27, when the 94 first-party connectors left for
[nimbus-agent/nimbus-mcp-servers](https://github.com/nimbus-agent/nimbus-mcp-servers) — before
that, `packages/` carried 100 workspaces and the word "monorepo" described something real. Read a
statement about "the repo" with that in mind: for connectors, the SDK, the IPC client, the MCP
launcher, the editor extension and the browser client, the source of truth is elsewhere.

In this repository:

- `packages/gateway` — Engine, MCP mesh, Vault, local index, IPC
- `packages/cli` — Terminal client (CLI + Ink TUI)
- `packages/ui` — Tauri 2.0 + React (desktop)
- `packages/docs` — Astro Starlight documentation site
- `packages/admin-console` — dependency-free static admin console served at `/admin/*` (Phase 6 Slice 4)
- `packages/registry-format` — pure, Bun-free extension-registry rules (manifest, signing, tarball, index) shared by the gateway, the CLI and the registry CI action; imports nothing from gateway/cli/ui (`registry-format-is-pure`)
- `packages/github-actions/*` — first-party GitHub Actions (annotate-action, preflight-query); tracked but intentionally NOT workspace members

Everything else releases on its own cadence, and a change to one of these is a PR in that repo —
not here. Where the Gateway also carries per-connector sync/indexing logic, a feature touches both:

- The **`@nimbus-dev/sdk`** extension-authoring contract — [nimbus-agent/nimbus-sdk](https://github.com/nimbus-agent/nimbus-sdk) (MIT); the connectors consume the published package.
- The **`@nimbus-dev/connectors`** first-party MCP connectors — [nimbus-agent/nimbus-mcp-servers](https://github.com/nimbus-agent/nimbus-mcp-servers) (AGPL-3.0-only); all 94 ship as ONE package that the gateway depends on and bundles into its compiled binary via `BUNDLED_CONNECTORS`. The gateway's per-connector **sync and indexing** logic stays here, so adding a connector touches both repos. `audit:connector-version-skew` fails the build when this repo's pin falls a MINOR version behind what is published.
- The **`@nimbus-dev/client`** typed IPC wrapper — [nimbus-agent/nimbus-client](https://github.com/nimbus-agent/nimbus-client) (MIT); `packages/cli` and the VS Code extension consume the published package.
- The **`@nimbus-dev/mcp`** MCP-server launcher (`nimbus-mcp` bin) — [nimbus-agent/nimbus-mcp](https://github.com/nimbus-agent/nimbus-mcp) (MIT); resolves the installed `nimbus` binary and execs `nimbus mcp-server --stdio`, so any MCP client reaches the local index and agents. The MCP server itself stays here (`packages/cli/src/commands/mcp-server.ts` + `packages/cli/src/mcp/`). Listed in the official MCP Registry as `io.github.nimbus-agent/nimbus`, republished from that repo's CI on every release.
- The VS Code / Open VSX extension — [nimbus-agent/nimbus-vscode](https://github.com/nimbus-agent/nimbus-vscode); consumes the published `@nimbus-dev/client`.
- The browser client (Chrome + Firefox MV3) — [nimbus-agent/nimbus-web-clipper](https://github.com/nimbus-agent/nimbus-web-clipper); today a **web clipper** talking to the gateway's web-clip HTTP surface (`POST /v1/clips`, invariant I30). The gateway-side surface itself stays here (`packages/gateway/src/clips/`). **Recorded direction (not built):** it becomes a **browser-side gateway client** — an ambient panel that resolves a Bitbucket PR / Jenkins build page to an already-indexed item and runs the existing `agents.*` briefs against it; the editor extension gets the same treatment. Agents are shared, surfaces are contextual. The browser-reachable agent-invocation route now EXISTS — `POST /v1/agents/{agent}` plus `GET /v1/agents/runs/{id}` and `GET /v1/agents`, all behind the `agents` token scope. A resolve-by-URL read now EXISTS too — `GET /v1/items/resolve` behind the `resolve` token scope, keyed on the derived, indexed `item.resolve_key` (added at V52). See [`docs/roadmap.md` § Track 2 → Client surfaces](./docs/roadmap.md#client-surfaces).

**PAL:** OS-specific logic lives under `packages/gateway/src/platform/`, accessed via `PlatformServices` — never import `win32`/`darwin`/`linux` from business logic.

**Dependency rules:** `gateway` imports nothing from cli/ui. `cli` and `ui` reach the gateway IPC-only (no source imports). `sdk` imports nothing from gateway/cli/ui. The gateway's ONLY static import from the connectors package is `setConnectorMode` from `@nimbus-dev/connectors/shared/connector-mode.ts`, in `run-bundled-connector.ts`; beyond it, the generated `connectors/bundled-connector-registry.ts` `import()`s each of the 94 connector entrypoints (`@nimbus-dev/connectors/<id>`), which is how `BUNDLED_CONNECTORS` gets them into the compiled binary. Circular dependencies are forbidden.

**Prerequisites:** Bun v1.2+; Rust for the Tauri UI. Local `nimbus ask` can run through Ollama on `http://127.0.0.1:11434` with `[llm].prefer_local = true` + `[llm].local_model`.

---

## Testing Philosophy

- **HITL tests** prove the gate fires for every whitelisted action type before the connector is called.
- **Vault tests** prove no secret value escapes through any interface.
- **Integration tests** use real SQLite + real Bun subprocesses + fresh temp dirs — no mocks at the DB layer.
- **E2E CLI tests** use a real Gateway subprocess + mock MCP servers — no real cloud calls.
- **Coverage gates** in CI: Engine ≥85%, Vault ≥90%, Embedding ≥80%, plus scheduler/rate-limiter/people and seventeen more (23 scopes, each with its own floor) — enforced by `audit:coverage-scopes` over the merged lcov, NOT by the `test:coverage:*` scripts (their `--coverage-threshold-lines` flag does not exist in Bun and is silently ignored; `bunfig.toml`'s `[test] coverage = false` suppresses collection anyway). Denominator is non-exempt files, matching `audit:coverage-floor`. See `scripts/coverage-floor/check-scopes.ts`.
- Focus on the current phase; do not add Phase N+1 features in Phase N code.

### CI gating

PRs run the Ubuntu `pr-quality` set **and** `pr-quality-cross-platform` — one macOS leg and one
Windows leg, the Windows one split into two `--shard` jobs. Pushes run the full 3-OS matrix. On
macOS and Windows its integration and e2e trees run inside the whole-repo unit job only, not again
as jobs of their own (`scripts/ci/cross-platform-parity.test.ts` holds the unit command to
covering them), and its desktop E2E legs run only when the push changed `packages/ui/`, and
nightly (`schedule` in `ci.yml`). Two exceptions skip the test jobs, both reported as passing: a
docs-only PR, and
release-please's own PR when its diff is a version bump and nothing else
(`scripts/ci/release-only-diff.ts`, run as it is on the base commit) — the tag's Release
workflow runs the full Ubuntu suite again before it builds anything.

**TEN status checks gate the merge, not one.** The _General_ ruleset (14784377) requires all of:
`PR quality — required gates`, `Dependency audit`, `Trivy vulnerability scan`, `Gitleaks secret
scan`, `Gateway audit JSON + connector.remove vault restore`, `Cargo audit (Tauri)`, `Cargo deny
(licenses + advisories + bans)`, `Analyze (javascript-typescript)`, `Analyze (rust)`, and `cla`.
Only the FIRST is an aggregator: `PR quality — required gates` is an `if: always()` job that
`needs:` every other job in the PR-quality workflow, so adding or renaming a gate **inside that
workflow** never needs a ruleset edit, and a red leg there reds the aggregator. The other nine come
from separate workflows (Security, CodeQL, CLA) and are named in the ruleset individually — adding
one there IS a ruleset edit. Two merge rules in the same ruleset are not status checks: every review
thread must be resolved (`required_review_thread_resolution`), and a `code_quality` rule is set at
severity `all`.

This sentence read "exactly one status check gates the merge" until 2026-09-09, and the error is
the kind that costs a merge: on #1475 the Dependency audit and Trivy checks were red, this file
said they could not be blocking, and the ruleset disagreed. Both are required, both were genuinely
blocking, and neither was the PR's fault — they had gone red repo-wide overnight on newly published
advisories, the same sha having passed hours earlier. **When a check is red and this file says it
should not matter, re-derive the list from the ruleset** (`gh api
repos/nimbus-agent/Nimbus/rulesets/14784377`) rather than trusting the prose.

**`main` has no merge queue, and branches need not be up to date: a PR can merge as soon as its
ten required checks are green.** A merge queue ran from 2026-09-30 to 2026-10-02 and was retired
as not worth its cost: every entry re-ran the whole PR gate on the merged result, so each of its
seven merges waited another 21–52 minutes after its PR was already green. No workflow listens for
`merge_group` any more. What that gives up: a PR's checks test it merged into `main` as `main` stood
when they ran, so two PRs that are each green can still break `main` together, and that shows up on
`main`'s push run rather than before the merge. Dependabot is retired too (no Dependabot
configuration, no update PRs; its security alerts stay on): dependencies move in periodic manual
bulk updates that follow
[`docs/CONTRIBUTING.md` § Updating Dependencies](./docs/CONTRIBUTING.md#updating-dependencies),
which also records the packages that must move together and the majors that were previously held
back (re-test each blocker before assuming it still holds). A red Security run on `main` (a push
or the nightly schedule) opens, and on its next green run closes, a "Security gate is red on main"
issue; that job watches only the six Security contexts, so a red CI or CodeQL run on `main` opens
no issue.

**A failing step HIDES every later step in the same job.** Both of these jobs are step sequences,
and a red one reports only its first failure — so "one gate is red" routinely means several are,
serialised one CI round-trip apart. `Dependency audit` runs `bun audit` → `audit:advisories` →
`audit:js-licenses`, and on #1475 each fix revealed the next, three pushes for what looked like one
problem. `Unit + Coverage` runs `audit:coverage-floor` → `audit:coverage-scopes` → the SonarQube
gate, and a floor failure means the scopes gate **never executed at all** — it is not passing, it
is unmeasured. Before pushing a fix for any red job, read its whole step list
(`gh api repos/nimbus-agent/Nimbus/actions/jobs/<id> --jq '.steps[] | "\(.conclusion) \(.name)"'`)
and run the later steps locally too.

**The PR cross-platform legs run the same test PATHS as the push matrix** —
`bun test packages/gateway packages/cli scripts --parallel`, whole-repo, so unit + integration +
e2e alike (a directory argument recurses, and `bunfig.toml` excludes nothing). Both legs pass
`--parallel` (Bun 1.4), which runs files across worker processes and implies `--isolate`: every
file gets a fresh global and module registry, so a `mock.module` in one file can no longer leak
into another on these legs. The Linux `Unit + Coverage` leg runs each package with `--parallel
--no-isolate` (Istanbul instruments each module once per worker, not once per file: 4.7 min
against 14.8 isolated and ~9 serial), and a worker keeps one global for every file it runs, so
cross-file mock leakage remains possible there. Only the remaining flags differ:
the push leg records per-file timings (`--update-timings`) and the PR legs read them, which is why
the parity test compares positional paths only. Both run uninstrumented (macOS/Windows coverage
was never read, and instrumenting the push leg skewed the timings) and through
`scripts/ci/test-with-retry.ts`, whose ONE retry re-runs only the files that failed when the JUnit
accounts for every failure, and the whole command otherwise. An attempt that prints nothing for 5
minutes counts as hung and is killed (`--silence-seconds`); before that, a hung macOS worker idled
to the 25-minute cap. The Windows PR leg runs those paths as
`--shard=1/2` and `--shard=2/2`, split by those timings; parity holds over the UNION of the shards,
and the parity test checks that every OS's shard set is exactly `1/N … N/N`. **`--parallel` is part of the shape: drop it from one
leg and not the other and the two stop running the same thing.**
That equality is load-bearing and was only established on 2026-08-23. Before it, the PR
leg ran `bun test packages/<pkg>/src` per package while the push leg ran everything in one process,
and the difference was where the red lived: of the 200 CI runs on `main` between 2026-08-02 and
2026-08-22, 50 failed and 35 had a failing macOS job — **33 of the 35 were in files the PR gate
never loaded** (`scripts/`, `test/integration/`, `test/e2e/`), so they could only ever be found
after merge. The process shape matters as much as the file set: `mock.module` is process-global, so
a per-package run and a whole-repo run do not have the same mocks in play. #1307 shipped a test that
was green on the PR leg on all three OSes and red on the macOS push leg minutes later, purely
because `packages/gateway/src` never loads the two files under `packages/gateway/test/` that mock
its dependency. **If you change one command, change the other.**

**What the PR cross-platform legs still do NOT cover:** coverage and packaging. Both are Ubuntu-only
by design (the coverage floor is CI-Linux-authoritative; packaging is a separate job).

**A green local run does not predict these legs.** The runner is ~13–18× slower than a dev machine
at temp-dir SQLite work, so every wall-clock assumption in a test is a different number there.

---

## Development Workflow

- **Worktrees:** `.claude/worktrees/<branch-name>` (project-local, git-ignored).
- **Pre-flight before a PR:** `bun run preflight` (full CI parity) or `bun run preflight:fast` (~2-3 min, cheap static gates). **`test:ci` is only the test suite, NOT the full gate set — `preflight` is.** Gate manifest: `scripts/lib/preflight-gates.ts` (drift test fails if a CI gate is missing). Four companions when local green still isn't CI green: `bun run verify:docker` runs the manifest's fast tier inside the CI bun image at a normal path (catches gates that pass only because of a path exclusion or an OS difference; `--full` adds build + `test:ci` + coverage floor; **`--changed` runs only the tests your branch touched** — the fast way to reproduce a Linux-only test failure, which was the largest real PR-failure category and does not reproduce on Windows/macOS at all. A narrow run cannot reproduce cross-file `mock.module` contamination, so a green `--changed` is evidence about your files, not about the suite); `bun run verify:pr` refuses to call a conflicted or still-pending PR green; `bun run typecheck:tests` covers `packages/{gateway,ui}/test/**`, which no tsconfig `include` covers and plain `typecheck` cannot see (`bun run typecheck:tests:update-baseline` re-banks the baseline — required when paying debt down, not only when adding it); and `bun run audit:platform-test-gaps` (advisory, in `preflight:fast`) names the tests in your diff that **cannot run on your OS** — a `skipIf(process.platform === "win32")` test never executes on a Windows box, so local green is silent about it and CI is its first execution. See the `nimbus-preflight` skill.
- **AI Agent PR Quality & Verification (CRITICAL):** To prevent failing PRs and minimize ping-pong cycles, the AI assistant **MUST** run verification locally before finishing any work or proposing changes:
  1. After making any code changes, **always** run `bun run preflight:fast` to check types, linting, and static rules.
  2. If logic or tests were touched, run the specific test suite (e.g., `bun test packages/gateway/src/...`) or the full `bun run preflight` if needed.
  3. If any check/test fails, fix the issue locally before presenting the solution to the user. Do not declare success or stop if there are failing checks.
  4. Ensure any newly added/modified code adheres to all Non-Negotiables and Security Invariants.
- **Branch hygiene:** never commit on `main`/`develop` — `git switch -c dev/<you>/<topic>` and verify `git rev-parse --abbrev-ref HEAD` first. `bun run hooks:install` adds a pre-commit guard + pre-push `preflight:fast`.
- **Never merge with checks still running — that is the main cause of red `main`.** The _General_ ruleset (14784377) requires `PR quality — required gates`, but its only bypass actor is `OrganizationAdmin` with `bypass_mode: "always"` — so for a repo admin the merge button stays live while checks are pending and **GitHub reports nothing when it is used**. Precedent: #1298 merged 2026-08-21 at 17:47:15Z; its required gate did not even _start_ until 17:57:44Z and then failed, putting two broken tests on `main` and reding the release PR (#1301) minutes later. Wait for `PR quality — required gates` to report green, or use **auto-merge** (`gh pr merge --squash --auto`), which merges the PR the moment its required checks are green, so GitHub does the waiting. When triaging a red `main`, **compare the merge timestamp against the required check's `started_at` before assuming a flake** — `gh api repos/nimbus-agent/Nimbus/commits/<sha>/check-runs` — because a merge-before-green looks exactly like a post-merge regression.
- **Commit messages are discarded on merge — the PR title and description ARE the commit.** Squash is the only merge method enabled (`allow_merge_commit` and `allow_rebase_merge` are both off), and the squash commit is built from `PR_TITLE` + `PR_BODY`. Whatever you write in a local commit message never reaches `main`, so: put the conventional-commit type in the **PR title**, because that subject line is what release-please parses for the version bump; put reasoning, and any git trailer you need to survive, in the **PR description**, because that becomes the permanent commit body. A corollary worth remembering in reverse: a bare `Release-As:` line left in a PR description forces a release nobody asked for, so keep it inline or quoted unless a version bump is the intent.
- **A `!` means a USER must act — not that the change felt significant.** `main` went from
  `v2.21.0` to `v7.0.0` in two days across five majors, and exactly ONE of them
  (`v5.0.0`, #1359, which removed the `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` env fallback) was
  a change a user had to do anything about. The other four were not: deleting the internal
  `mcp-connectors` workspace, which ships from npm now (`v3.0.0`), an internal router retype whose own plan
  promised "total config back-compat" (`v4.0.0`), removing `[llm] classifier_model` and
  `remote_model` after they had become **inert** (`v6.0.0`), and tightening
  `LlmRegistryOptions.db` to required in a `private: true` package (`v7.0.0`) — a PR whose own
  body said "no published surface changes" and still carried a `!`.

  Before writing `!` or `BREAKING CHANGE:`, answer one question: **what does an existing user
  have to change to keep working?** If the honest answer is "nothing", it is not breaking.
  Specifically NOT breaking: removing a key nothing reads (behaviour is identical either way);
  any type/signature change confined to a `private: true` package, which has no published
  surface; deleting an internal workspace whose consumers already resolve it from npm; a
  stricter internal invariant. Majors are permanent — the tag ruleset has no bypass actors — so
  a burned major cannot be reclaimed, and spending them on internal tidiness destroys the one
  signal that tells a user to read the release notes before upgrading.
- **Release tags are immutable — a failed release is abandoned, never retagged.** The _Protected release tags_ ruleset enforces `deletion`, `non_fast_forward` and `update` on `refs/tags/v*` and `refs/tags/client-v*`, with **no bypass actors** (admins included), so `git push origin :refs/tags/vX.Y.Z` is rejected with `GH013`. When a release build fails after its tag exists (the gate fails, every build/publish job skips, and no GitHub Release is produced), do **not** try to move the tag onto the fix: land the fix on `main`, mark the dead version in `CHANGELOG.md`, and cut the next version. Force that version with a `Release-As: X.Y.Z` trailer — release-please cuts nothing on its own when every commit since the last tag is `docs`/`chore`/`test`. **Squash-merging replaces the commit message with the PR title and description, so the trailer must be the last line of the PR description or it is silently dropped and no release PR appears.** Precedent: `v1.11.0` (#957 → superseded by 1.12.0).
- **Cross-platform:** build paths with `path.join()` / `os.tmpdir()`, never hardcoded separators; `bun run audit:cross-platform` flags Windows-separator path assertions (escape hatch: `// cross-platform-ok`).
- **CI-Linux-only failures — reproduce, don't guess:** CI is Ubuntu + Bun `1.4` (the `bun-version` default in `.github/actions/setup-nimbus-ci/action.yml`, which also builds the release binary; no test/quality workflow overrides it — `bun-version: latest` appears only in the two scheduled drift sweeps, `org-drift-sweep.yml` and `release-channel-drift.yml`). Some failures never reproduce on Windows/macOS and aren't version-related — chiefly `mock.module` contamination in the combined `bun test packages/cli/src` run (prefer **dependency injection (DI) over `mock.module`** for dispatcher-driven code) and `@types/*` hoisting conflicts. Reproduce on Linux (Docker `oven/bun:1.4`, matching CI — or WSL on a Linux-native copy, not `/mnt/c`) **before** pushing a fix. Do not substitute `oven/bun:latest`: it moves on to the next minor without CI. `audit:coverage-floor` is **CI-Linux-authoritative**. Details: `nimbus-preflight` skill.
- **The skills under `.claude/commands/` are gated, not scratch notes.** `audit:doc-refs` resolves every path they cite (and now every path in `docs/` too), and `audit:status-drift` derives the I13 write-route count _and_ full enumeration from code across `nimbus-http-write-surface.md` + `nimbus-federation-identity.md` — both had gone stale while the `docs/` copies of the same claim stayed correct, because only `docs/` was read. `audit:workflow-lint` likewise now requires `timeout-minutes` on every workflow job with `steps:`.
- **A total that is still right can hide an enumeration that is wrong.** `ALLOWED_METHODS` stayed at 105 across one addition (`agents.negotiate`) and one removal (`connector.list`), so the prose ledger's arithmetic worked while its list named neither. When a count changes, re-derive the _list_, not just the number.
- **A branch-count metric over a GATE measures the security surface and reads it as debt.** The three
  worst Sonar `S3776` (cognitive-complexity) findings in this repo have been, repeatedly, the three
  functions implementing I35 — `cu-gate.ts`'s `runAction`/`openSession` and `cu-classify.ts`'s
  `classifyBrowserAction` — because I35 IS an ordered sequence of checks and each check is a branch.
  **Check what a complex function IS before simplifying it.** When one of these must be brought under
  the gate, the constraint is that the ORDER stays readable as an ordered list at the call site: split
  by PHASE into named steps invoked in sequence (`assertOpenAllowedBeforeConsent`, then
  `resolveSessionBounds`, then `buildEnvelope`), never by extracting a check into a helper a reader has
  to go and find. Two further rules learned the same way: state that a single `finally` must write
  exactly once belongs in ONE mutable record passed to the arms, not in each arm's own closure; and a
  check both arms need (approve, then re-check liveness) belongs in ONE function, because two copies is
  two places for it to go missing.

Full command catalogue + coverage thresholds + env overrides: `nimbus-commands` skill. File-location pointers: `nimbus-file-map` skill.

---

## See Also

- [`docs/architecture.md`](./docs/architecture.md) — subsystem design, IPC method catalogue, schema reference. Read before modifying any subsystem. **It is now the design record too**: with the delivered specs pruned, [§ Spine S2 Subsystems](./docs/architecture.md#spine-s2-subsystems) carries the shared-gate shape and the per-capability decisions (and the rejected alternatives) for exec / computer-use / multimodal / toolgen / fleet.
- [`docs/roadmap.md`](./docs/roadmap.md) — phases, acceptance criteria, delivered summaries.
- [`docs/SECURITY-INVARIANTS.md`](./docs/SECURITY-INVARIANTS.md) — I1–I55 rationale + anti-patterns.
- [`docs/cli-reference.md`](./docs/cli-reference.md) — full CLI subcommand reference.

---

## Skill References

Domain skills live in `.claude/commands/nimbus-*.md`, **loaded on demand** via the Skill tool (or `/<name>`) — each carries a `description` that drives when it triggers.

| Skill | Use when… |
| --- | --- |
| `nimbus-architecture` | Placing/naming new code, package ownership, IPC design — read first for any Gateway-touching task |
| `nimbus-file-map` | "Where does X live?" — pointer index to high-traffic files |
| `nimbus-commands` | bun scripts, CLI subcommands, coverage-gate names, env overrides, `bun add` safety |
| `nimbus-ipc` | Adding/designing an IPC method, notification, or streaming contract; Tauri-exposure check |
| `nimbus-testing` | Choosing a test layer, file location, coverage gate, or mocking the Gateway |
| `nimbus-preflight` | What to run before pushing; why `test:ci` ≠ full gate set; cross-platform/CI gates |
| `nimbus-security-invariants` | Adding/auditing a structural defense (the wiring + docs + test triple rule) |
| `nimbus-tauri-allowlist` | Exposing a method to the renderer (`ALLOWED_METHODS`, I7) |
| `nimbus-http-write-surface` | Adding an HTTP `POST`/`PUT`/`DELETE` route (`WRITE_ROUTE_ALLOWLIST`, I13) |
| `nimbus-tool-output-envelope` | Feeding tool results to the LLM (`wrapToolOutput`, I11) |
| `nimbus-connector-authoring` | Building/modifying a first-party MCP connector |
| `nimbus-db-migrations` | Authoring a SQLite migration or new table |
| `nimbus-embedding-routing` | Embedding-table routing for a new item type; `nimbus index reembed` |
| `nimbus-index-body-depth` | Indexing an item body or changing connector index depth (V48/V49 `item.body`, the `upsertIndexedItemForSync` chokepoint, `nimbus index rebody`) |
| `nimbus-cicd-data-layer` | DORA metrics, preflight checks, deployment annotation (Phase 5 T4) |
| `nimbus-data-warehouse-lineage` | Authoring/extending a data-warehouse or BI connector + the cross-warehouse lineage graph (Phase 6 Slice 7); no-row-data contract, V40 lineage edges, `normalizeDataModelKey` |
| `nimbus-federation-identity` | Phase 6 Team federation (I17 query gate, namespaces/RBAC, pairing/discovery) + identity/SSO/SCIM (I18, OIDC device-code, SCIM-on-I13); touching `gateway/src/{federation,identity}/` |
| `nimbus-share-virality` | Authoring/auditing the Share & Virality subsystem (outbound share-gate I27/D21, redaction, V41 share_records, V42 recipes, `share.*` IPC + `nimbus share` CLI) |
| `nimbus-egress` | Auditing/authoring the Egress Ledger & `nimbus prove` (I29/D22 executor dispatch chokepoint, V44 `egress_ledger`, BLAKE3 chain, `egress.*` IPC + `nimbus prove`/`nimbus egress` CLI) |
| `nimbus-agent-patterns` | Authoring a built-in read-only agent |
| `nimbus-implicit-knowledge-extraction` | Authoring/auditing the glossary + decisions extraction pipelines (`gateway/src/{glossary,decisions}/`, V45/V46/V47 tables, the debounced post-sync pass, `glossary.*`/`decisions.*` IPC) |
| `nimbus-sandbox-and-extensions` | Third-party code end to end: the per-OS `SandboxRunner` (I15/D10), user MCP servers (`connector add --mcp`, I42) and the S3 extension runtime (install gate, V70 run grants, I43/D33, `approve --model`); per-OS sandbox traps; touching `gateway/src/{extensions,platform/sandbox,connectors/lazy-mesh}/` |
| `nimbus-mcp-sink` | The MCP server's write doors (I28/D32(a)–(g), V67–V69): `capture_note`, `propose_*`, `propose_action`, `nimbus captures *`, `[mcp_sink]`, making an action type proposable; touching `gateway/src/mcp-sink/` or `ipc/capture-rpc.ts` |