# Soul Stack Name Dictionary All system entities are named according to this dictionary. Names are required in the code (packages, types), in the API (fields, endpoints, MCP tools), in the configuration (flags, keys), in logs, metrics and user documentation. The primary terms are ours; use them consistently. Introduction of a new name - through the propose-and-wait rule in [CLAUDE.md](../CLAUDE.md). ### Criterion "large → Soul Stack, small → familiar" Clarifies **when** the name is taken from the "soulful" metaphor of Soul Stack, and when from the usual DevOps/Developer vocabulary (the propose-and-wait rule itself for any new name remains in force - this criterion only sets the direction of choice): - **Large blocks** - entities, subsystems, run phases, artifacts, protocols - are named in the spirit of the "spiritual" metaphor: `Keeper`, `Souls`, `Destiny`, `Soulprint`, `Archon`, `Reaper`, `Voyage`, `Choir`, `Covenant`, `Augur`, `Sigil`, `Herald`, etc. - **Small units** - config keys, flags, fields, enum values, internal helpers - are called by familiar DevOps/Developer terms: `transport`, `retry`, `timeout`, `provider`, `profile`, `extends` etc. - **Why so:** the product is large. In small things, familiar names reduce cognitive load and confusion; metaphor, on the contrary, consolidates large concepts into a recognizable vocabulary. - **Example of application (2026-06-29):** config-key for selecting the bootstrap-token delivery transport is selected `transport: direct|teleport` - a familiar word, and **not** the "soulful" `passage`, which was first proposed by architect (this is a small unit - a config-key, not a new entity). - **Use example (2026-07-01):** log-shipping data plane agent is named **`vector`** - upstream product name (Vector.dev), like `node-exporter`/`redis-exporter`: this is an external tool that Soul Stack installs and configures (destiny [`examples/destiny/vector/`](../examples/destiny/vector/destiny.yml), rolls with service scenarios next to exporters - the PUSH log plane, which complements the pull metrics plane, does not replace it). Thematic name rejected - vector is not a Soul Stack entity, but a managed product; inventing your name on top of the upstream name would only confuse the operator. ## Domain Entities | Name | Meaning | |---|---| | **Keeper** | Guardian, central server. Control node. | | **Souls** | Managed agents - single: "Soul", "soul". | | **Destiny** | The desired state of the host after the run. | | **Soulprint** (Prints) | Host System Facts. | | **Coven** | Soul group label/tag - **only stable** logical attributes (cluster, project, environment, data center, hardware type). **The (master/replica) Coven role is NOT** - it is volatile, it turns out to be a live probe (see [ADR-008](adr/0008-coven-stable-tags.md)). `incarnation.id` is **NOT** a Coven ([ADR-008 amendment 2026-07-17](adr/0008-coven-stable-tags.md#amendment-2026-07-17-nim-124-incarnationname-is-not-a-coven--membership-is-a-first-class-relation)): incarnation **membership** is a first-class relation `incarnation_membership`, not a coven equal to the incarnation name. There are no `{incarnation.id}-{role}` sub-covens. Used in RBAC, targeting and potentially routing. | | **Trait** | **Key-value of operator label on incarnation** ([ADR-060](adr/0060-traits.md), R1 - relocated from Soul) - scalar value OR list (`namespace: dba-ns` / `owners: [alice, bob]` / `product: aboba`). **Difference from [Coven](#domain-entities):** Coven = flat groups (cluster/environment/project - membership/targeting/RBAC axis); Trait = attributes owner/product/namespace. Source - **operator** (stable registry data, like Coven; **NOT Soul-reported** - Trait is an org-tag, not a host fact). **R1 (2026-06-25): source of truth - `incarnation.traits jsonb`** (operator-set in `incarnation.spec.traits`, separate axis next to `incarnation.covens`), projected MATERIALIZED to `souls.traits` member hosts via sync-hook (`SyncTraitsToHosts`, create + bind sidebar `core.soul.registered` - **the hook is REMOVED**, see the amendments at the end of this entry). `souls.traits` = separate axis next to `souls.coven TEXT[]` (does not collapse). Projection to `soulprint.self.traits` (registry-projection as `covens`/`choirs`); targeting `where: soulprint.self.traits.` (`traits.namespace == 'dba-ns'` / `'alice' in traits.owners`). **R1 slice a (2026-06-25): operator-facing write-path per-incarnation IMPLEMENTED** - `incarnation.spec.traits` on create + day-2 `PUT /v1/incarnations/{id}/traits` (permission `incarnation.traits-set`, audit `incarnation.traits_changed`, MCP `keeper.incarnation.traits-set`). Per-soul bulk-write `POST /v1/souls/traits` / `soul.traits-assign` / `keeper.soul.traits-assign` - **DEPRECATED, NOT deleted** (forward-compat, overwritten by projection). RBAC-scope for traits on the incarnation dimension - **IMPLEMENTED (slice 1)**: selector `trait=key:value` ([rbac.md → Selector grammar](keeper/rbac.md), [ADR-047](adr/0047-purview.md) amendment / [ADR-060](adr/0060-traits.md) p.7) - exact scalar-equality, read-only narrowing of visibility `incarnation.list`/`get`, **OR-dimension**. AND-narrowing by several trait pairs (multi-key) - follow-up. **Trait key** - kebab/snake-case (`^[a-z][a-z0-9]*([_-][a-z0-9]+)*$`, 1..63): `_` is allowed (NIM-67 is a free operator attribute name like `owner_team`/`product_id`), unlike the Coven tag (`-` only). **★ADR-080 (2026-07-27, NIM-121) supersedes the R1 relocation:** Trait is an axis on the **host AND the incarnation at once**, and neither is projected onto the other - the materialized `SyncTraitsToHosts` hook is REMOVED. `souls.traits` = the labels of THAT host (operator-set directly via `POST /v1/souls/traits`, no longer deprecated); `incarnation.traits` = the labels of THAT incarnation. **★NIM-281 (2026-08-05) revokes the read-time union ADR-080 introduced** ([ADR-008 amendment 2026-08-05](adr/0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited)): a host carries **only** the pairs an operator set on it, and belonging to an incarnation adds nothing. Neither reader consults `incarnation_membership` any more - not the RBAC scope predicate, not `soulprint.self.traits`. Labelling an incarnation labels the incarnation; to label its hosts, label the hosts (`POST /v1/souls/traits`). The per-soul write keeps **gate (b)** (the pair must be inside the operator's own trait-scope), since a host-attached pair grants visibility permanently. | | **SoulSeed** | Soul mTLS identity artifact: certificate + private key pair. Issued by Keeper, rotated regularly, stored in the registry `soul_seeds` (fingerprint only, without PEM and key). | | **Warrant** | **Register of issued SERVICE TLS certificates of incarnations** (cert-rotation Keeper-center, [ADR-017 amendment 2026-07-01](adr/0017-keeper-side-core.md)) - table `warrant` in Postgres (migration 092): `kind` (`cert`/`key`/`ca`), `vault_ref`, `fingerprint`, `serial_number`, **`not_after` - scan axis** Reaper rules `rotate_due_certs`, `status` (`active`/`superseded`/`expired`/`rotating`/`failed`); one active on `(incarnation, kind)`. **NOT to be confused with [SoulSeed](#domain-entities)** - that identity certificate of the Soul agent (the private never leaves the host); Warrant - a service certificate (for example, server TLS Redis from the `rotate_tls` scenario), the private certificate of which Keeper generates centrally and keeps in Vault (R2 exception, confirmed by the user 2026-07-01) - in PG only `vault_ref`+`fingerprint`+`serial`. Registration of primary certificates is done by the keeper-side core module `core.cert.registered`. Name chosen by user 2026-07-01 (propose-and-wait passed; `Vellum`/`Aegis` rejected). Metaphor - a letter/injunction with an expiring date. **NB:** consonance with [Ward](#domain-entities) (claim task apply) - a deliberately accepted price; contexts do not overlap (cert registry vs work-queue). | | **Archon** (Archon) | Soul Stack operator, human or machine-identity. The first Archon is released by the `keeper init` command when initializing the cluster - all the others will be born from it through the usual OpenAPI/MCP with RBAC check. Registry - table `operators` in Postgres. Full commit - [ADR-013](adr/0013-bootstrap-archon.md) and [ADR-014](adr/0014-operator-identity.md). | | **Augur** | Keeper-side subsystem-broker of Soul's **live** (during rendering / apply) access to external systems (Vault / Prometheus / ELK), which is not covered by the pre-resolved model. Two phases: broker (`delegate=false`, data via Keeper) and delegation (`delegate=true`, Soul walks itself with an ephemeral scoped-credential). Full commit - [ADR-025](adr/0025-augur.md), spec - [keeper/augur.md](keeper/augur.md). | | **Omen** | External system to which Augur mediates access (one Vault-mount / Prometheus / ELK cluster). Registry - table `omens` in Postgres (managed via OpenAPI/MCP), analogue of [Provider](#plugin-infrastructure-and-cloud). See [ADR-025](adr/0025-augur.md), [keeper/augur.md](keeper/augur.md). | | **Rite** | Grant/policy-mapping Augur: permission "[Subject](#domain-entities) × Omen → allow-list + `delegate` + ttl/uses". Registry - table `rites` in Postgres. See [ADR-025](adr/0025-augur.md), [keeper/augur.md](keeper/augur.md). | | **Subject** | WHO a [Rite](#domain-entities) / [Vigil](#domain-entities) / [Decree](#domain-entities) applies to - **exactly one** of four dimensions: `sid` (named hosts), `service`+`incarnation` (the `.` roster address), `coven` (labels) or `trait` (one key/value). The two label dimensions read **both levels**: the rule reaches a host carrying the label **and** every member of an incarnation carrying it, resolved at match time without writing anything to `souls`. Targeting only - an operator's RBAC scope is a separate resolver and stays "a label on the host". See [ADR-008 amendment 2026-08-05 / NIM-280](adr/0008-coven-stable-tags.md#amendment-2026-08-05-nim-280-a-rules-subject-reads-both-levels--targeting-only). | | **Sigil** | **Trust seal** of Keeper for a specific plugin binary: signature of an approved digest. Trust model of plugin integrity (`soul-mod-*` / `soul-cloud-*` / `soul-ssh-*`) - Keeper-signed digest index (**Option A**), replacing TOFU. Keeper — authority over the allow-list `(namespace, name, ref) → sha256` (registry `plugin_sigils` in Postgres); writing is allowed **Explicitly by Archon** via OpenAPI/MCP. When distributed to the host, Keeper gives the binary + an index fragment signed with its key (digest + signature = Sigil), the host verifies it to seal/exec. The signature covers the manifest with `binary_sha256` attached (declarations `side_effects` / `capabilities` are not forged). The name overlaps with the existing seal/`sealDigest` code in [`shared/pluginhost`](#go-modules-of-the-main-repo). Full commit - [ADR-026](adr/0026-sigil.md), spec - [keeper/plugins.md → Integrity-model](keeper/plugins.md#integrity-model). | | **seal** / **sealed-paths** | **Sealed paths** secret masking ([ADR-010](adr/0010-templating.md) §7.4). `seal` (verb) - mark the path of the cell params **sealed** in the render phase when its CEL expression reads the secret source (`input.` active input schema / `vault(...)` / transitively sealed `vars`/`compute`); detection - AST traversal of expression (whole-cell taint: ternary/gluing literal+secret is also sealed). `sealed-paths` (entity) - an in-memory set of paths of one render run, brought to the observed channels (`status_details`/`error_summary`/logs) for provenance masking, ON TOP of the declarative schema layer (`secret: true`) and the vault-content layer. Names in the code: `cel.SealSources`/`cel.Engine.DetectSealed` (shared/cel), `render.SealedSet` (keeper/internal/render), `audit.MaskSecretsSealed`/`audit.SealHooks` (shared/audit). **NOT to be confused with [Sigil](#domain-entities)** `seal/sealDigest` - there "seal" = crypto seal of plugin integrity (different subsystem, `shared/pluginhost`); here - secret-taint. | | **Acolyte** | A pool of apply execution workers on each Keeper instance (`keeper.acolytes:N`). Replaces the single run-goroutine of the owner instance: Acolyte atomically brands the per-host task (`apply_runs`-line) via `FOR UPDATE SKIP LOCKED`, renders it **just-in-time with claim** (resolves secrets in RAM, not in PG), runs apply and maintains the barrier of its cut. Any instance picks up any task through Acolyte → execution is distributed across the cluster. See [ADR-027](adr/0027-apply-work-queue.md). | | **Ward** | Claim (custody) execution tasks: columns `claim_by_kid` / `claim_at` / `claim_expires_at` / `attempt` + statuses `claimed` → `dispatched` on `apply_runs`. "Take Ward" = atomically capture a planned task (`attempt++` - fencing-epoch). Lifecycle `apply_runs`: `planned → claimed → dispatched → terminal` (status **`dispatched`** = "given to Soul", marked claimed→dispatched **before** SendApply; `running` - vestigial, only the old synchronous path `acolytes:0`). The recovery scan of the Reaper leader (`reclaim_apply_runs`) returns to `planned` only the rancid **`claimed`** (pre-recoil phase) - `dispatched` is NOT advertised (after recoil is owned by Soul). The hung `dispatched` (Keeper and Soul are both dead after the return) is closed by the terminal **`orphaned`** (Soul-reconcile, S6): Soul on reconnect declares slaves `apply_id` through `WardRoster`, Keeper terminals `dispatched`-SID lines outside the set (`applyrun.OrphanDispatched`); the barrier classifies `orphaned` as a failure (incarnation → `error_locked`). The non-target roster host (Acolyte path writes `planned` to EVERY roster host BEFORE the per-host resolve `on:`/`where:`; after resolution there are 0 tasks left) is closed by the terminal **`no_match`** (FINDING-01 option (b); NOT `success` - apply_runs not over-reports "success" on non-target hosts); barrier classifies `no_match` as TERMINAL and NOT-fail (benign as `success`): a run where target `success` + non-target `no_match` leads to incarnation at `ready`. Complete set of `apply_runs.status` terminals: `success` / `failed` / `cancelled` / `orphaned` / `no_match`. See [ADR-027](adr/0027-apply-work-queue.md) (amend "GATE-1: deliver-once recovery" + "S6: Soul-reconcile" + "FINDING-01: no_match non-target host"). | | **Summons** | pub/sub signal "planned tasks have appeared" (Redis, **best-effort**) + periodic poll fallback on the Acolyte side. Losing the signal does not lose the task - poll will still pick up `planned`. Redis-topic - **`apply:summons`** (committed, propose-and-wait passed; style `:` as `rbac:invalidate`). See [ADR-027](adr/0027-apply-work-queue.md). | | **Vigil** | Soul-side check of event-driven monitoring: "what to observe and how often." **Read-only by design** - observes, does NOT mutate the host. The body of the check is built-in core-beacon (`core.beacon.file_changed` / `core.beacon.service_down` / `core.beacon.port_closed` / `core.beacon.disk_full` / `core.beacon.process_absent` / `core.beacon.http_unhealthy` / `core.beacon.inotify` - 7 built-in, V5-3 closed inotify) or plugin kind `soul_beacon` (4th plugin-kind, V5-2). Which hosts run it - the [Subject](#domain-entities). Registry - table `vigils` in Postgres (managed via OpenAPI/MCP, toggle + RBAC). The active set for the host goes to him through `VigilSnapshot` (ReplaceAll). See [ADR-030](adr/0030-vigil-oracle.md) + [amendment 2026-05-26](adr/0030-vigil-oracle.md#amendment-2026-05-26-s5-closure). | | **Portent** | Event that Vigil raises Soul → Keeper when the host's state changes (**edge-triggered**). Goes only-add to `EventStream`-oneof (`PortentEvent` to `FromSoul`). Payload — `google.protobuf.Struct` in MVP (typed as a separate ADR, like Soulprint). For Oracle - **untrusted input** (Soul may be compromised). See [ADR-030](adr/0030-vigil-oracle.md). | | **Oracle** | Keeper-side reactor subsystem: receiving Portent → match in the Decree registry → setting named-scenario to work-queue ([ADR-027](adr/0027-apply-work-queue.md)). apply itself does not execute - it only routes. See [ADR-030](adr/0030-vigil-oracle.md). | | **Decree** | Reactor rule "event → action". **Default-deny** (like [Rite](#domain-entities)): no matching Decree → no action. Two incarnation-shaped ends, and they are not the same field: the [Subject](#domain-entities) says WHO may fire the rule, the mandatory **`incarnation_name`** says WHAT the reaction acts on (scenario operates `incarnation.state`; ServiceRef resolves from incarnation, not duplicated; reacts to one sender SID; membership-check on `incarnation_name` before enqueue, reading the membership relation and never labels). Opt. `where` - keeper-local CEL-sandbox (root of `event`). **Action = named scenario ONLY** (whitelist via work-queue; raw command rejected as RCE vector - commands available via `core.exec.run` inside scenario). Registry - table `decrees` in Postgres (managed via OpenAPI/MCP). See [ADR-030](adr/0030-vigil-oracle.md). | | **Scry** ⚠️ WITHDRAWN | **Was the read-only drift-detection subsystem; REMOVED 2026-08-05 (NIM-446).** The name stays in this dictionary so it is not silently reused for something else: nothing in the system is called Scry any more. Gone with it: `keeper.incarnation.check-drift` (REST+MCP), `DriftReport`, the `scry_background` background scan, the `incarnation.drift_checked` audit event. Still live from the same ADR: `Plan` pure-read + `PlanReadSafe` (the SoulModule contract, used by Errand's dry-run), the only-add `ApplyRequest.dry_run` / `PlanEvent.changed`, and the incarnation status `drift` — now meaning "the DB state is ahead of the hosts after a legacy upgrade", not "a scan found a mismatch". See [ADR-031](adr/0031-scry-drift.md#adr-031-scry--drift-detection-declarative-dry-run-reconcile). | | **Errand** | Pull-ad-hoc exec of a single module on a specific Soul via mTLS EventStream, outside scenario/incarnation/incarnation.state ([ADR-033](adr/0033-errand.md)). Endpoint POST /v1/souls/{sid}/exec, sync-primary (30s default, 300s cap) with async escalation. Apply whitelist: exact core.cmd.shell/core.exec.run, exact read-only core.http.probe, or a module carrying ErrandReadSafe; mutating core.http.request is default-deny. State-invariant: DOES NOT mutate incarnation.state. Cross-keeper routing - reuse outbound: + applybus. Errands registry in Postgres (TTL 7d). Symmetrical push ([ADR-032](adr/0032-push-orchestrator.md)). | | **ErrandRun** _(superseded-by-Voyage, removed in Wave 5)_ | **Historical name (implementation removed, commit `3b47802`).** Multi-target binding over N `Errand` (shared ULID, AND-merge target, concurrency-cap, cancel-all; symmetrical `Tide↔Surge`; registry `errand_runs` in Postgres). **absorbed by [Voyage](#domain-entities) `kind=command`** ([ADR-043](adr/0043-voyage.md), 2026-05-29): code cut in Wave 5, harness/registry/audit-family no longer exists, name reserved as historical. See [ADR-041](adr/0041-errandrun.md). | | **Tide** _(superseded-by-Voyage, removed in Wave 5)_ | **Historical name (implementation removed, commit `33fe33b`).** Invocation-instance of a massive scenario run, divided into successive **Surge** waves (top-level over `apply_runs`, FK `apply_runs.tide_id`, PG-table `tides`, AND-merge target-override, REPLACE concurrency-override, Acolyte-style lease, RBAC `incarnation.run`). **absorbed by [Voyage](#domain-entities) `kind=scenario`** ([ADR-043](adr/0043-voyage.md), 2026-05-29) - invocation-time target-override removed (replaced by selection from RBAC scope), Surge→Leg; code cut in Wave 5 (subsystem/table/audit-family no longer exists), name reserved as historical. See [ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override). | | **Surge** _(superseded-by-Voyage, removed in Wave 5)_ | **Historical name (implementation removed, commit `33fe33b`).** One wave of Tide run = one `apply_run` per subset souls (`wave_size`), sequential, full scenario run with its own barrier + state-commit (parity [ADR-009 §7](adr/0009-scenario-dsl.md), identifier `(tide_id, surge_index)`). **Replaced by [Leg](#domain-entities)** ([Voyage](#domain-entities), [ADR-043](adr/0043-voyage.md), 2026-05-29): Code cut in Wave 5 along with Tide, name reserved as historical. See [ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override). | | **TideWorker** | Worker-pool for claim+execute Tide operations ([ADR-040 amendment 2026-05-27](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)). A separate pool on each Keeper instance, **NOT** shared with Acolyte (deadlock-prevention with `acolytes:1` + 1 Tide occupied the slot). Config `keeper.tide_workers: N` (default `1`, recommendation `max ≤ acolytes-1`). Claim-pattern parity [ADR-027(d)](adr/0027-apply-work-queue.md): `FOR UPDATE SKIP LOCKED` to `tides` with `status='pending'`, PG-based lease 60s + renewal 20s via CAS-UPDATE, `attempt++` for each claim for fencing-epoch. The cycle is simple `claim → orchestrate → poll/sleep`. **Superseded-by-Voyage, removed in Wave 5** ([ADR-043](adr/0043-voyage.md)) - absorbed by `VoyageWorker`, code cut (commit `33fe33b`); the name is reserved as historical. | | **ErrandRunWorker** | Worker-pool for claim+execute ErrandRun operations ([ADR-041](adr/0041-errandrun.md)). Separate pool on each Keeper instance (`keeper/internal/errandrunorch/`), parity TideWorker. Claim-pattern parity [ADR-027(d)](adr/0027-apply-work-queue.md): `FOR UPDATE SKIP LOCKED` to `errand_runs` with `status='pending'`, PG-based lease + renewal via CAS-UPDATE, `attempt++` for fencing-epoch. Cycle: claim → resolve target (AND-merge) → fan-out N Errand under semaphore-cap → aggregate → finalize. **Superseded-by-Voyage, removed in Wave 5** ([ADR-043](adr/0043-voyage.md)) - absorbed by `VoyageWorker`, code cut (commit `3b47802`); the name is reserved as historical. | | **Voyage** | **Unified batch run** ([ADR-043](adr/0043-voyage.md)), absorbing Tide + ErrandRun + scenario-run. Discriminator `kind`: **`scenario`** (apply named scenario to set of **incarnations**; batch = N incarnations, B1, per-incarnation state-commit) \| **`command`** (execute the whitelisted module on a set of **hosts**; `incarnation.state` is left alone). The batch unit is **Leg**. Parameters: `batch_size` / `concurrency` / `dry_run` / `schedule_at` (delayed start → state `scheduled`) / `inter_batch_interval` / `on_failure` (`abort`\|`continue`); batch strategies (`batch_mode` `barrier`\|`window`, `batch_percent`, `fail_threshold`, `inter_unit_interval`, `require_alive` + config `max_batch_size`) - [ADR-043 amendment 2026-06-01](adr/0043-voyage.md), dictionary in section ["Voyage: invocation and config keys"](#voyage-invocation-and-config-keys). Registry - tables `voyages` + `voyage_targets` in Postgres (S1+; names updated from sketches `runs`/`run_targets`, migration `059`). Endpoint `/v1/voyages` (S5), permission by `kind` (`incarnation.run` for scenario, `errand.run` for command - security-critical guard). Failover-resilient via Acolyte-style PG-lease. The metaphor is "swimming" (continued from Tide/Surge). | | **Leg** | A batch unit [Voyage](#domain-entities)-run ([ADR-043](adr/0043-voyage.md)) is one consecutive "path segment". For `kind=scenario` Leg = N **incarnations** (each is a full-fledged scenario-run with its own barrier + per-incarnation state-commit, parity [ADR-009 §7](adr/0009-scenario-dsl.md); wave host-level is a separate axis through scenario `serial:`). For `kind=command` Leg = N **hosts** (state is not affected). Size - `batch_size`; between Legs - `inter_batch_interval`; transition on failure - `on_failure`. Identified by `voyage_targets.batch_index`. Metaphor - continuation of [Surge](#domain-entities). | | **Passage** | **Scenario run stage** ([ADR-056](adr/0056-staged-render-passage.md)) - ordered cycle **render → dispatch → barrier → register collection**. The run is executed as **N ≥ 1 ordered Passage**: tasks are stratified by register dependency (a task reading `register.X` into `where:`/`apply: input:`/`params:`/`vars:` ends up in Passage **strictly AFTER** probe emitting `X`; topological N-stage). Render of the next Passage substitutes per-host `register` of all previous ones - this **really works** probe→where ([ADR-008](adr/0008-coven-stable-tags.md) volatile role; canon [orchestration.md §4/§5](scenario/orchestration.md)). **Task axis** (when to render/send what), is orthogonal to `serial:` (target host axis) and Voyage `Leg` (incarnation/host batch axis). **`incarnation.state` commits ONCE after the last Passage** - barrier/state-commit-invariant [ADR-009 §7](adr/0009-scenario-dsl.md) is not split. Contract: proto only-add `passage` to `ApplyRequest`/`TaskEvent`/`RunResult`; execution task becomes per-passage `(apply_id, sid, passage)` (amend [ADR-027](adr/0027-apply-work-queue.md) Ward-claim/reclaim); N `RunResult` per host (one per Passage). Old Soul without the `passage` field under the staged scenario → explicit-reject (`render_failed`). Name selected by user (propose-and-wait passed). | | **VoyageWorker** | Worker-pool for claim+execute Voyage runs ([ADR-043](adr/0043-voyage.md), S1+). Separate pool on each Keeper instance, parity TideWorker. Claim-pattern parity [ADR-027(d)](adr/0027-apply-work-queue.md): `FOR UPDATE SKIP LOCKED` by `voyages` with `status='pending'`, PG-based lease + renewal via CAS-UPDATE, `attempt++` for fencing-epoch. Cycle through `kind`: scenario - spawn per-incarnation scenario-run + per-incarnation state-commit; command - fan-out by host. Replaces TideWorker + ErrandRunWorker for S7. | | **Choir** | **Named topology of hosts WITHIN an incarnation** ([ADR-044](adr/0044-choir.md)) - "chorus part": a declared group of SIDs with a common position (e.g. `redis_primary` / `redis_replica` / `haproxy_frontends`). **Choir ≠ Coven** (coven is a global stable targeting/RBAC axis; Choir is an intra-incarnation topological axis without RBAC semantics of a coven; does not revive a deleted sub-coven `{incarnation.id}-{role}`, [ADR-008](adr/0008-coven-stable-tags.md)) and **≠ membership** (membership = a row in `incarnation_membership`, [ADR-008 amendment 2026-07-17](adr/0008-coven-stable-tags.md#amendment-2026-07-17-nim-124-incarnationname-is-not-a-coven--membership-is-a-first-class-relation); Choir specifies the position within the already-members). Stable declared fact → available in `where:` without probe (`soulprint.self.choirs` / `soulprint.hosts[].choirs`). Absorbed the declared role — `voice.role` is its **only** home since [ADR-044 amendment 2026-07-30](adr/0044-choir.md#amendment-2026-07-30-nim-330-spechosts-is-removed-voice-is-the-only-source-of-a-declared-role) (NIM-330) removed `incarnation.spec.hosts[]`. **Assignment is optional, per-host, scenario-driven** (the service scenario assigns hosts to batches; [ADR-044](adr/0044-choir.md) amendment 2026-06-30). **Not set → `NULL` in the database (no row in `incarnation_choir_voices`, empty `choirs[]`), NOT default/standard batch** - empty state is more honest than default (does not impose a group, symmetrical to `null` role). The source of truth is table `incarnation_choirs` in Postgres (S-T2; **NOT** `incarnation.state` - declared topology ≠ actual result). The per-shard/per-role layout in the scenario is really needed for mongo (shards/coordinators/control nodes) - **deferred** to mongo; the redis cluster lives in a single part (all members of the incarnation). The metaphor is a choir of souls of one incarnation, singing in different parts. | | **Voice** | **SID Membership in [Choir](#domain-entities)** ([ADR-044](adr/0044-choir.md)) - "voice in the party." It is tied to the **troika `(incarnation_name, choir_name, sid)`**, so one SID is legally the Voice in Choirs of **different** incarnations at the same time (multi-incarnation: the host can carry Haproxy and Redis at once). Carries the attributes `role` (nullable - absorbed declared role) and `position` (nullable - index within the party). **Invariant:** Voice is created only for a SID that is already a member of this incarnation (a row in `incarnation_membership`, [ADR-008 amendment 2026-07-17](adr/0008-coven-stable-tags.md#amendment-2026-07-17-nim-124-incarnationname-is-not-a-coven--membership-is-a-first-class-relation)) - Voice does not replace membership, but clarifies the position. The source of truth is table `incarnation_choir_voices` in Postgres (S-T2). Metaphor is the voice of a specific soul in a choir. | | **Bare-incarnation** | Incarnation created **without starting (`create: true`) scenario**: `StatusReady` without run, `created_scenario = NULL`, is ready for day-2 operations. Occurs when the service does not have any `create: true` scenarios and the operator did not transmit `create_scenario` ([ADR-009 amendment 2026-06-29](adr/0009-scenario-dsl.md)). NOT an error and NOT a weakening of the barrier/state-commit-invariant - there is no run, the first mutation `incarnation.state` will come from the first day-2 scenario. The bare incarnation does not have a crashed bootstrap run: it gets to `error_locked` only through the failure of the day-2 scenario, and then `rerun-last` restarts exactly this day-2 scenario (input from the recipe `apply_runs.recipe.input`; unavailable → 409 `ErrRerunInputUnavailable`). | | **Synod** (Synod) | **Group of Archons, banding roles** ([ADR-049](adr/0049-synod.md)) - intermediate level of the model **[Archon](#domain-entities) → Synod → Roles**. Carries **role set** ([ADR-028](adr/0028-rbac-storage.md#adr-028-rbac-storage--postgres) `rbac_roles`); **does NOT have its own `default_scope`** - scope lives on roles ([ADR-047](adr/0047-purview.md)), Synod only groups already-scoped roles (group-scope is NOT entered - additive for the future). **Flat** — Synod does NOT contain other Synods (nesting is additive). An Archon can belong to several Synods; **effective roles of an archon = direct (`rbac_role_operators`) ∪ roles through all his Synods**. Resolve is an addition to the snapshot assembly of the enforcer ([ADR-028(d)](adr/0028-rbac-storage.md#adr-028-rbac-storage--postgres)), the matching layer [Purview](#purview---scope-resolver-adr-047) does not change. **Security-invariant:** least-privilege subset and self-lockout MUST take into account roles via Synod ([ADR-049 §f](adr/0049-synod.md)). Registry - three PG tables `synods` / `synod_operators` / `synod_roles` (pattern `rbac_*`). Endpoint `/v1/synods` (+`/{name}/operators`, `/{name}/roles`), permission family `synod.*` (NoSelector). The name is chosen by the user (Greek: "meeting"; propose-and-wait passed). **NB:** NOT to be confused with **[Conclave](#modules-and-subsystems-inside-keeper)** - this is a registry of live Keeper instances in Redis (another subsystem, another "collection"), `Conclave` is occupied by it and is not used for Synod. | | **Herald** (Herald) | **Notification delivery channel** ([ADR-052](adr/0052-herald-notifications.md)) - "where to send." Registry - table `heralds` in Postgres (managed via OpenAPI/MCP, pattern [Omen](#domain-entities)): `name` PK (kebab), `type` closed-enum, `config` JSONB (per-type - form depends on `type`), `secret_ref` (vault-ref - channel secret **not in PG cleartext**, pattern `omens.auth_ref`/`core.url`; used only by type `webhook` for HMAC signature, others have credential - vault-ref field **inside** `config`), `enabled`, `created_by_aid`. **6 channel types (ADR-052 amendment, [commit f3739d03](adr/0052-herald-notifications.md)) in two transport classes** - enum `type ∈ {webhook, telegram, slack, mattermost, discord, custom, email}`: **HTTP class** (`webhook`/`telegram`/`slack`/`mattermost`/`discord`/`custom` - driver [`channelDriver`](#modules-and-subsystems-inside-keeper), delivery via [`httpDelivery`](#modules-and-subsystems-inside-keeper) under the general SSRF-guard) and **SMTP class** (`email` - separate axis `net/smtp`, NOT `channelDriver`; own SSRF-guard by resolved IP). Difference between bodies: `webhook`/`custom` send structural `webhookPayload`, messengers `telegram`/`slack`/`mattermost`/`discord` - human-readable digest text, `email` - RFC5322 letter. `custom` = custom HTTP endpoint with **fixed** body `webhookPayload` (no `body_template`) + custom method + opt. `header_secret_ref` to `Authorization`. Config fields of each type are published by the **`GET /v1/herald-types`** catalog (see [`HeraldFieldSpec`](#modules-and-subsystems-inside-keeper)); The UI builds the per-type form from the directory, not hardcoding ([ADR-042](adr/0042-backend-driven-ui.md)). HTTP class under SSRF-guard (https-only + deny private IP + redirect block) through the common keeper-side `shared/netguard` (`netguard.ValidateEndpoint`/`netguard.NewCheckRedirect`/`netguard.GuardedDialContext`) - the same guard on top of which Soul-side [`core.url`](#specific-core-modules)/`core.http`. When `secret_ref` (only `webhook`) is specified, the body is signed with HMAC-SHA256 and transmitted in the HTTP header **`X-SoulStack-Signature`** in the form **`sha256=`** (parity GitHub `X-Hub-Signature-256`; the receiver verifies with the same secret). Body `webhook`/`custom`-POST - typed `webhookPayload` (`event_type`/`occurred_at`/`herald`/`tiding`/`payload`); [ADR-052 Amendment 2026-06-11](adr/0052-herald-notifications.md) adds opt. top-level key **`annotations`** (object from [Tiding](#domain-entities)`.annotations`; absent if empty - additive for external receivers). Endpoint `/v1/heralds` (PUT `{name}` = replace, like Push-Provider). Metaphor - a messenger carrying a message ([Tiding](#domain-entities)); continuation of the herald line next to [Choir](#domain-entities)/[Voice](#domain-entities)/[Oracle](#domain-entities). | | **Tiding** (News) | **Rule for subscribing to notifications** ([ADR-052](adr/0052-herald-notifications.md)) - "what to react to → what [Herald](#domain-entities)th." Registry - table `tidings` in Postgres (managed via OpenAPI/MCP): `name` PK (kebab), `event_types` TEXT[] (audit event-types with support for areas `scenario_run.*` - area-glob, not arbitrary wildcard), filters `only_failures`/`only_changes`, opt. selectors `incarnation`/`cadence`/`task`, `herald` FK→`heralds(id)` `ON DELETE CASCADE`, `enabled`, `created_by_aid`. **Scope MVP - Run Events ONLY** (`scenario_run.*`/`command_run.*`/`voyage.*`/`incarnation.run_completed`/`cadence.*`); host beacon events ([Portent](#domain-entities)/[Oracle](#domain-entities)) - separate entry ([ADR-052](adr/0052-herald-notifications.md) Rejected). Endpoint `/v1/tidings`. Match event→Tiding→delivery does a tap on top of `audit.Writer` + notification-dispatcher. **Amendment fields** ([ADR-052 Amendment 2026-06-11](adr/0052-herald-notifications.md), additive are Tiding fields, NOT new entities): **`ephemeral`** BOOL DEFAULT `false` (one-time rule) + **`voyage_id`** (binding to a specific [Voyage](#domain-entities); required for `ephemeral`, `NULL` for permanent ones) - one-time subscription, created atomically by the keeper from the opt. block **`notify`** in `VoyageCreateRequest` ([ADR-043](adr/0043-voyage.md), additive) (one tx with a run; RBAC-guard `herald.read` per channel; `ephemeral` removed from the Notifications tab; listing `/v1/tidings` does not return them by default (`include_ephemeral` for debugging); visibility - on Voyage/Run detail; cleaning - Reaper rule `purge_orphan_ephemeral_tidings` by the predicate "run in terminal" with grace period); **`annotations`** JSONB (operator statics, merged into webhook body with new key `annotations`) + **`projection`** TEXT[] (allow-list payload paths; empty = full form) - flexible body, both are calculated in the delivery worker (claim-queue) off-path (dispatcher remains "match + enqueue"). **`task`** TEXT nullable ([ADR-052 §l](adr/0052-herald-notifications.md), migration 073) - opt. subscription selector for a SPECIFIC run task at its address (`register ∪ id` from `changed_tasks` event `incarnation.run_completed`); `NULL` = no filter; presence of address in `changed_tasks` = task has changed (`dispatcher.matchTask`). This is a **rule selector** (to which Tiding is subscribed), NOT to be confused with the grammatical fields of the task `register`/`id` (the address of the task itself in the DSL); the value `task` is taken from the same address space `register ∪ id`. **`created_from_cadence_id`** TEXT nullable ([ADR-052 §m](adr/0052-herald-notifications.md), migration 074) - **ORIGIN marker** "rule created from block `notify` of form [Cadence](#domain-entities)" (`POST /v1/cadences`); FK→`cadences(id)` `ON DELETE CASCADE` (demolishing Cadence takes away the generated auto rules, [ADR-046 §9](adr/0046-cadence.md)). `NULL` = set up differently (manual CRUD / `ephemeral` from Voyage). **Orthogonal to the `cadence` selector**: the cascade distinguishes the origin, not the filter selector - a rule with the same `cadence` selector, but `created_from_cadence_id=NULL`, survives when the schedule is demolished. Binding by ULID (`cadences.id`), not by name (rename-safe). **Auto-rule name - `-notify`** (for several `notify` on one schedule - suffix `-2`/`-3`/...; `Tiding.Name` = PK, collision is not allowed; human-readable name is reduced to `^[a-z0-9-]{1,63}$`). The metaphor is the message the Herald brings. | | **Cadence** | **Schedule that spawns the usual [Voyage](#domain-entities)-run** ([ADR-046](adr/0046-cadence.md)). A separate entity in Postgres (table `cadences`) experiences runs: when the time comes **spawns a new Voyage** (Insert in `voyages`/`voyage_targets`) - claim/Leg/batch/failover/audit Voyage work without changes. **Voyage-invariant "one Voyage = one run" SAVED** - Cadence creates new lines, does not resurrect old ones. Parent Cadence → children Voyage (back-link nullable-column `voyages.cadence_id`; manual run = `NULL`). Stores the **recipe** of the run (`kind`/`scenario_name`\|`module`/`target`/`input`/batch settings - the same set as `VoyageCreateRequest`) + repetition rule (`schedule_kind` `interval`\|`cron`; `interval_seconds` XOR `cron_expr`) + `overlap_policy` (`skip`\|`queue`\|`parallel`) + `next_run_at`/`last_run_at`/`enabled`/`created_by_aid`. Trigger - **[Conductor](#modules-and-subsystems-inside-keeper)** (separate leader-elected subsystem, lease `conductor:leader`, [ADR-048](adr/0048-conductor.md)); amendment 2026-06-02 moved the spawn from the Reaper leader (rule `spawn_due_cadence`, `action: spawn`) to Conductor for the sake of an independent tick-interval. Endpoint `/v1/cadences` (CRUD + `enabled`-toggle + `GET /{id}/runs` reuse Voyage-DTO). RBAC `cadence.create`/`list`/`update`/`delete`/`enable`/`disable` (`enable`/`disable` - granular toggle rights, amendment 2026-06-02; `cadence.update` remains backcompat-grant for toggle) + Voyage-permission according to `kind` recipe (two-level guard). Metaphor - "rhythm, measured beat" (musical-spiritual line next to Choir/Voice). | > **"label" now names two things in Soul Stack, and they never meet.** A **matching label** is a [Coven](#domain-entities) tag or a [Trait](#domain-entities) pair — it lives in `souls.coven[]` / `souls.traits` / `incarnation.covens` / `incarnation.traits`, it is what the RBAC selectors `coven=` and `trait.=` and a rule's Subject resolve against, and it is what [ADR-008](adr/0008-coven-stable-tags.md) / NIM-281 means by "a label is never inherited". An entity's [**`label` field**](#identifiers) is a display caption on the registry row itself: free text, mutable, non-unique, optional, read by the UI and by nothing else. No Vault path, no RBAC scope, no CEL root, no snapshot directory, no selector and no resolver reads an entity's `label` — that is the entire reason the field exists, and it is what makes "I changed the label and nothing moved" a guarantee rather than a hope. Consequently `coven=` in the selector grammar means a **Coven tag** and never this field, and no entity's `label` is ever a Coven tag. ([ADR-0085](adr/0085-entity-id-and-label.md)) ## Identifiers | Name | Meaning | |---|---| | **SID** | Soul ID. Equal to the host's FQDN - stable, human-readable, provides automatic dedup when reinstalling. | | **KID** | Keeper ID. Stable instance identifier in the Keeper cluster; used in lease, audit logs and metrics. Human-readable kebab-case string, regex `^[a-z][a-z0-9-]{0,62}$` (similar to AID, without the required prefix). | | **AID** | Archon ID. Operator ID. Lowercase ASCII string, regex `^[a-z0-9][a-z0-9._@-]{1,127}$` (examples: `archon-alice`, `archon-ops-01`, email-like `alice@corp.com`, ldap-uid `uid-4815`). The first character is a letter/number; charset `a-z 0-9 . _ @ -`. The mandatory prefix `archon-` has been removed (ADR-014 amendment 2026-05-29) for the sake of LDAP/Keycloak auto-provision external identities. Charset is intentionally safe: no `/`/`\` (path-traversal), only ASCII-lowercase (no unicode doubles and case), no control/quotes (no injection). Used in RBAC (membership `rbac_role_operators`), FK audit fields (`created_by_aid`, `changed_by_aid`, etc.), API/MCP-call-context. Free from conflicts with known standards (not OID/ASN.1, not DID/W3C, not PID/GID/unix). | | **`id`** | **The identifier of a registry entity** ([ADR-0085](adr/0085-entity-id-and-label.md); replaces `name` in that role platform-wide). One grammar for every registry: `^[a-z0-9][a-z0-9-]{0,62}$` — lower-case kebab, a leading digit legal, bounded at 63. **Immutable**, set once at registration: it is the `TEXT PRIMARY KEY`, the CEL root (`incarnation.id`), the URL path segment (`/v1//{id}`), the RBAC scope value (`incarnation=`) and segment 2 of every derived Vault path — and no rename operation exists anywhere in the tree, so a changed `id` makes the platform derive a *different* path and orphans what was written under the old one. `operators` is the one exception: its id is [**AID**](#identifiers), whose grammar is deliberately wider because AIDs arrive from external identity providers. | | **`label`** | **The display caption of a registry entity** ([ADR-0085](adr/0085-entity-id-and-label.md)). Free text, capitals allowed, **mutable at any time**, not unique, not required. Seeded at registration with a Title-cased default derived from the git path; the operator may change it whenever. **Participates in nothing derived** — not a Vault path, not an RBAC scope, not a snapshot directory, not `incarnation.<…>` in CEL, not a selector, not a resolver, not an FK, not a URL. That invariant is the whole reason the field is split off `id`: only under it is *"I changed the label and nothing moved"* a guarantee rather than a hope. **Not to be confused with a matching label** (a Coven tag / a Trait pair) — see the note under the [Coven](#domain-entities) glossary row. On `operators` this field is the former `display_name` (ADR-0085, NIM-733). | > **`id` in Soul Stack means "code word", not "opaque identifier".** A registry entity's `id` is lower-case kebab, chosen by a human, and legible in a URL, a Vault path segment and a CEL expression — the same kind of thing `SID` (an FQDN) and `AID` (`archon-alice`) already are, and the same kind of thing [ADR-0083](adr/0083-declared-secret-state-fields.md)'s reveal id (`.`) is. Where the platform needs a genuinely opaque handle it uses a **surrogate** and says so: `apply_id` / `errand_id` / `AuditEvent.id` are ULIDs and `Rite.id` is a database sequence number. A surrogate is not an entity id in this sense, and an entity id is never a surrogate. ## Binaries | Name | Role | |---|---| | `keeper` | Central server. Includes module `keeper.push` (see below). | | `soul` | Daemon agent on a managed host, agent mode. | | `soul-lint` | Offline linter Destiny / service vars / plugin-manifest (pure static, does not execute) + scaffold-tool for authors of SoulModule plugins (`plugin-init /`, [ADR-016 amendment 2026-05-27](adr/0016-parity-license.md); the scope extension of the linter on init-tooling is fixed there). Subcommands: `validate-config` / `validate-destiny` / `validate-service` / `validate-scenario` / `validate-manifest` / `plugin-init`. | | `soul-trial` | Offline **trial runner** ([Trial](#trial)) Destiny/Scenario and state_schema migrations: runs the render pipeline / migration DSL on fixtures and asserts the result. Border with `soul-lint`: "does not perform → soul-lint, performs → soul-trial." The code lives in module `keeper` (`keeper/cmd/soul-trial`, [ADR-023](adr/0023-trial-test-runner.md)). | | `soulctl` | Operator client CLI. A thin wrapper over the Operator API ([keeper/operator-api.md](keeper/operator-api.md)) - paired with the agent `soul` (as `kubectl` ↔ `kubelet`, `etcdctl` ↔ `etcd`). According to [ADR-004](adr/0004-binaries.md#adr-004-binary-layout--keeper-soul-soul-lint-push-mode-as-a-module-inside-keeper), the primary operator interface is OpenAPI and MCP; The CLI is valid as a thin wrapper over OpenAPI, not as a separate behavioral contract. Scope MVP: `incarnation list/get/run/history`, `souls list/get`, `archon login/whoami`. Full CRUD - later. Authentication - JWT from `~/.config/soul-stack/credentials.yaml` (fields `keeper_url:` / `archon_jwt:`). The code is a separate Go module `soulctl/` ([ADR-011](adr/0011-go-layout.md)). | | `soul-legion` | Load stub generator: produces N fake Souls, each holding a real `EventStream` (gRPC bidi-mTLS) to Keeper for the Keeper / PG / Redis load test. **Shipped** as `soul-stack-legion` ([ADR-004 Amendment 2026-07-26](adr/0004-binaries.md#adr-004-binary-layout--keeper-soul-soul-lint-push-mode-as-a-module-inside-keeper)) so operators can size their own clusters; the code stays in `tests/load/`. See ["Soul Legion"](#soul-legion) below. | ### Trial **Trial** (test) - running Destiny/Scenario or state_schema migration on fixed input data (fixtures) with checking the result. Metaphor: test of fate before incarnation. The tool is the `soul-trial` binary. The DSL coverage metric is **`trial coverage`** (which tasks/CEL-branches/`enum`-values/state captures are run), as opposed to **`code coverage`** (regular Go kernel coverage). Levels L0 (render-only hermetic) / L1 (migration) - MVP; L2/L3 - post-MVP. Full commit - [ADR-023](adr/0023-trial-test-runner.md), spec - [docs/destiny/testing.md](destiny/testing.md). ### Soul Legion **Soul Legion** (`soul-legion`) - **test-only** load stub generator: produces N fake Souls, each of which holds a real `EventStream` (gRPC bidi-mTLS) to Keeper to create a load on Keeper / PG / Redis at a scale unattainable by functional levels (thousands-tens of thousands streams). Stub **DOES NOT parse Destiny and DOES NOT apply** - measures the load on Keeper, not the realism of apply on the host. Metaphor: "legion" = many souls ("my name is Legion, for we are many"). **Shipped binary** since [ADR-004 Amendment 2026-07-26](adr/0004-binaries.md#adr-004-binary-layout--keeper-soul-soul-lint-push-mode-as-a-module-inside-keeper) (package `soul-stack-legion`): operators size their own clusters with it. The code stays in `tests/load/` and is built on the `soulstub` foundation. It fabricates the stub souls' identity directly in the cluster (`INSERT` into `souls`/`soul_seeds`, certs from Vault PKI), so it needs cluster DB credentials and a PKI-issue token — a bench-cluster tool, never production. Plan - [docs/testing/load-testing.md](testing/load-testing.md). ## Dev bench: environment variables (local development) Local dev stand toolkit (NIM-25) - env ​​variables of Makefile targets `dev-*`, which distribute several stands on one developer machine. These are **DevOps terms of a dev stand, outside the "soulful" metaphor of Soul Stack** (according to [criterion "large → Soul Stack, small → familiar"](#criterion-large--soul-stack-small--familiar): these are not product entities, but small control variables of local equipment - therefore they are called familiar DevOps words, like `transport`/`provider`). Full mechanics, port offset tables, dilution modes and recipes - [docs/dev/local-setup.md → Parallel stands](dev/local-setup.md). | Variable | Role | |---|---| | **`DEV_STAND`** | Dev stand ID; value = slug/ticket (eg `nim30`; validation `^[a-z0-9][a-z0-9-]{0,30}$`). Empty = default stand (historical ports `8080…` / DB `keeper` / directory `/tmp/keeper-dev`). Non-empty - second+ stand: own directory `/tmp/keeper-dev-`, database `keeper_`, Vault prefix `secret/keeper//`, ports with shift `slot×10` (slot `1..3` is auto-allocated from the registry `/tmp/soul-stack-stands.tsv`, override - `DEV_STAND_SLOT`). Readable by all `make dev-*` and `dev/*.sh` via the sourced helper [`dev/stand-env.sh`](../dev/stand-env.sh). | | **`DEDICATED_INFRA`** | Flag of complete isolation of the stand infrastructure. `0` (default) - light mode: PG/Vault/Redis containers **general**, only database/KV-prefix/ports are allocated. `1` - your own set of containers (`COMPOSE_PROJECT_NAME=soul-stack-` + infra-ports with a shift `slot×10`), complete decoupling. Needed for parallel apply runs and HA/failover demos, where general light-mode Redis fumbles the Conductor/Reaper leadership and presence Conclave between stands. | ## Modules and subsystems inside `keeper` | Name | Role | |---|---| | **`keeper.push`** | Push mode: SSH delivery of Destiny to hosts without a Soul agent. Not a separate binary, a module inside `keeper`. | | **Reaper** | Background task of cleaning the registry from garbage (`pending`-overdue, zombie entries, old seeds). The leader is selected via Redis-lease; single executor in a cluster. Rule names (canonical list - [keeper/reaper.md → Rules](keeper/reaper.md)): `expire_pending_seeds` / `purge_used_tokens` / `purge_souls` / `purge_old_seeds` / `mark_disconnected` / `purge_audit_old` / `purge_apply_runs` / `purge_apply_task_register` / `reclaim_apply_runs` / `reconcile_orphan_applying` (recovery rule, removes the orphaned `incarnation.status='applying'` lock of the direct standalone `incarnation.run` of the crashed Keeper owner: presence-gated, default-ON via path-defaulting; [ADR-027(m)](adr/0027-apply-work-queue.md)) / `reap_orphan_vault_keys` (report-only cross-store reconcile of orphaned Vault-privates of Sigil signature against `sigil_signing_keys`, does not delete anything; [ADR-026(h)](adr/0026-sigil.md)). Expansion of the list - propose-and-wait. | | **Charon** (Charon) | **Reserved name** in case the scope of the Reaper expands beyond the scope of the cleanup (table migrations, archive data transfer, GC of cold layers). Not currently used. | | **Conductor** (Conductor) | **Leader-elected executor of [Cadence](#domain-entities)-schedules** ([ADR-048](adr/0048-conductor.md)). A separate keeper-side subsystem (not a binary, like Reaper), taken out of Reaper: the cleanup domain of Reaper (`reaper.interval` 1h) and the scheduling domain of Cadence have different natural rhythms, a common tick prevents Cadence from spawning often. Your own Redis-lease **`conductor:leader`** ([ADR-006](adr/0006-cache-redis.md) single-executor; **independent** of `reaper:leader` - the Conductor leader and the Reaper leader can be on different instances) + your own tick-interval (config block **`cadence_scheduler`** in `keeper.yml`, recommendation ~15–30s). It sits on the generic primitive `keeper/internal/leaderloop` (shared with Reaper). Transfers Cadence spawn logic from Reaper **without changes** (due fetch + three `overlap_policy` + recalculation `next_run_at` + spawn-tx); Reaper loses rule `spawn_due_cadence` and `action: spawn`. Audit spawn remains `source: background` (new source `scheduler` is NOT entered). **Default-ON if Redis is present** (footgun-guard: Cadence without a scheduler will not spawn silently). Metaphor - a conductor who sets the rhythm of the performance (musical and soulful line next to Choir/Voice/Cadence). **NB:** the name collides with Netflix/Temporal Conductor - the collision was accepted consciously (user, 2026-06-02). | | **Conclave** (Conclave) | Registry of **living Keeper instances** of a cluster in Redis: presence of a keeper instance with TTL + renew (ADR-006 amend, soul-shedding S1). Each instance at the start registers its key `keeper:instance:` (the technical name of the key is `soul::lock`; entity in the dictionary = Conclave) with TTL and extends its renewal-goroutine; graceful shutdown - `DEL`, crash - TTL-expiry. **NOT exclusive lock** (unlike [Reaper](#modules-and-subsystems-inside-keeper)-lease / SoulLease): each instance holds its own key according to its own KID, there is no competition for one key. The presence authority is Redis (presence→Redis invariant; PG option rejected). Live enumeration (`LiveKIDs` / `CountLive`) - `SCAN keeper:instance:*` (single source of truth for TTL keys, without a separate Redis-Set - like `SoulsStreamAlive`). It feeds refuse-guard "I'm not alone" (`CountLive > 1`) and soul-shedding (there is somewhere to go). Metric - `keeper_conclave_instances` (gauge of live instances). KID collision (two keeper processes with the same `kid` in the config) - warn + registration over (operator configuration error, non-blocking). | | **Shepherd** (Shepherd) | **Souls load balancing between Keeper instances** with horizontal scale-out (PLANNED, backlog 2026-05-25; name selected by user, propose-and-wait passed). Problem: the new Keeper for LB is idle - existing long-lived EventStream streams get stuck on old instances (LB balances only new connections; Soul itself does not reconnect until the stream ends). Mechanism: instances publish a snapshot of the load (number of streams; optional Acolyte queue depth) in the [Conclave](#modules-and-subsystems-inside-keeper) record (renew tick 10s); an instance with a skew above its fair share (`sum of streams / CountLive`) dumps **excess** of its streams partial `StreamManager.CloseAll` (not all, unlike Watchman) with jitter/cap → Souls reconnect and scatter. **Balancing domain = priority group / one VIP** (different priorities = failover, not balance). Difference from Watchman: Watchman = binary full reset on ISOLATION; Shepherd = partial reset for the sake of UNIFORMITY. drain mode (auto-threshold / semi-auto / manual) - TBD. Metrics (plan) - `keeper_shepherd_*` (stream-count gauge, shed-total). **Implementation postponed** (ADR-002/006 amendments). | | **Watchman** | **Isolation-detection + soul-shedding** of one Keeper instance (soul-shedding S2, ADR-002 HA cluster). Background goroutine: periodically (`watchman_interval`, default 5s) pings PG+Redis - the same dependencies as `/readyz`. **Debounce/flap-guard**: isolation is declared only after `watchman_fail_threshold` (default 3) consecutive probe failures (a single network spike does not reset all the streams at once - thundering-herd reconnect). With stable isolation - **actively closes ALL local EventStream streams** (hard-close, without drain): cancels everyone's per-stream ctx (`StreamManager.CloseAll`) → handler returns → gRPC sends Soul EOF → Soul goes to live Keeper via reconnect-loop/failback-list. When dependencies are restored, it resumes normal operation (new streams are accepted, lease-renewal/Acolyte come to life on their own). **The "I'm isolated" solution is centralized in Watchman** - NOT duplicated in per-stream renewal-loops. The metrics are `keeper_watchman_isolated` (gauge 0/1) and `keeper_watchman_streams_shed_total`. (Name `Sentinel` rejected - conflict with Redis Sentinel.) | | **Toll** | (no; similar to Health-checks, but cluster-wide rate-of-loss) | Detector of mass outflow of Souls to cluster-level ([ADR-038](adr/0038-toll.md)). Sliding 60s window, threshold 20% of baseline connected. Per-instance gRPC-disconnect observer + Redis-leader aggregation. Sets cluster:degraded flag → blocks POST scenarios/push-apply. Hysteresis with 60s grace on clearing. Warmup 60s after starting the instance (cluster restart immunity). Name - propose-and-wait (bell ringing / death toll, souls metaphor, does not conflict with soul-shedding=Watchman). | | **`keeper/internal/soulpurview`** | **Souls list scoped visibility resolver by [Purview](#purview---scope-resolver-adr-047)** ([ADR-047](adr/0047-purview.md), S3b; name selected by user, propose-and-wait passed). Souls analogue of `keeper/internal/statepredicate` (this one resolves incarnations according to state-CEL; this one is souls according to Purview scope dimensions). Accepts the already-resolved `rbac.Purview` **parameter** (does not go to the enforcer itself - `ResolvePurview` calls the handler), translates the scope boundary into the parameters of the souls request. Pilot (S3b-0) - coven-dimension only, SQL-pushdown `souls.coven && ARRAY[purview.Covens]`; regex/soulprint page-CEL + keyset - S3b-2. **fail-closed**: empty Purview → empty list (NOT all Souls), `Unrestricted`/`*` → all. Unidirectional dependency `soulpurview→rbac`. **NB (ADR-047 S4):** Voyage command path (`errand.run`, [ADR-043 §6](adr/0043-voyage.md)) reuse `soulpurview` for target intersection with Purview - **new RBAC selector keys for `errand.run` are NOT added** (RBAC keys are limited to {service, coven, incarnation, host}; intersection through the same Purview mechanism, hybrid semantics 403/cut/422 - [ADR-047 §S4](adr/0047-purview.md)). | | **notification-dispatcher** (tap) | **Herald/Tiding Notification Manager** ([ADR-052](adr/0052-herald-notifications.md)). **tap** — multi-writer decorator on top of `audit.Writer` (dot [ADR-022(f)](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)): after a successful PG-record, the audit-event gives it to the dispatcher (audit is primary, dispatcher failure does not roll back the audit-write). **dispatcher** matches the event against the enabled [Tiding](#domain-entities)-rules (`event_types`-area-glob + `only_failures`/`only_changes` + selectors `incarnation`/`cadence`/`voyage_id` for ephemeral) and sets the delivery task - **hot invariant paths: dispatcher only does "match + enqueue copies"** (it does NOT touch the flex body fields `annotations`/`projection`, [ADR-052 Amendment](adr/0052-herald-notifications.md)). **Delivery** - at-least-once via claim-queue worker (parity [VoyageWorker](#domain-entities)/[ADR-027(d)](adr/0027-apply-work-queue.md): `FOR UPDATE SKIP LOCKED` + lease + `attempt++`), retry+backoff; attempt statuses - Redis (hot→Redis, [ADR-006](adr/0006-cache-redis.md)); terminals - audit `herald.delivered`/`herald.failed`. **Merge `annotations` + applying `projection`** to the payload copy when building `webhookPayload` - in the worker (off-path), not in the dispatcher. **Scope MVP - run events** (see [Tiding](#domain-entities)). | | **`channelDriver`** / **`httpDelivery`** (Herald HTTP class) | **Two-class transport delivery model [Herald](#domain-entities)** (ADR-052 amendment, [commit f3739d03](adr/0052-herald-notifications.md); names assigned by user, propose-and-wait passed; `keeper/internal/herald/channel.go`). **`channelDriver`** - driver interface for ONE HTTP class channel type (`webhook`/`telegram`/`slack`/`mattermost`/`discord`/`custom`) with three responsibilities: `validateConfig` (CRUD validation of config by field descriptor + domain invariants, without reading Vault), `secretRequired` (does the type use top-level `secret_ref` - `true` only for `webhook`), `resolveDelivery` (assembling a ready-made `httpDelivery` at the time of delivery - config may have changed after create). Driver registry - `channelDrivers` (canonical **single source of HTTP types**: adding type = one entry here + CHECK migration + huma-enum, verified by guard test with `AllHeraldTypes`). Type `email` is **NOT** `channelDriver` (SMTP axis `net/smtp`, `email.go`, separate branch `DeliveryWorker.deliver`). **`httpDelivery`** - ready result of HTTP delivery resolution: request-blank (`url`/`method`/`body`/`contentType`/`headers`) + SSRF-opt-out-flags (`httpAllowed`/`allowPrivate`, per-Herald opt-out; for fixed public messenger endpoints both `false`) + opt. `signingKey` (HMAC, `webhook` only). **Single SSRF circuit by construction:** The driver ONLY builds `httpDelivery`, and SSRF-guard (`netguard`) and `client.Do` calls itself `DeliveryWorker.deliver` - the new HTTP type cannot bypass guard. | | **`HeraldFieldSpec`** / **`FieldKind`** (channel config field handle) | **Description of one config field of the channel type [Herald](#domain-entities)** (ADR-052 amendment, [commit f3739d03](adr/0052-herald-notifications.md); names assigned by user, propose-and-wait passed; `keeper/internal/herald/channel.go`). **`HeraldFieldSpec`** — channel form field structure: `Name`/`Label`/`Required`/`Secret`/`Kind`/`EnumValues`. `Secret=true` ⟹ field holds vault-ref (`Kind` must be `vault_ref`; the secret is in `config`, not in the top-level `secret_ref` - wiring amendment). **`FieldKind`** - type of field value (closed set of lines for the generic validator and form UI renderer): `string`/`int`/`bool`/`enum`/`map`/`list`/`list_string`/`url`/`vault_ref`. **SINGLE source of validation + directory:** same set `HeraldFieldSpec` (from `channelDriver.fields()` for HTTP class + `emailFields()` for SMTP) and validates config on CRUD (`validateBySpec`), and published by directory endpoint **`GET /v1/herald-types`** (`herald.TypeCatalog` → `HeraldTypeDescriptor`; auth-only, without separate permission, self-describing, event-type/permission-directory pattern [ADR-042](adr/0042-backend-driven-ui.md)) - the directory and validator cannot become out of sync. UI Herald-forms (`POST /v1/heralds`) builds per-type fields from this directory. | | **LoginGuard** | **Anti-bruteforce primitive for public login endpoints** (`keeper/internal/redis/loginguard.go`, [ADR-058(g)](adr/0058-operator-auth-ldap-oidc.md), HIGH-3). Protects `/auth/ldap/login` / `/auth/oidc/login` from brute force. Two cluster-shared mechanisms on top of Redis (authority in Redis, does not propagate ×N across stateless-HA instances): (1) **frequency throttle** - token-bucket for the NUMBER OF ATTEMPTES from the principal (key-prefix `authrl::`, scope = `ip`\|`user`, principal = IP/username, because login pre-JWT and AID are not yet available), the algorithm is the same as [Tempo](#modules-and-subsystems-inside-keeper)-bucket, but has a separate prefix; (2) **lockout by failures** — failure counter in a sliding window (`authlock:::n`), when `lockout_threshold` is reached, the principal is blocked on `lockout_backoff` (for IP and username independently); successful login does not affect the counter. **Fail-CLOSED** on lockout check (unlike fail-open Tempo: login is a security perimeter, Redis unavailability should NOT open brute force). Excess → **429 + `Retry-After` + `application/problem+json`** (problem-type **`auth-throttled`** / `TypeAuthThrottled`; detail anti-oracle - without disclosing scope/reason). Config block **`auth.rate_limit:`** (hot-reload, default-ON for Redis): `enabled` / `rate` / `burst` / `lockout_threshold` / `lockout_window` / `lockout_backoff`. **Boundary with Tempo:** Tempo = per-AID frequency write-API post-JWT; LoginGuard = per-IP/username frequency+lockout of pre-JWT login endpoints. | | **Tempo** | **Per-AID rate-limiter write-API** (Redis token-bucket) ([ADR-050](adr/0050-tempo.md#adr-050-tempo--per-aid-rate-limiting-write-api); name selected by user, propose-and-wait passed). End-to-end frequency limiter for operator calls (by `claims.Subject` = AID) to resolver-heavy write endpoints - **third anti-DoS layer** after body-limit and [Toll](#modules-and-subsystems-inside-keeper). Token-bucket in Redis (Lua-atomic, hash `{tokens, last_refill_ts}` + `PEXPIRE`), key **`tempo::`**; **fail-OPEN with Redis-down** (passthrough, like Toll). MVP buckets (per-AID, individual limits): **`voyage_create`** (`POST /v1/voyages`, defaults `rate 10 rps, burst 20`) and **`voyage_preview`** (`POST /v1/voyages/preview`, defaults `rate 30 rps, burst 60` - softer create: preview = dry-resolve scope, read-like without persist/audit, but resolver-heavy, so not unlimited; own bucket, separate from `voyage_create`). Excess → **429 + `Retry-After` + `application/problem+json`** (problem-type **`tempo-exceeded`** / `TypeTempoExceeded`). Config block **`tempo:`** (hot-reload, [ADR-021](adr/0021-hot-reload-config.md)): `enabled` (default-ON for Redis), `voyage_create.{rate,burst}`, `voyage_preview.{rate,burst}`. Metrics **`keeper_tempo_allowed_total{endpoint}`** / **`keeper_tempo_rejected_total{endpoint}`** (`endpoint` = `voyage_create` or `voyage_preview`; WITHOUT aid label - cardinality). The metaphor is a musical line next to [Conductor](#modules-and-subsystems-inside-keeper)/[Cadence](#domain-entities)/[Choir](#domain-entities) ("acceptable tempo of API calls"). **Border with Cadence:** Cadence = **when to spawn** Voyage; Tempo = **how often the operator pulls the API**. | | **SettingsStore** | **Cluster-wide store of reload-able Keeper settings in Postgres plus its overlay onto the file config** ([ADR-0073](adr/0073-keeper-runtime-config-pg.md); name selected by user, propose-and-wait passed). Owns the `cfg_*` rows of [`keeper_settings`](#service-registry-and-cluster-settings-managed-registry-in-the-database), the Go field-registry `{key, yamlPath, parse, validate, default}`, the write-gate (parse + validate + **range bounds** before publish, 422 fail-closed) and the overlay hook injected late-binding into `shared/config.Store` (the `SetAuditWriter` pattern). Resolves a value in **three layers, per key: built-in default < Postgres < `keeper.yml`** (amended 2026-07-27, NIM-141 — the instance's own file wins; the catalog carries the shadowed `cluster_value`); an absent row falls through to the layer below (**no seeding** of file values into PG — `DELETE` is a clean revert). **Target end-state — `keeper.yml` shrinks to the bootstrap floor** (`postgres`/`vault`/`redis`/`kid`/`listen`/`logging`/`hot_reload` + the security-critical keys); everything else migrates into PG phase by phase. **Operator surface:** API/MCP + editable web-UI form + a dedicated RBAC permission family (distinct from `service.*`), with the field-registry **published as a backend catalog** ([ADR-042](adr/0042-backend-driven-ui.md), the `GET /v1/herald-types` shape: type, bounds, default, effective value, `source`, `requires_restart`). Merge = patch the in-memory `Document` + re-run the full [ADR-021(c)](adr/0021-hot-reload-config.md) validation pipeline; the patched document **never reaches disk**. Cluster propagation reuses the [`service:invalidate`](#redis-pubsub-channels-keeperkeeper) channel + TTL-poll, with an **idempotence guard** (swap only on a real difference). Fail-soft: no Postgres at startup → file base + WARN (not fatal); at runtime → last-good; a bad row rejects the whole overlay; break-glass `KEEPER_CONFIG_SOURCE=file`. **Does NOT own** the [ADR-029(g)](adr/0029-service-registry.md) well-known keys (`default_destiny_source` / `provisioning_allowed_methods`) — those keep their own consumers. **Boundary with the hot-reload pipeline:** [ADR-021](adr/0021-hot-reload-config.md) is *how* a config swap happens (SIGHUP / API, validate, atomic swap); SettingsStore is *where the value comes from* and *how it reaches the other instances*. | ## Destiny: internal terms See **[`docs/destiny/`](destiny/README.md)** - destiny concept, `destiny.yml`, `tasks/main.yml`, `input:`, testing. Architectural summary - section ["Destiny: Input Contract and Validation"](architecture.md#destiny-entry-contract-and-validation). | Name | Role | |---|---| | **Input** | Input contract destiny: block `input:` in `destiny.yml` declares the values ​​expected from the caller (scheme + validation rules). The block format is **standard [docs/input.md](input.md)**, the same for destiny, module manifest and (based on migration results) scenario. In templates and `when:` expressions, the referenced values ​​are `input.`. Not to be confused with step-level `params:` - these are module arguments; the names are intentionally different ("input" - from outside to inside destiny, "params" - inside the module). | | **Output** | destiny output contract: the top-level block `output:` in `destiny.yml` declares what output destiny publishes to the outside. **Symmetrical to `input:`** in shape (same standard [docs/input.md](input.md)); optional. ★ **The declaration is real, the filling is not.** A task-level `output:` is **refused** on every task kind (`output_unsupported`) and `register..` does not resolve: task-level fill and that projection are two halves of one unbuilt slice ([destiny/tasks.md §9](destiny/tasks.md), [scenario/orchestration.md §2.1.1](scenario/orchestration.md)). Declaring the block is not the same as claiming something fills it. destiny-specific - [destiny/output.md](destiny/output.md). destiny `output:` does not violate isolation (destiny gives away its own, does not read someone else's - [ADR-009](adr/0009-scenario-dsl.md)). | | **`secret: true`** (module output field) | Per-field secrecy on a module's **output**, declared by the module and carried on `ApplyEvent.output` ([ADR-083](adr/0083-declared-secret-state-fields.md)): the platform masks exactly those fields wherever the output is observable. **Retires `no_log`**, which was all-or-nothing and set by the *task author* rather than by the module that knows its own output shape - too coarse (suppressing a whole task's diagnostics to hide one field) and unreliable (the author had to know the shape) at the same time. Removed, not deprecated. | ### Intra-host concurrency: `async:` / reserved `parallel:` ([ADR-0075](adr/0075-intra-host-async-tasks.md)) | Name | Role | |---|---| | **`async:`** | Task key (`bool`, default `false`) marking a task as fire-and-forget: it starts in its own flow and the main flow goes on without waiting. A flag on an ordinary task, **not** a grouping construct. The word names the semantics that exists — asynchrony, "start now, collect the result later at a point you choose". Spec — [destiny/tasks.md §6](destiny/tasks.md#6-asynchronous-tasks-async-true). | | **`parallel:`** | **Reserved, not a valid key** (writing it is `unknown_key`). Held for a future concurrent **group with a join** — a bounded set of tasks with a group-scoped outcome and error boundary — which is a different construct from asynchrony, not a synonym for it. The better word is deliberately kept for the stronger meaning; the key had no consumer when it was freed, so nothing was migrated. | | **`require:`** | The **explicit barrier** and the preferred way to depend on an async task: `require: [, …]` waits for the named tasks, `require: all` for every async task started earlier in the run. Preferred over an implicit wait via a `register.` reference because the dependency is then stated in the plan and readable statically. Semantics — [destiny/tasks.md §8](destiny/tasks.md). | | **`async.max_concurrent`** | Host-side ceiling on in-flight async tasks, block `async:` in `soul.yml`. `0`/unset = unlimited; above the ceiling a task waits for a slot and is never dropped. Host-side because how much concurrency a machine tolerates is not something the plan's author can judge — same split as the console's `max_sessions` ([ADR-0074](adr/0074-interactive-console-pty.md)). There is deliberately **no** DSL-level limit. | | **`async_on_block_invalid`** | Fail-closed diagnostic for `async:` on a `block:` task — deferred, not forbidden by design ([destiny/tasks.md §6.5](destiny/tasks.md)). Member of the `_on_block_invalid` family (see [Error codes](#error-codes)); replaces the former `parallel_on_block_invalid`. | | **`async_on_apply_invalid`** | The same gate at the other construct that expands into a group: `async:` on an `apply:` task. Render carries the flag onto none of the destiny tasks the applier fans out into, so the key would be accepted and silently do nothing ([destiny/tasks.md §6](destiny/tasks.md)). | | **`require_forward_reference`** | Fail-closed diagnostic for a `require:` barrier whose source is not earlier in the plan — a later task, or the task's own `register:`. A barrier resolves at the awaiting task's plan position, so a forward one waits for nothing instead of deadlocking; rejecting forward edges also makes a `require:` cycle unrepresentable ([destiny/tasks.md §8](destiny/tasks.md)). | | **`async_on_keeper_invalid`** | The same gate at the third and last construct where `async:` has no meaning: a keeper-side task, executed by the keeper's own runner and never reaching a Soul ([destiny/tasks.md §6](destiny/tasks.md)). | | **`_on_apply_invalid`** | Family of fail-closed diagnostics for module-specific keys on an `apply:` task — `changed_when`/`failed_when`/`retry`/`timeout`/`params`. Membership rule: the key works on a module task and is lost on an applier, which invokes no module and hands its children only the three requisites. The apply-side mirror of `_on_block_invalid` (see [Error codes](#error-codes)); `output:` is outside it - it is unimplemented on every kind, not lost on one, and is refused by `output_unsupported` ([scenario/orchestration.md §2.1.2](scenario/orchestration.md)). | | **`output_unsupported`** | Fail-closed diagnostic for a task-level `output:`, raised on **every** task kind and in both entities that carry tasks. The key parsed and nothing ever resolved it: no consumer materialised a value, no name was checked against the destiny's top-level `output:` schema, while [destiny/tasks.md §9](destiny/tasks.md) documented both halves as working. Its own code rather than a `_on_apply_invalid`/`_on_block_invalid` member: those two mean "works elsewhere, lost HERE", and this key is unbuilt EVERYWHERE. Top-level `output:` in `destiny.yml` stays valid - it is a declaration, not a claim that something fills it ([destiny/output.md](destiny/output.md)). | | **`apply_when_dynamic_unsupported`** | Fail-closed diagnostic for a `when:` on an `apply:` task reading `register.*`/`soulprint.*`. An applier's condition is decided Keeper-side, before its destiny is rendered — a dynamic one has nowhere to be answered, and until it was refused the key was dropped and the destiny applied everywhere. Replacements: `where:` for a host-variant condition, `onchanges:`/`onfail:` for a source's outcome ([scenario/orchestration.md §2.1.2](scenario/orchestration.md)). | | **`when_on_keeper_dynamic_unsupported`** | Fail-closed diagnostic for a `when:` on a keeper-side task reading `register.*`/`soulprint.*`. Judged by the task's SIDE since NIM-749, not by a written `on: keeper` — which is what closed the case where the predicate went through untouched because the author had not written the key. `when:` is a Soul-side predicate and a keeper task never reaches a Soul, so the key was accepted and dropped and the step ran every time — which since [ADR-0084](adr/0084-explicit-state-capture.md) is the step that writes incarnation state. A static `when:` stays the working form (the keeper settles it at render). Replacement: the condition inside the value (`${ cond ? a : b }`), or the step on the Soul side ([keeper/modules.md](keeper/modules.md)). | | **`async` / `require_idx` / `require_all`** | The wire form of the two keys — only-add `RenderedTask` fields 18-20 ([ADR-012(c)](adr/0012-keeper-soul-grpc.md), `proto/keeper/v1/apply.proto`). `require:` names are resolved into task indices Keeper-side (Variant A, as `onchanges_idx`/`onfail_idx`) and remapped global→local per `ApplyRequest`. ★ The `-1` sentinel reads **inversely** to the requisite fields: there an absent source contributes `false` to a gate, here it means "nothing to wait for". All three are zero-value on a plan using neither key, so a Soul that ignores them runs the plan sequentially — correct, only serial. | ## Destiny Modules A module is a unit of work in Destiny (install a package, deploy a file, restart a service, ...). See the [Modules Model](architecture.md#module-model) and [Modules Addressing](architecture.md#module-addressing) sections in the architecture. Modules are addressed in the three-level form `..`: | Level | Role | |---|---| | **alias** (collection) | **The registration alias — the operator's choice**, not the publisher's ([ADR-020(p)](adr/0020-plugin-infrastructure.md#amendment-2026-08-06-nim-377-the-schema-is-generated-from-go-the-artifact-carries-no-name), NIM-377). `core` for built-ins; for plugins, whatever the operator wrote in `keeper.yml::plugins.*[].name` — `redis`, `acme`. The artifact carries **no name of its own**, so the same bytes registered under two aliases answer at two addresses. Reserved names — [below](#reserved-namespace-names). The entity name in the Soul Stack dictionary is still open ([module-collections.md](module-collections.md)); we use the neutral "collection / alias". | | **module** | Control object - `pkg`, `file`, `service`, `user`, `exec`, `http`, ... | | **state** | Desired object state (declarative): `installed`, `absent`, `latest`, `present`, `running`, `stopped`, `restarted`, `enabled`. For non-stateful (read-probe / imperative) modules - verb form (`run`, `shell`, `probe`). | ### The discipline binding the three levels The table above states the **grammar**. What follows is the **discipline** that keeps the three levels meaning what the table says they mean — the user's decision of 2026-09-02, recorded in [ADR-020 amendment 2026-09-02](adr/0020-plugin-infrastructure.md#amendment-2026-09-02-nim-764--nim-765-a-plugin-address-is-pluginobjectaction-and-the-origin-grouping-level-is-removed) (NIM-764 / NIM-765). ★ **Served by `redis`** (NIM-766, NIM-767): the artifact registers under the alias `redis` and serves seven objects — `acl`, `cluster`, `command`, `instance`, `replica`, `sentinel`, `user` — so `redis.instance.pinged`, `redis.cluster.created` and `redis.user.present` are what the catalog answers to today ([`docs/module/redis/`](module/redis/README.md)). ★ **Served by `mongo`** (NIM-769): the artifact registers under the alias `mongo` and serves three objects — `command`, `instance`, `user` — so `mongo.instance.pinged`, `mongo.user.present` and `mongo.user.absent` are what the catalog answers to ([`docs/module/mongo/`](module/mongo/README.md)). `params.state` went with the grouping level: the verb belongs at level 3, so a service choosing per item filters its loop instead of passing a verb as a parameter. A plugin step's address is **`..`** — `redis.instance.pinged`. That is the grammar core has spoken all along (`core.user.present`, `core.file.rendered`, `core.state.set`); the plugin was the outlier. - **Level 2 is the object the module manages** — a noun: `user`, `file`, `service`, `instance`, `replica`, `cluster`. It is **not** the plugin's own subject and **not** its origin. The old `community.redis.acl` put the plugin's own subject at level 2, which left level 3 with nothing to name but a second subject. - **Level 3 is the action** — the state the object is left in (`present`, `absent`, `pinged`, `rendered`, `synced`), or, when the object is non-stateful, the single verb naming the operation (`run`, `shell`, `probe`). It is never the name of a subject. ★ **An object that takes the verb form takes exactly one: two operations are two objects.** That clause is the point — without it a level 2 spelled `command` re-admits `acl` / `role` / `offset-synced` at level 3 under a different roof. - **The grouping level is not written.** `community.*` and `official.*` named a plugin's *origin* rather than its subject, and both are removed on the same rule. Origin is still answerable — it is the catalog entry's `source` in `keeper.yml::plugins.*[]`, plus the Sigil allow-list of which digests may run ([ADR-026(a)](adr/0026-sigil.md#amendment-2026-08-06-nim-377-the-registry-keys-on-the-artifact-source-the-signature-is-not-a-control-on-declarations)) — but it is not answerable from address level 1. See [module-collections.md](module-collections.md). - **This is a naming discipline, not a mechanism.** The engine reads no word at any level: it splits the address positionally into exactly three segments ([`splitModuleAddress`](../shared/config/module_params.go)) and accepts three kebab-case segments ([`reModuleAddress`](../shared/config/scenario_task.go)). Nothing checks that level 2 is a noun or that level 3 is a state, and an address that violates this section loads and runs. `redis.user.present` matches the pattern today, and changing level 1 is a config edit: the alias *shape* (`plugin.AliasPattern`) admits both words, and *reservation* is the separate closed list (`reservedAliases` / `plugin.IsReserved`, [`shared/plugin/reserved.go`](../shared/plugin/reserved.go)) — which holds neither `redis` nor `community`. See [reserved names](#reserved-namespace-names). - ⚠ **No gate follows from a word in a plugin's address.** The gates are not the same shape. The [ADR-0074](adr/0074-interactive-console-pty.md) console gate (`keeper/internal/shellgate`) reads a **closed set of full core addresses** and nothing else — `coremanifest.IsVerbShell`, an exact lookup over `core.cmd.shell` and `core.exec.run` ([`shared/coremanifest/verbshell.go`](../shared/coremanifest/verbshell.go)); the keeper-side dry-run check ([`ValidateDryRunModule`](../keeper/internal/errand/dryrunshell.go)) is a second reader of that same set and can only reject with it. The Errand allow-list ([`IsAllowed`](../soul/internal/runtime/errandrunner/whitelist.go)) is the one that is not a closed set: **five** arms on its non-dry-run path — that same closed set, a defensive `mod == nil` reject, the exact address `core.http.probe`, a `core.http.` **prefix** match that also rejects, and the `sdkmodule.ErrandReadSafe` marker. Three of the five key on `core.` addresses, and `core` is [reserved](#reserved-namespace-names) — no plugin can claim it, so a plugin address reaches none of them, prefix arm included. What decides for a plugin is the marker, and it is **default-deny** (`BaseModule` implements neither `ErrandReadSafe` nor `PlanReadSafe`). A plugin object named `command` therefore gets **no** gate from its name **and no admission either**. Neither count above is a list to trust unchecked — re-derive it from the call sites before a ticket rests on it. ### Related terms | Name | Role | |---|---| | **Core modules** | Basic modules statically built into the `soul` binary (**Soul-side**) or into the `keeper` binary (**Keeper-side**). They live in namespace `core`. Addressed as `core.pkg.installed`, `core.file.present`, ... The side follows from the ADDRESS and a task never restates it (NIM-749): the two sets are disjoint, so `core.state`/`core.cloud`/`core.soul`/`core.vault`/`core.choir`/`core.bootstrap`/`core.cert` are Keeper-side and the other twenty-one Soul-side. `on:` means only "which covens" ([scenario/orchestration.md §3](scenario/orchestration.md)). The first Keeper-side core is `core.soul.registered` ([keeper/modules.md](keeper/modules.md)). | | **Custom modules** | External modules supplied as executable artifacts. The `soul` binary launches one as a sub-process over the gRPC-stdio handshake, naming the module it wants as a **subcommand** (` acl`). They live under their registration alias. | | ~~**`soul-mod-`**~~ | **No longer a contract (NIM-377).** A repository may still name its output `soul-mod-redis` for the humans reading `dist/`; the loader takes the single executable in the slot and never computes a filename. | | **SoulModule API** | gRPC contract between the `soul` binary (host) and a module (sub-process). Service `SoulModule` with methods `Validate`, `Plan`, `Apply`. There is **no `Manifest()` RPC**: the schema is read from the artifact's trailer without executing it — at `plugin.allow` the binary is not yet approved ([ADR-020(o)](adr/0020-plugin-infrastructure.md#amendment-2026-08-06-nim-377-the-schema-is-generated-from-go-the-artifact-carries-no-name)). The `state` parameter is a required part of the call. | | **Module declaration** | Per module, in the [schema document](#plugin-manifest-and-handshake): `name` (address level 2), `description`, **the states** with an input schema each, `capabilities`, `side_effects`, `introduced_in`. Artifact-level: `kind`, `protocol_version`, `compat`. The module's version as an artifact is **not in the document** — it is the git ref it is committed under ([ADR-007](adr/0007-versioning-git-ref.md)). | ### Specific core modules Full list of core MVPs fixed [ADR-015](adr/0015-core-modules-mvp.md); keeper-side extensions - [ADR-017](adr/0017-keeper-side-core.md). Parity strategy and Soul Stack license - [ADR-016](adr/0016-parity-license.md). **Soul-side core MVP (statically built into `soul` binary):** | Name | State-forms | Role | |---|---|---| | **`core.pkg`** | `installed` / `absent` / `latest` | OS packages, abstraction via native pkg-mgr. | | **`core.file`** | `present` / `absent` / `rendered` | The file exists with literal-content / missing / rendered from `.tmpl` (see [ADR-010](adr/0010-templating.md), [templating.md](templating.md)). Directory management moved out to **`core.directory`** ([ADR-015 Amendment 2026-07-17](adr/0015-core-modules-mvp.md)). | | **`core.directory`** | `present` / `absent` | Directory management, split out of the former `core.file.directory` ([ADR-015 Amendment 2026-07-17](adr/0015-core-modules-mvp.md); hard rename, no back-compat). `present` - directory exists with `mode`/`owner`/`group` (opt. `parents` = `mkdir -p`); idempotent create + `chmod`/`chown` drift-fix; type-conflict on a non-directory; `PlanReadSafe`. `absent` - removes a directory: an empty one always (`os.Remove`), a **non-empty** one ONLY with `recursive: true` (default `false` → error `directory … is not empty`) - a deliberate divergence from silent recursive delete. Guards: refuses `path == "/"` and a symlink at `path`, never traverses in-tree symlinks. | | **`core.service`** | `running` / `stopped` / `restarted` / `enabled` / `disabled` / `masked` | Service, abstraction via systemd/openrc/sysv. Opt. param `daemon_reload` (string enum `auto` \| `always` \| `never`, default `auto`) on states `running`/`restarted`/`enabled` (NOT `stopped`): systemd-backend before mutating action does `systemctl daemon-reload` - `auto` with `NeedDaemonReload=yes` (gated, idempotent), `always` unconditional, `never` opt-out; openrc/sysv - no-op. reload does not mark the step changed; actually performed reload → diagnostics `output.reloaded=true` ([ADR-015 Amendment 2026-06-18](adr/0015-core-modules-mvp.md)). `disabled` - boot-autostart off (mirror of `enabled`, orthogonal to `stopped`; backend-agnostic). `masked` - unit unstartable (`systemctl mask`, symlink→`/dev/null`; strictly stronger than `disabled`); **systemd-only** (openrc/sysv → error, fail-closed), disable-before-mask ([ADR-015 Amendment 2026-07-17](adr/0015-core-modules-mvp.md)). | | **`core.user`** | `present` / `absent` | Local OS users. Opt. params present: `uid`, `shell`, `home`, `groups` (supplementary, `-G`), `system` (bool → `-r`, system account), `group` (string → `-g`, primary-group must exist). `present` - present-or-create (the existing one is not reconciled, new params are valid only when created). | | **`core.group`** | `present` / `absent` | Local groups. Opt. params present: `gid`, `system` (bool → `-r`, system group; compatible with `gid`). | | **`core.exec`** | `run` (verb) | Arbitrary command, exec(). Probe idiom [ADR-008](adr/0008-coven-stable-tags.md). | | **`core.cmd`** | `shell` (verb) | shell command (pipes, redirects). | | **`core.cron`** | `present` / `absent` | Cron tasks. | | **`core.mount`** | `present` / `absent` / `mounted` / `unmounted` | Mount points, /etc/fstab. | | **`core.git`** | `cloned` / `pulled` | Cloning/updating a git repository on the host. | | **`core.archive`** | `extracted` | Unpacking archives (tar/zip/gz/bz2). | | **`core.sysctl`** | `present` / `applied` | Kernel parameters. `present` - ONE key (`vm.overcommit_memory`, `kernel.shmmax`, ...) runtime + persist. `applied` — BULK-set `settings` (map) with one deterministic drop-in `/etc/sysctl.d/.conf` (sorted keys) + point `sysctl -p ` when changing (`reload: auto/always/never`, `ignore_failures` → `-e`); the module itself owns drop-in (a deliberate exception to the boundary with `core.file`, [ADR-015](adr/0015-core-modules-mvp.md) amend 2026-06-24). | | **`core.url`** | `fetched` | Uploading a file via URL. `https` default; `http` / insecure-TLS / private IPs - explicit per-call opt-out (`allow_http`/`insecure_skip_verify`/`allow_private`, each default `false`, removal → warn in output `warnings`); idempotency via `checksum` (`sha256`/`sha1`) or SHA-256 comparison; atomic verify-then-rename; `headers` sensitive-by-construction ([ADR-010](adr/0010-templating.md) §7.4). | | **`core.line`** | `present` / `absent` | In-place line-by-line editing of an existing file (lineinfile equivalent). Stripped-down safe MVP: `present`+`regexp` replaces the FIRST match line (+warning if >1), `present` without `regexp` adds the exact line by `insertafter`/`insertbefore` (literal/EOF/BOF, NOT regexp), `absent` removes everything coincidences. **backrefs not supported**; the write is atomic (see [ADR-015](adr/0015-core-modules-mvp.md)). | | **`core.repo`** | `present` / `absent` | Package repository (apt/dnf/yum/apk). Backend by `util.DetectPkgMgr`: apt → `sources.list.d/.list` + key in `/etc/apt/keyrings/.asc` (armored) or `.gpg` (dearmored), referenced by `signed-by=`, NOT `apt-key`; dnf/yum → `yum.repos.d/.repo`; apk → line in `/etc/apk/repositories`. Idempotency: file + content + key. **Security:** `gpg_key` critical (supply-chain); `gpg_check=false` — opt-out + mandatory warning; `http://` allowed (internal mirror) + mandatory warning (see [ADR-015](adr/0015-core-modules-mvp.md)). | | **`core.firewall`** | `present` / `absent` | ONE firewall rule (`ufw`/`firewalld`). Backend by `util.DetectFirewall` (according to the installed binary): ufw and firewalld (iptables is deferred). Idempotency: parsing `ufw status` / `firewall-cmd --list-...`. **CRITICAL INVARIANT:** NEVER touches the default policy and does not enable the entire firewall (`ufw enable` / `systemctl start firewalld`) - otherwise it will cut off SSH; only add/delete a specific rule (see [ADR-015](adr/0015-core-modules-mvp.md)). | | **`core.http`** | `probe` / `request` (verbs) | HTTP response into `register`, never a file. `probe` is strictly GET/HEAD (default GET), read-only, `changed=false` always. `request` requires one of POST/PUT/PATCH/DELETE and reports `changed=true` only on an expected status; it performs one call, while idempotency/retry belong to the API/scenario. Common response: `status` / `body` (64 KiB cap + `truncated`) / `elapsed_ms` / `headers_keys`; mismatch stays diagnostic `failed`. Both use independent default-on guards (`allow_http`/`allow_private`/`insecure_skip_verify`) and only `network_outbound`. All header values are sensitive-by-construction, including `X-Consul-Token`. Only exact state `core.http.probe` is Errand-safe; mutating `request` is default-deny ([ADR-015 amendment 2026-08-08](adr/0015-core-modules-mvp.md), [ADR-033 amendment 2026-08-08](adr/0033-errand.md)). | | **`core.noop`** | `run` (verb) | **No-op / barrier-anchor** - does nothing, always succeeds without change (**`changed=false`** constructive). `params:` carries no schema, so only `params: {}` loads - any key is refused (`unknown_param`); name registers on the anchor through `vars:`. Purpose: barrier-anchor (a task that accesses `register.*` of several previous ones, gives a point of waiting for their completion - the barrier itself gives a dependency graph, not a module) and placeholder (an empty step). It was also described as the carrier of the `output:`-projection; that projection is unbuilt and a task-level `output:` is now refused (`output_unsupported`), so the barrier-anchor is the only live purpose. Use case `changed=false` - `core.exec.run`/`core.http.probe`. See [ADR-015](adr/0015-core-modules-mvp.md). | **Not highlighted as separate:** `core.template` (rendered by `core.file.rendered`); `core.copy` (covered by `core.file.present` with inline-content). **Infrastructure (not included in the 17 MVP count):** `core.module.installed` ⚠ **the delivery path gains a SECOND arm (NIM-794 / NIM-795 — the keeper half is not implemented; the Soul half shipped as NIM-796, and with no grant yet carrying artifact rows everything described below is what ships today)** - delivery of custom modules to the host (Keeper → server-streaming RPC `FetchModule` → catalog cache `//`, Sigil-verify before atomic rename - [ADR-065](adr/0065-core-module-installed.md)). The operator writes in Destiny an explicit step `module: core.module.installed` with `params: {name: , ref?: }` before using the custom module for the first time (`ref` - pin verification of active tolerance, NOT version selection); idempotency - sha256 of the installed binary == sha of the active Sigil → `changed=false`; after installation - hot-register without restarting the daemon. `name` is **address level 1 alone** - the registration alias the operator chose in `plugins.soul_modules[]`, never the two-level `.` a task addresses: one artifact serves every module it declares, so what gets installed is the artifact, and a dotted value is refused (NIM-524; before NIM-377 level 1 was a namespace the artifact declared, which is where the older `.` spelling came from). Implemented as a core module for consistency with Destiny DSL ([ADR-015 Consequences](adr/0015-core-modules-mvp.md)). ⚠ **The 2026-09-04 amendment (NIM-794) changes exactly one thing in the above — where the bytes come from.** A host that can reach the artifact source pulls them **from the source** (`source_kind: artifact`), and that becomes the primary path; **`FetchModule` is retained and NOT deprecated**, as the path for hosts without egress. The step name, its params, the allow-check before a single network byte, the verify before the atomic rename and the hot-register are all unchanged; the idempotency compare is against the digest of the artifact row matching this host rather than against a single scalar. ★ **The row is selected from the host's own Soulprint facts** — `os.family`, with the four Linux distro families collapsed to `linux` because a row names an operating system where the fact names a distribution, and `os.arch` — **with the running binary's platform as the fallback** where a fact is missing (`Module.hostPlatform`, `soul/internal/coremod/module/source.go:315-331`); an earlier draft of the amendment said `runtime.GOOS`/`runtime.GOARCH` and is superseded ([ADR-065 amendment](adr/0065-core-module-installed.md#amendment-2026-09-04-nim-794-the-fetch-step-goes-to-the-source-and-fetchmodule-stays-as-the-egress-free-path)). **Keeper-side core (routed by the module address, see [keeper/modules.md](keeper/modules.md)):** | Name | State-forms | Role | |---|---|---| | **`core.soul.registered`** | `registered` | Linking SID (string OR list) to souls registry coven tags. Onboarding barrier ([ADR-061](adr/0061-onboarding-await-and-midrun-reresolve.md)): `await_online` (bool) + `await_timeout` (duration, required-when `await_online`) + `await_min_count` (int, default = SID number) + `await_poll_interval` (duration, ~2s) - blockingly waits for presence (Redis SID-lease) to `await_min_count`/timeout; B1-strict (online < min → `failed`); output `online[]`/`pending[]`/`satisfied`; ceiling `keeper.yml::max_await_timeout`. `refresh_soulprint` (bool) - mid-run re-resolve roster (animated; stratification/re-resolve - S2/S3 slices). | | **`core.cloud`** ⛔ **LEFT THE DICTIONARY (NIM-757 / NIM-761, removed 2026-09-04)** (author-address `core.cloud.created` / `core.cloud.destroyed` / `core.cloud.resized`) | `created` / `destroyed` / `resized` | Creating/deleting VMs via CloudDriver plugin. Replaces the pattern "destiny `cloud-provision`" ([ADR-017](adr/0017-keeper-side-core.md)). **★ In `module:` the operator writes `core.cloud.created` / `core.cloud.destroyed` / `core.cloud.resized`** (base `core.cloud` + state). The form `core.cloud.provisioned` **DOES NOT exist** as a task address - the registry (`keeper/internal/coremod/registry.go`) breaks the address into base+state, `provisioned` is an unknown state (the integration test catches it as fail). The word "provisioned" is the historical name of the Go package (`coremod/cloud/provisioned.go`) and the wording of ADR-017, a non-author-facing address. | | **`core.bootstrap.issued`** | `issued` | Keeper issues one-time bootstrap capabilities for a unique non-empty list of ready-made VM FQDN/SIDs (`on: keeper`), independent of `core.cloud.created`. The past-tense state names the achieved state: every accepted `pending`/`expired`, `transport=agent` Soul has a fresh token ready for delivery. Output `hosts[]={sid,bootstrap_token,expires_at,created,reissued}` is the current run's sensitive register only; the audit event is `bootstrap.issued` without token material. An already onboarded identity gets no token either way; whose host it is decides the rest — this run's own is converged over as `{sid,onboarded:true}` and counted in `skipped`, another incarnation's is refused fail-closed (NIM-780). | | **`core.bootstrap.delivered`** | `delivered` | Delivery of a per-VM bootstrap token via SSH after either `core.bootstrap.issued` or `core.cloud.created` ([ADR-063](adr/0063-bootstrap-token-delivery.md), `on: keeper`). Two modes: **token-only** and **full-install** (`install: true`, Teleport). Direct transport requires `hosts[].primary_ip` for every host it dials; Teleport addresses by SID and does not. A host flagged `onboarded: true` is dialed on neither and needs no address on either. The token is written through STDIN, redeemed with guarded `soul init`, and never returned from this state. Output `hosts[]={sid,delivered,started}`+`count` without token; B1-strict. | | **`core.choir.present`** / **`core.choir.absent`** | `present` / `absent` | Editing the Voice's membership in the Choir incarnation (base `core.choir` + state, [ADR-044](adr/0044-choir.md), `on: keeper`). `present` → `AddVoice`, `absent` → `RemoveVoice` (both are idempotent: Voice already exists/no longer exists → `changed=false`). Params: `incarnation`, `choir`, `sid` (all required), opt. `role`/`position` (`present` only). Invariant ADR-044 "Voice only for incarnation member SID" - in choir-CRUD. Registered only when `Deps.ChoirStore` is specified, otherwise the step is "unknown keeper-side module". See [keeper/modules.md](keeper/modules.md#corechoirpresent--corechoirabsent). | | **`core.vault.kv-read`** | `kv-read` | Explicit reading of the Vault KV secret on the keeper side (for audit-accurate cases; in CEL expressions, implicit `${ vault(...) }` remains). **Fenced out of the service's own namespace** since [ADR-083](adr/0083-declared-secret-state-fields.md): a `path:` resolving under `secret///` is rejected at load, because that prefix is derived from the `state_schema` declaration and is never author-written. Outside the prefix (a shared TLS CA, another service's credential) - unchanged. | | **`core.vault.kv-present`** | `kv-present` | Generate-if-absent for Vault KV secrets (base `core.vault` + state, same module as `kv-read`, [ADR-017(b)](adr/0017-keeper-side-core.md), `on: keeper`). Guarantees the existence of secrets: a missing field generates a crypto-random value according to the **password-policy** described by the author (length in characters + alphabet), a present one - no-op (`changed=false`). Params: `targets` (required, `[{path, field?=password, policy?}]`), step-level `policy` (general default). `policy`: `length` (characters, default 32) + `charset` (`alphanumeric`/`hex`/`base64url`/`ascii-printable-safe`, default redis.conf-safe) OR `allowed_chars` (explicit alphabet, mutually exclusive with `charset`). **The generated value never ends up in register-output / audit-payload / log** (security-invariant [ADR-010](adr/0010-templating.md)): output carries only `generated` = map path → [field names], audit-event `vault.kv-present` - only a list of paths + fields. Destroy does NOT clean secrets (there is no rotation/deletion). **Fenced out of the service's own namespace** since [ADR-083](adr/0083-declared-secret-state-fields.md), on the same rule as `kv-read`: inside `secret///` the write belongs to [`core.state.`](#specific-core-modules), which derives the path instead of taking one. The **password-policy grammar itself is unchanged** and moves to shared code so both callers parse one definition. | | **`core.state.set`** / **`.present`** / **`.add`** / **`.append`** / **`.modify`** / **`.remove`** / **`.unset`** | the [ADR-057](adr/0057-state-changes-crud-verbs.md) verb | The write point of a service state field ([ADR-0084](adr/0084-explicit-state-capture.md), `on: keeper`). Base `core.state`; **the address suffix is the verb**, one state per verb, applied by the same engine the retired `state_changes:` used - a verb cannot mean two things depending on which path wrote it. The field lands in `incarnation.state` **at the step**, under the run that produced it, so a later task reads what an earlier one wrote and a run that dies half-way keeps what it had already captured. Params: `field` (the top-level state property, all verbs) + `value` (`set`/`present`/`add`/`append`) + `key` (`add`) / `match` (`add`/`modify`/`remove`) / `on_conflict` (`add`) / `patch` (`modify`) / `expect` (`modify`/`remove`); a param the verb does not take is an authoring error, not a silent drop. **Secret resolution is orthogonal to the verb** ([ADR-0083](adr/0083-declared-secret-state-fields.md) §4): on a property declared `type: secret` every verb keeps an existing Vault value and mints only what is missing, `set` included - `set` overwrites ordinary content, it does not rotate a credential. `present` answers the other question - whether the incoming value reaches the field at all. Returns the **effective** state in its register - *a generator's output is a candidate, a writer's output is the truth* - carrying secret properties as **references**, never plaintext, so nothing secret transits `apply_task_register`. Path segments are validated against the [ADR-064](adr/0064-secret-write-path.md) `secretwrite` grammar and **fail closed** - `` is operator-influenced data. | ### SDK marker interfaces Optional marker interfaces in `sdk/module/`. The module implements them to declare the host's capability. Host applies default-deny: without declaration - the path is not called. The method has the same name as the interface; the body is empty. | Interface | Declares | Who checks | Default-deny if absent | |---|---|---|---| | **`PlanReadSafe`** | Plan - pure-read (does NOT mutate the host) | Soul-runner (apply-stream); Soul-side Errand-runner on `dry_run: true` | dry_run task → `plan.unsupported`; dry_run Errand → `errand_dry_run_unsupported` | | **`ErrandReadSafe`** | The module's **Apply** is safe to call ad-hoc via Errand (does NOT mutate incarnation.state) | Soul-side Errand-runner on `dry_run: false` | reject from `errand_module_not_allowed` | `BaseModule` does NOT implement any marker by default. The two markers describe **different methods** and are checked on **different paths**; neither implies the other, and neither is consulted on the path it does not describe (see [ADR-033 → Amendment 2026-08-07](adr/0033-errand.md), NIM-488). A module carrying only `PlanReadSafe` — `core.file` and 12 others — can be asked what it *would* change through an Errand, and still cannot be applied through one. ## Service / Incarnation / Scenario See sections ["Service - structure and manifest"](architecture.md#service---structure-and-manifest) and ["Incarnation - runtime service instance"](architecture.md#incarnation--runtime-service-instance). The scenario specification is [`docs/scenario/`](scenario/README.md). | Name | Role | |---|---| | **Service** | Service type - reusable definition (Redis HA, PostgreSQL, Vector). One service = one git repo. Contains `service.yml`, `vars/00-base.yaml` ([ADR-0082](adr/0082-service-vars.md)), `scenario//main.yml`, `migrations/` (with its generated `schema.lock`), `tests/`. | | **`lifecycle.auto_create`** / **`lifecycle.auto_destroy`** (manifest `service.yml`) | Optional block `lifecycle:` in `service.yml` - incarnation life cycle policy. `auto_create: bool` (default `true`) — `POST /v1/incarnations` immediately launches scenario `create`; `false` - the incarnation is created in `ready` without running, the operator starts `create` manually. `auto_destroy: bool` (default `true`) - deletion starts teardown `destroy` according to the logic of `allow_destroy`; `false` - deletion is always direct, without teardown (priority over `allow_destroy`). No block = both `true` (backcompat). See [Service - structure and manifest](architecture.md#service---structure-and-manifest). | | **`runnable`** (scenario directory field) | The bool field in the response `GET /v1/services/{id}/scenarios` is a sign of "the scenario is launched by the operator from the Run form." `create` = `true`; operational (incl. `converge`) = `true`; `destroy` = `false` (special deletion flow via `DELETE /v1/incarnations/{id}`). The UI filters the Run form by `runnable`, not by name hardcode ([ADR-042](adr/0042-backend-driven-ui.md)). | | **`revealable_secrets[]`** (manifest `service.yml`) | ⛔ **REMOVED by [ADR-083](adr/0083-declared-secret-state-fields.md)** - a secret is declared as a `state_schema` field (`type: secret`) and its Vault path is derived, so the author-written `vault_ref` this block existed to carry has nothing left to say; the diagnostic `vault_ref_not_service_scoped` goes with it, the escalation class it fenced being no longer expressible. The reveal surface it fed is untouched: the endpoints, the right [`incarnation.view-secrets`](#permission-incarnationview-secrets) and the audit event `incarnation.secret_revealed` all stay, reading the derived path instead of a declared one. **The retired form, for reading old manifests:** declarative registry of plaintext incarnation secrets disclosed to the operator ([ADR-070](adr/0070-secret-reveal-path.md)): list of entries **`{id, label, enumerate: state., vault_ref: "secret/{service}/{incarnation}/…/{key}#field"}`** - the service itself declares WHAT is disclosed (generic, NOT redis hardcode in the kernel). Placeholders `{service}`/`{incarnation}`/`{key}` - literal substitution (**not CEL**); `{service}` (=`inc.Service`) and `{incarnation}` (=`inc.Name`) **both are required** (diag `vault_ref_not_service_scoped` on load), `key` is required ∈ enumerate array of the current `state` (anti-arbitrariness). The resolved path is read ONLY under `secret///` (positive prefix-allowlist, main guard) + `vault.ParseRef` traversal-guard + `DeniedByVaultFloor`-backstop. Read by reveal/revealable endpoints under the right [`incarnation.view-secrets`](#permission-incarnationview-secrets). Name - **DevOps-term** (section-field of the manifesto/API, rule "small = DevOps"; does not create a thematic pattern of the dictionary - generalized registry, unnamed Soul Stack entity). | | **Incarnation** | A specific runtime instance of the service (one cluster). Stored in Postgres, not git. Has `spec` (what the operator declared), `state` (current configuration), `status` (running / error_locked / drift / ...). | | **Scenario** | Operation on incarnation: `create`, `add_user`, `update_acl`, `add_replica`, `restart`, `migrate_cluster` (create-scenario: raise a new cluster and fill it with data from an external source via native replication), `detach_source` (day-2: unlink the external source after migration), etc. Has a typed `input:` and its own `core.state.` capture steps. Folder `scenario//` in the service repo, entry point `main.yml`. After [ADR-009](adr/0009-scenario-dsl.md) inherits the full DSL core of destiny tasks ([destiny/tasks.md](destiny/tasks.md)) + orchestration delta ([scenario/orchestration.md](scenario/orchestration.md)). | | **converge** (scenario-kind, operational) | **Operational** scenario-kind, describing the DESIRED end state of the service ([ADR-031 amendment 2026-06-10](adr/0031-scry-drift.md#adr-031-scry--drift-detection-declarative-dry-run-reconcile)). **NOT included in the lifecycle set** (`LifecycleScenarioNames` = `create`/`destroy`). Since NIM-446 it has a SINGLE role: a regular `run` — Apply-reconcile that really brings hosts to the declaration (`runnable: true`). Its former second role as the dry-run target of `check-drift` left with the drift circuit, and with it the `ConvergeScenarioName` constant — keeper no longer special-cases the name at all. File `scenario/converge/main.yml` in the service repo, discovered like any other scenario. Shipping one is optional; a service without it simply has no converge scenario to run. | | **create** / **destroy** (scenario-kind, lifecycle) | Lifecycle set (`LifecycleScenarioNames`) - two specialized scenario-kinds of life cycle phases. **`create`** — bootstrap incarnations (`runnable: true`, launched `POST /v1/incarnations` at `lifecycle.auto_create: true` or manually from the Run form; rerun fallen bootstrap from `error_locked` — `POST /v1/incarnations/{id}/rerun-last`, if create was the last fallen scenario). **`destroy`** — teardown operation (S-D2b, [ADR-027](adr/0027-apply-work-queue.md); file `scenario/destroy/main.yml`, `runnable: false` — special deletion flow via `DELETE /v1/incarnations/{id}` / `keeper.incarnation.destroy`, not from the Run form). | ### Upgrade v2: directory `upgrade/`, key `from:`, upgrade-paths Version upgrade of the incarnation to a new version of the service with optional host orchestration of the transition. Design - [ADR-0068](adr/0068-service-upgrade-v2.md); action - `POST /v1/incarnations/{id}/upgrade`. | Name | Role | |---|---| | **`upgrade//`** (service-repo directory) | **Second channel of auto-discovery scenarios** ([ADR-0068](adr/0068-service-upgrade-v2.md), amends [ADR-009](adr/0009-scenario-dsl.md)): top-level directory next to `scenario/`, entry point `upgrade//main.yml`. Keeps version-to-version upgrade scenarios **separate** from day-2 `scenario/` - NOT shown in `GET /v1/services/{id}/scenarios` lists, visible only through the upgrade circuit. Go constant `upgradeDir`, scanner `ListUpgrades`. Found for the transition `from→to` → **autostart** during upgrade (`found` mode); not found → `legacy` (pin change + state migration + `drift`). | | **`_/`** (service-repo directory under `scenario/`) | **Shared task bodies of a scenario family, not a scenario** ([ADR-009](adr/0009-scenario-dsl.md) amendment 2026-08-17, NIM-694): a directory under `scenario/` whose name begins with `_` (or `.`) holds the pieces several scenarios of one family share — `scenario/_create/provision.yml` included by both `scenario/create/main.yml` and `scenario/create_from_souls/main.yml` as `include: _create/provision.yml`. **The prefix is the signal, not the absence of `main.yml`**: such a directory is skipped by scenario discovery even when it contains one, so a shared body is never listed as a runnable scenario. Go predicate **`config.IsSharedDirName`** — one rule read by every walker (`ListScenarios`/`ListUpgrades`, the deprecation survey) instead of each re-deriving it. Applies under `upgrade/` identically. A **directory convention**, not a new entity: nothing enters the runtime vocabulary. | | **`id_template`** (top-level key `scenario//main.yml`, create only) | Template composing the incarnation name from that scenario's own `input:` components instead of taking it as free text ([ADR-079](adr/0079-incarnation-name-template.md)): a `${ … }` string over **input only** (same narrow CEL sandbox as `required_when`/`validate:`), e.g. `"${input.name}-${input.project}-${input.subproject}-redis-${input.service_type}"`. Go field **`IDTemplate string`**. A **plain DevOps key**, not a dictionary entity — nothing new enters the vocabulary beyond the key name. Rendered Keeper-side over the RESOLVED input BEFORE the row is inserted; `name` in the request then becomes optional and, against a composing scenario, **forbidden** (422 `id_not_composable`). The composed string is validated against the incarnation name grammar — over 63 characters is a 422 `composed_id_invalid`, never a truncation. Read ONLY on the create path, so the components feeding a name are **write-once identity** (a later run does not rename). soul-lint: `id_template_input_unknown` / `id_template_invalid` / `id_template_too_long` (ERROR), `id_template_constant` / `id_template_ignored` (WARNING). **Spelled `name_template` until [ADR-0085](adr/0085-entity-id-and-label.md) / NIM-730**; both keys are read for a compatibility window, the old one warning `id_template_legacy_spelling` with a line and a replacement, and declaring both is `id_template_conflict`. | | **`from:`** (top-level key `upgrade//main.yml`) | Self-describing a list of source versions (git tags) from which the upgrade scenario can upgrade. Go field **`FromVersions []string`** (YAML `from`, JSON `from_versions`). Symmetry with `create: true` ([ADR-065](adr/0065-core-module-installed.md)): the discriminator is in the scenario file itself, not in the registry. Declared in the **NEW** version (immutable tags [ADR-007](adr/0007-versioning-git-ref.md): the new version knows its sources, the old tag is frozen). An empty `from:` does not match anything. Channel isolation **physical** - strain `from:` to `scenario//main.yml` in day-2 reply does not leak. | | **`GET /v1/incarnations/{id}/upgrade-paths`** (+`?to=`) | READ endpoint "where and how can I update" ([ADR-0068](adr/0068-service-upgrade-v2.md)), incarnation-scoped. Without `?to=` - cheap list of service registry tags (`ref`/`type`/`commit`/`is_current`; direction is NOT calculated - prohibition of semver parsing of tags [ADR-007](adr/0007-versioning-git-ref.md)). With `?to=` - on-demand analysis of one target (`direction`/`mode`/`slug`/`reachable`+`unreachable_reason`/applied state migrations). Permission `incarnation.upgrade` (read-edge), without audit. | | **`direction`** (field `target`, `?to=` only) | Direction relative to the current pin/circuit: **`no-op`** (target == pin and circuit == current) \| **`downgrade`** (target scheme < current; forward-only [ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl)) \| **`forward`** (target scheme > current) \| **`same-schema`** (schemes are equal, ref is different - ref-bump). | | **`mode`** (field `target`, `?to=` only) | Availability of an upgrade scenario for the transition: **`found`** (there is a scenario whose `from:` ⊇ current pin → host orchestration) \| **`legacy`** (no → the upgrade would have gone to `drift` without host orchestration). | | **`reachable`** / **`unreachable_reason`** (fields `target`, `?to=` only) | A machine indicator of whether a goal is achievable by an upgrade. **`reachable: false`** + **`unreachable_reason`** (human readable, e.g. `migration chain to is broken: …`) - structurally broken state migration chain ([ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl)): preview endpoint gives the unattainable goal as DATA (`200`, not `422` - UI draws grey) and not an HTTP error. `downgrade`/`no-op` remain `reachable: true` (this is the direction signaled by `direction`, not "unreachable"). | **Three different "migrate / upgrade" axes - do not confuse** ([ADR-0068](adr/0068-service-upgrade-v2.md), "inconsistencies detected" #1): | Term | What does | Where does he live | |---|---|---| | **`upgrade//`** | Version upgrade of an incarnation (host-transition version-to-version), launched by the action upgrade | Service-repo directory next to `scenario/`; key `from:`; [ADR-0068](adr/0068-service-upgrade-v2.md) | | **`scenario/migrate_cluster/`** | Migration of **DATA** from an external cluster (create-scenario, native replication); `create: true` - visible in day-2 lists | Regular `scenario/` directory; [ADR-009](adr/0009-scenario-dsl.md) | | **`migrations/_/`** | Structural **state_schema-migration** of form `incarnation.state` (pure function, one PG-tx, forward-only) | Directory `migrations/`; [ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl), [migrations.md](migrations.md) | ### Engine compat window: `compat:` / effective window ([ADR-0076](adr/0076-engine-compat-window.md)) The declared range of engine versions an artifact is known to work with. Carried per entity — in `service.yml` **and** in every `destiny.yml`, since a destiny is a separate git artifact pinned at its own ref ([ADR-007](adr/0007-versioning-git-ref.md)). Name **`compat`** is a **DevOps term** (manifest section, rule "small = DevOps"): it introduces no dictionary entity, and the two neighbouring `requir*` names are already taken by different axes — [`required_modules`](#destiny-internal-terms) (destiny module dependencies) and [`required_capabilities`](#required_capabilities-enum) (host privileges of a plugin). | Name | Role | |---|---| | **`compat:`** (block, `service.yml` + `destiny.yml`) | The entity's declared engine-compatibility contract. Currently one axis — `keeper:`. A missing block = unbounded (backcompat; no migration of existing manifests). A block declaring nothing is an error. | | **`compat.keeper`** = **`{min, max}`** | The version window of keeper this entity was authored and tested against. **Half-open `[min, max)`** — `min` inclusive, `max` **exclusive** ("the first version I have NOT tested"). Values are plain **`MAJOR.MINOR.PATCH`**: no `v` prefix, no pre-release suffix, no `>=`/`<`/`~`/`^` operators (a range **string** was rejected — arbitrary constraints are not closed under intersection, see below). Both keys optional, at least one required. | | **compat window** | The window declared by **one** entity. | | **effective compat window** | The **intersection** of all windows in force for a run — `service.yml` plus every `destiny.yml` resolved at its pinned ref. Computed as max-of-mins / min-of-maxes ("the narrowest wins"); served through the operator API so the UI can show it before a run. | | **`introduced_in`** (feature / module-manifest metadata) | The keeper version in which a DSL feature or a module state first appeared. Engine-side metadata (lives in code and manifests, never per-service), used to infer the floor a plan actually needs and cross-check the declared window. Inference yields a floor only — a ceiling is undeclarable from code, which is why `compat` is the primary contract. | | **`keeper_version_unsupported`** (abort reason) | Fail-closed abort on the render path when the rendering keeper's version falls outside the effective window. Symmetric to [`soul_passage_unsupported`](#soul-capabilities). | | **`compat_window_empty`** (diag code) | The declared windows do not intersect — no keeper version can ever satisfy them. An authoring error, raised by `soul-lint` and at service registration. | | **`compat_window_incomplete`** (diag code) | A `compat:` block that declares no engine axis, or a `compat.keeper` that declares neither `min` nor `max`. An empty declaration is an error rather than a silent "unbounded" — omit the block instead. | | **`compat_version_invalid`** (diag code) | A bound that is not plain `MAJOR.MINOR.PATCH` — a `v` prefix, a pre-release suffix or a `>=`/`<` operator. One syntax only. | | **`introduced_in_invalid`** (diag code) | An `introduced_in` in a module manifest that is not plain `MAJOR.MINOR.PATCH` — same one-syntax rule as a compat bound, since the two are compared against each other. | | **`compat_floor_too_low`** (diag code) | The declared `min` is below the floor inferred from `introduced_in` — the declaration is too permissive and would break on a keeper inside the declared window. Lint/registration diagnostic, deliberately **not** a run-time block. | | **`deprecated:`** = **`{since, removed_in, use?}`** (manifest input param) | The param-level deprecation notice — the way a module contract shrinks without breaking yesterday's definitions ([ADR-0076](adr/0076-engine-compat-window.md) amendment (r)). `removed_in` is **EXCLUSIVE**, the same grammar and reading as `compat.keeper.max`. **Deprecation window** = at least **2 minor releases** (`DeprecationMinMinors`): deprecated in `X.Y.0` → removable no earlier than `X.(Y+2).0`, which is exactly the window `{min: X.Y.0, max: X.(Y+2).0}` an author may declare. This is the answer to "how many generations back must keeper honor a definition". | | **`deprecated_param`** (diag code, **warning**) | An author's task passes a param marked `deprecated:`. A warning, never an error — the param still works; an error would make deprecation indistinguishable from removal. | | **`deprecation_window_too_short`** / **`deprecated_bound_missing`** / **`deprecated_version_invalid`** / **`deprecated_replacement_unknown`** (diag codes) | Manifest-authoring errors on the `deprecated:` block: a window below the policy minimum; a missing `since`/`removed_in` (an open-ended deprecation cannot be planned against); a bound that is not plain `MAJOR.MINOR.PATCH`; a `use:` naming a param the same state does not declare. | | **`module.unknown_param`** (TaskError code) | Soul-side, before Apply: the task carries a param the module's manifest **in this binary** does not declare, so the task FAILS instead of running with the key silently unread. The param-level sibling of [`soul_capability_unsupported`](#soul-capabilities) (module-level) — enforced for core modules, advisory for custom ones ([ADR-0076](adr/0076-engine-compat-window.md) (o–q)). | | **`deprecated_param`** (diag code / [`TaskNotice`](#proto-keepersoul-messages) code) | One name on both sides of the same fact: an **author-facing warning** from the static check (never an error — the param still works) and, from [ADR-0076](adr/0076-engine-compat-window.md)(u), the **operator-facing notice** on the run that carried it. Deliberately the same string: an operator forwarding "`deprecated_param` on `address`" to the author must be describing the thing the author's linter names. At `removed_in` it is replaced by `module.unknown_param` — the deprecation is what buys the time to avoid that. | **Engine provenance stamp** ([ADR-0076(l)](adr/0076-engine-compat-window.md), migration 103) — the recorded fact of which engines executed a run, as opposed to the declared contract above. State-side only; **no gate reads it**, and none may (the gates are `keeper_version_unsupported` and `soul_capability_unsupported`). Names are **DevOps terms** (PG columns), introducing no dictionary entity. | Name | Role | |---|---| | **`apply_runs.keeper_version`** | Raw build version of the keeper instance that **rendered** that per-host row. The renderer, not the dispatcher: the inline path stamps at insert, the Acolyte path at claim (a rolling upgrade means the claiming instance may differ from the one that planned the run, [ADR-0076(f)](adr/0076-engine-compat-window.md)). | | **`apply_runs.soul_version`** | Raw `Hello.soul_version` of the agent the row was dispatched to, read at dispatch from the heartbeat Hash field `ver` — the same announcement the [capability gate](#soul-capabilities) judged. Audit-only ([ADR-0076(n)](adr/0076-engine-compat-window.md)); NULL for keeper-side rows and the run sentinel. | | **`engine_compat`** (jsonb, `incarnation` + `state_history`) | The engine contract the state was produced under: `keeper_version`, the [effective compat window](#engine-compat-window-compat--effective-window-adr-0076), `window_enforced`, and the union of required [soul capabilities](#soul-capabilities). Written on the **successful** state commit only; a failed run leaves the previous stamp (it changed no state, so it produced none). | | **`window_enforced`** (field of `engine_compat`) | Whether the window was actually compared against that build: true iff a window was declared **and** the build carried a comparable version. False covers both "nothing declared" (read with a null window) and the version-less build that renders unenforced by design ([ADR-0076(e)](adr/0076-engine-compat-window.md)) — so an unchecked run never later reads as a checked one. | **Four version-shaped things — do not confuse:** | Term | What it versions | Where it lives | |---|---|---| | **git `ref:`** | The **artifact** — service / destiny / module ([ADR-007](adr/0007-versioning-git-ref.md); there is deliberately no top-level `version:` field) | git tag or branch; `service.yml → destiny[]/modules[]`, `keeper.yml` | | **`state_schema_version`** | The **shape of `incarnation.state`** in Postgres, for migrations ([ADR-019](adr/0019-state-migration-dsl.md)) | Nowhere as a written-down number in the service repo: it is derived as the top of the ladder `migrations/_/`, and stamped into `schema.lock` (see ["State and service vars"](#state-and-service-vars)). At runtime it is the `incarnation.state_schema_version` column — that one is a real stored field and stays | | **`protocol_version`** | The **plugin-API contract** between a plugin and its host ([ADR-020](adr/0020-plugin-infrastructure.md)) | plugin `manifest.yaml` | | **`compat.keeper`** | The **engine** entitled to render this artifact ([ADR-0076](adr/0076-engine-compat-window.md)) | `service.yml` + every `destiny.yml` | ### Named input types: `types:` / `$type` / `x-type` ([ADR-062](adr/0062-input-types.md)) Reused named input schemes - replacing the unimplemented `$ref`/`schemas/`. Spec - [input.md → "Reused named types"](input.md#reusable-named-types-types--type). | Name | Role | |---|---| | **`types:`** (section `service//types.yml`) | service-level map `` → scheme in the same input-DSL ([input.md](input.md)). Declaration of reused named types; resolve strictly within one service (NOT local-per-scenario, NOT cross-service). The type name is `PascalCase` (`^[A-Z][A-Za-z0-9]*$`). Since the [ADR-0086](adr/0086-one-schema-dialect.md) that one DSL is also what `state_schema` is written in, so a type is referenceable from both sides; a member may carry `type: secret`, which means "the platform mints it, it is not asked for on input" on the input side (**NOT implemented — NIM-751**) and "a declared secret" in `state_schema` ([ADR-083](adr/0083-declared-secret-state-fields.md)). The object-level list `required: [names]` is gone from `types.yml` too (`input_required_list_removed`). | | **`$type: `** (input-DSL directive) | Type reference from `types:`. A standalone field (single object) or under `items:` (array). The service-level is resolved at the input stage with the obligatory cycle-detection. Mutually exclusive with an inline scheme on the same node (`input_type_ref_conflict`, closed set `{type, properties, items}`) — **except** in `state_schema`, where the reference may also carry its own `properties:` and have them overlaid onto the resolved type (add-only shallow, fail-closed → `input_type_ref_overlay_conflict`; **NOT implemented — NIM-742**). An example of an implemented type is **`MongoUser`** (service `mongo`, [`types.yml`](../examples/service/mongo/types.yml)): one MongoDB user record (`name`/`database`/`roles[{role, db}]`/`state`); `input.users` service - array `MongoUser` (`items: { $type: MongoUser }`). ★ Mongo roles are an **array** `{role, db}` (the exact mongo model: a set of named roles, each in a specific database), and not one perms string, like redis. | | **`x-type: `** (read-only DTO annotation, `GET /v1/services/{id}/scenarios`) | Forward-compat annotation on the node where `$type` was in the scenario directory DTO. backend expands `$type` into an inline diagram BEFORE projection and marks the node as `x-type` (for a specialized UI widget for growth). NOT written in the YAML source. | ### Covenant: `extends:` / `covenant.yml` ([ADR-009](adr/0009-scenario-dsl.md) amendment 2026-06-29) Common service-level contract for scenario sections, inherited through `extends:`. The third service-level shared mechanism is on a par with `types.yml` (named input schemes) and service-level `include:` (task sets). Spec - [scenario/orchestration.md](scenario/orchestration.md). | Name | Role | |---|---| | **Covenant** | General service-level contract of scenario sections (`input:` / `compute:` / `validate:`) in the fragment file of the service-repo root; the scenario inherits it via `extends:` (add-only). Carries ONLY these 3 sections (`state_changes:` was a fourth until [ADR-0084](adr/0084-explicit-state-capture.md) retired it — a shared state write is a step and travels through `include:`) (not `tasks:`/`name:`/`create:`/`form:`/`extends:`). covenant does NOT extend covenant (flat sheet, no recursion). | | **`covenant.yml`** (service-repo root file) | Fragment file with 4 contract sections, isomorphic to `types.yml`. Name `covenant` - **convention** of the general service contract (`extends: covenant`); The mechanism supports `.yml` (resolve `extends: `), but one `covenant.yml` per service is recommended. | | **`extends: `** (top-level scenario-key) | Optional scenario link to Covenant. Value - **name of the covenant file without extension** (`extends: ` → `.yml` in the root of the service repo, symmetrically `apply: { destiny: }`); NOT a logical name when the file is fixed. Convention - `extends: covenant`. One `extends:` per scenario (not list). Merge — **add-only shallow** on the top key of the section, fail-closed: duplicate key → `section_key_conflict` (NOT last-wins, deep merge rejected). `form:` DOES NOT merge (local-only). Append covenant-first (local can refer to covenant-`compute`). Resolve - in `LoadScenarioManifestResolved` BEFORE input-validation/render (isomorphic to `$type`-resolve); downstream sees the already-merged manifest. Omitted → manifest bit-for-bit as it is now. | ### Service-registry and cluster-settings (managed-registry in the database) > **Reserved names - entered with impl S3.** Listed here as reserved names so that impl S3 does not invent dialects; before S3 there are none in the code/migrations. | Name | Role | |---|---| | **`service_registry`** | PG-table-registry of registered services (git-source service-repo + metadata). Managed via OpenAPI / MCP - runtime-state, not git ([architecture.md → Artifacts](architecture.md#soul-stack-artifacts-whats-in-git-whats-in-the-database)). | | **`ServiceEntry`** | Entry `service_registry` - one registered service (name + git-ref + metadata). | | **`keeper_settings`** | PG table cluster-wide key-value of Keeper cluster settings (one value per key, visible to all instances). | **Well-known keys `keeper_settings`** (untyped key-value; new key - without table migration, [ADR-029(g)](adr/0029-service-registry.md)): | Key | Meaning | Role | |---|---|---| | **`default_destiny_source`** | git-source-string | MVP scalar: default Destiny directory source for resolve `apply: destiny` ([ADR-009](adr/0009-scenario-dsl.md)). | | **`provisioning_allowed_methods`** | CSV from domain `{user, ldap, oidc}` | Policy for allowed **CREATE** operator methods ([ADR-058(i)](adr/0058-operator-auth-ldap-oidc.md)). absent → all allowed (back-compat); given-but-empty → config-error (anti-lockout). `bootstrap`/`system` are NOT part of the domain (never gated). Gates only the operator creation branch; runtime - `GET`/`PUT /v1/provisioning-policy`. | | **`cfg_*`** (reserved namespace) | scalar, TEXT-encoded | **Overlay of reload-able `keeper.yml` parameters** owned by [SettingsStore](#modules-and-subsystems-inside-keeper) ([ADR-0073](adr/0073-keeper-runtime-config-pg.md)). Key = `cfg_` + the flattened YAML path (`cfg_toll_threshold` → `toll.threshold`, `cfg_tempo_voyage_create_rate` → `tempo.voyage_create.rate`); the mapping is an **explicit field-registry entry**, not a mechanical dot→underscore transliteration (`a.b_c` and `a_b.c` flatten alike). The prefix keeps the namespace disjoint from the well-known keys above. absent row → the `keeper.yml` value shows through (**no seed**). Admission is closed: reload-able ∧ **not a security gate** ∧ scalar ∧ outside the bootstrap/require-restart classes. | **Reserved RBAC-perms** (area `service.*`, directory - [rbac.md](keeper/rbac.md)): `service.register` / `service.update` / `service.list` / `service.deregister`. **Reserved audit-events** (area `service.*`, convention `.`, [Audit-events](#audit-events)): `service.registered` / `service.updated` / `service.deregistered`. ### Scenario: orchestration keys Delta scenario on top of the DSL task core - see [`docs/scenario/orchestration.md`](scenario/orchestration.md). | Key | Role | |---|---| | **`on:`** | Stable step target: where it is executed (resolved by Postgres). Omitted = all incarnation **members** (via `incarnation_membership`); `keeper` = keeper-side; `[coven, …]` = intersection of stable covens, always ⊆ members. `on: ["${ incarnation.id }"]` is a validation error (`incarnation.id` is not a Coven, [ADR-008](adr/0008-coven-stable-tags.md#amendment-2026-07-17-nim-124-incarnationname-is-not-a-coven--membership-is-a-first-class-relation)). Cross-incarnation is prohibited by construction. The scalar `keeper` is on its way out: [ADR-0087](adr/0087-task-side-derived-from-module-address.md) derives the side from the module address and returns `on:` to a single meaning (a coven list). **Implemented in NIM-749** — on a **core** address the scalar is now an error (`on_keeper_redundant` on a keeper-side module, `on_keeper_on_soul_module` on a Soul-side one), and a coven list on a keeper-side module is `on_covens_on_keeper_module`; see [Group derived task side](#error-codes). It stays legal on a **plugin** address, and now permanently: NIM-758 landed the keeper-side plugin executor, but the side is declared inside the artifact's schema document, which a scenario cannot read — so the key is the only thing that says where such a step goes. | | **`where:`** | Volatile per-host predicate string according to `register:` of the previous probe step. Resolve in runtime, not by Postgres. Link to register in `where:` - canon form `register..*` (as in the DSL core, [destiny/tasks.md §10](destiny/tasks.md)); bare form `.*` is not allowed (validation error). **`where:`-step key** (on which hosts to do) and **`soulprint.where(...)`-function** in the expression (where to get the host data) are different positions, do not confuse them. | | **`apply:`** | Delegation to isolated destiny: `apply: { destiny: , input: {…} }`. An alternative to `module:` in a scenario task. | | **`assert:`** (scenario task-key) | **Keeper-side render-time precondition** run ([ADR-009](adr/0009-scenario-dsl.md) amendment 2026-06-23): `assert: { that: […], message: }`. Task discriminator (mutually exclusive with `module:`/`apply:`/`include:`/`block:`). `that[]` - list of CEL-bool predicates (entire line = CEL, like `where:`; calculated in the Keeper render phase, `soulprint.hosts` available - `AllowHosts=true`); all must be `true`, the first `false` terminates the render with an understandable error (`message` + text of the failed predicate), not a single task is saved on Soul. **Do not emit RenderedTask** (check, not a task - indexes are not reserved for it). Run-level (once per run), gated `when:`. Not a wire entity (does not change the proto/Soul contract, like `block:`). **★ Two-point eval (form A, amendment 2026-06-23):** `assert:` is calculated at TWO points by one source (`render.evalAssertTask`, without dialect): (1) **pre-flight** on CREATION run (request-path of create-handler, BEFORE commit incarnation - main UX) → failure = **422 `assert-failed`** operator, incarnation is not created, fail status is not set; (2) **render fail-safe** at the start of the run (TOCTOU: the roster could have changed between pre-flight and the start of the goroutine) → failure = rare `error_locked` via async render-abort. **★ Narrowed 2026-07-28 ([NIM-235](adr/0009-scenario-dsl.md#amendment-2026-07-28-nim-235-a-roster-reading-assert-has-no-pre-flight-point-at-create)):** on the CREATE path point (1) evaluates only asserts that do NOT read the roster. Pre-flight stands before `incarnation.Create`, and membership FKs that row ([ADR-008 amendment / NIM-124](adr/0008-coven-stable-tags.md#amendment-2026-07-17-nim-124-incarnationname-is-not-a-coven--membership-is-a-first-class-relation)), so `soulprint.*` has nothing to resolve there; a topology guard is deferred to point (2), which is the first place the run's roster exists. **★ Extended 2026-07-28 ([NIM-270](adr/0009-scenario-dsl.md#amendment-2026-07-28-nim-270-the-pre-flight-gate-moves-to-where-the-roster-is-real)):** point (1) also runs on the EXPLICIT-RUN path (`POST .../scenarios/{scenario}` and the MCP twin `keeper.incarnation.run`), where the incarnation exists and its roster is bound — there a topology guard IS answered 422 before the run starts. Deferred on either path when the plan builds its own roster (all-keeper / refresh emitter — the same two `no_hosts` bypass classes). The table of where each assert is answered — [`docs/scenario/orchestration.md §2.3.1`](scenario/orchestration.md). **★ Dual context - do not be confused:** `assert:` as scenario task-key (this manifest-context) ≠ top-level `assert:` in Trial test-case fixtures ([`docs/destiny/testing.md`](destiny/testing.md), test expectations - `task_present`/`task_absent`/`state_after`/`state_absent`; `state_changes` was retired with the block it asserted, [ADR-0084](adr/0084-explicit-state-capture.md)). These are **different entities with different parsers**: scenario-`assert:` parses `shared/config` (task grammar), trial-`assert:` parses `keeper/internal/trial` (case form). The name coincidence is superficial. | | **`serial:`** | Wave (rolling) cross-host execution of the step: waves of ≤N hosts (integer `1..M` or `"%"`), order of hosts by `SID`, waves are sequential. Apply to module/apply/`block:` task. Details - [`docs/scenario/orchestration.md §2`](scenario/orchestration.md). | | **`run_once:`** | Step on one target host (first by `SID` from resolve `on:`+`where:`); `bool`, default `false`. Mutually exclusive with `serial:`. Details - [`docs/scenario/orchestration.md §2`](scenario/orchestration.md). | > **`serial:`/`run_once:` - "target hosts" axis.** Orthogonal to `async:`/`loop:` ([destiny/tasks.md §6–§7](destiny/tasks.md)), which work along the data/thread axis on the same host. > **Boundary `when:` vs `where:`.** `when:` - **whether to take a step at all** (boolean, as in [destiny/tasks.md §9](destiny/tasks.md)). `where:` — **on which hosts** to perform the step (per-host target filter by `register:`). These are orthogonal keys: `when:` decides "step goes / skipped", `where:` (on hosts where the step goes) narrows down the set of hosts. ### `core.state.`: the capture verbs and their params A scenario writes `incarnation.state` with a **step**, not a section: `module: core.state.` + `on: keeper`, standing where the value becomes known and landing at that step ([ADR-0084](adr/0084-explicit-state-capture.md); the verb set is [ADR-057](adr/0057-state-changes-crud-verbs.md)'s, from the retired `state_changes:` block). Normative spec — [`docs/scenario/orchestration.md §7.1`](scenario/orchestration.md#71-the-capture-verbs). The **state suffix of the address IS the verb** — there is no verb param. Multiplicity is expressed by a **`match:` predicate**, not by flags or paired verbs. | Address | Params beyond `field:` | Role | |---|---|---| | **`core.state.set`** | `value:` | Overwrite `incarnation.state.` whole. | | **`core.state.present`** | `value:` | Write only if the field holds no value; an existing one wins. | | **`core.state.add`** | `value:` + opt. `key:` / `match:` / `on_conflict:` | Add one element **by identity**. Map collection: `key:`. List collection: `match:` as the dedup predicate. | | **`core.state.append`** | `value:` | Append one element to a list with **no** identity check. | | **`core.state.modify`** | `patch:` (required) + opt. `match:` / `expect:` | Patch **every** element matching the predicate (all-by-default). | | **`core.state.remove`** | opt. `match:` / `expect:` | Remove **every** element matching the predicate (all-by-default). | | **`core.state.unset`** | — | Remove the field itself. | | Key | Role | |---|---| | **`field:`** | The `incarnation.state` field the step writes. Required on **every** verb; a plain string, not a path expression. | | **`value:`** | The value for `set`/`present` (the whole field) or `add`/`append` (one element): a CEL expression `${ … }` or a YAML literal (map/list/scalar). | | **`key:`** | The entry key for `add` into a map collection: `key: "${CEL}"`. | | **`match:`** | CEL predicate over a collection element — which ones `modify`/`remove` touch, and the dedup predicate for `add` into a list. All-by-default; an empty match → no-op. | | **`patch:`** | Map `: "${CEL}"` for `modify` — what to rewrite in each matched element. | | **`on_conflict:`** | Behaviour of `add` on a collision (the map key taken / the list `match:` already finds one): `skip` (default, idempotent) `\| replace \| error`. | | **`expect:`** | Optional runtime multiplicity assertion for `modify`/`remove`: `one \| at_most_one \| any` (default `any`). Matched ≠ expected → the run fails. | > **A param the verb does not take is refused, not ignored.** `core.state.set` given a `match:` > would otherwise look like a filtered write and be a wholesale overwrite. | Binding CEL | Role | |---|---| | **`elem`** | The current list-collection element (or scalar) in `match:`/`patch:`/`value:`. The name is `elem`, not `self`, to avoid colliding with the per-host `soulprint.self`. | | **`key`** / **`value`** | The key/value of the current map-collection entry in `match:`/`patch:`/`value:`. | > On top of the bindings a capture sees the ordinary keeper-task CEL env — `input.*` / > `incarnation.*` / `vars.*` / `compute.*` / `register.*` ([ADR-010](adr/0010-templating.md)). > `vars.*` is one flat namespace holding the service's own `vars/` under the scenario and task > locals ([ADR-0082](adr/0082-service-vars.md)). Being an `on: keeper` task, its `register.*` is > the **keeper** bucket: a host probe's register is not reachable from a capture. Neither is any > other per-host value — a keeper task binds no soulprint root at all, `soulprint.hosts` included. > The one exception is the explicit accessor **`register.hosts.`** (below): the map > {SID → payload} for one register across the hosts that produced it, readable ONLY from > `on: keeper`, which is how a per-host value reaches `incarnation.state` at all > ([ADR-0084](adr/0084-explicit-state-capture.md) amendment 2026-08-26, > [scenario/orchestration.md §7](scenario/orchestration.md)). > **`remove` (capture) ≠ `delete` (migration-DSL) — deliberately different names.** `remove` > ([ADR-057](adr/0057-state-changes-crud-verbs.md)) = "remove an element from the collection", by a > `match` predicate over elements, at runtime. `delete` > ([ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl), > [migrations.md](migrations.md)) = "demolish a path in the state structure", by `path:`, when the > schema shape changes. `core.state.unset` is the runtime middle case: it drops **one declared > field's value**, not a schema path. > **Not introduced** ([ADR-057](adr/0057-state-changes-crud-verbs.md) rejected alternatives, still > rejected): `clear` (= `set` an empty value), `rename`/`move` (that is migration-DSL), `upsert` > (= `add` + `on_conflict: replace`), a positional `remove` (first/last — not reproducible in > JSONB), paired `*_one`/`*_all` verbs, an `all:` flag (multiplicity is a property of `match:`, not > a name or a flag). And from [ADR-0084](adr/0084-explicit-state-capture.md): a single > `core.state.write` taking the verb as a param (a verb is not data — the address is what a linter, > a diff and a reader key on), and a compare-and-swap `expect:` on `set` (deferred: ADR-057's > `expect` counts match cardinality, and a whole-field `set` has no `match:`). > **Retired.** `state_changes:` — the scenario-level block these verbs used to live in > ([ADR-057](adr/0057-state-changes-crud-verbs.md), removed by > [ADR-0084](adr/0084-explicit-state-capture.md)). A scenario carrying one is rejected > (`unknown_key`), a covenant carrying one likewise. `foreach:` went with it and does not return as > a task key — a step has `loop:`. The map form before that (`sets:`/`appends:`/`modifies:`) is > long gone. ### Task field `id:` - address for alerts | Key | Role | |---|---| | **`id:`** | Optional stable task address (snake-case `^[a-z][a-z0-9_]*$` format) for subscribing to per-task-changed alerts "Task X has changed" ([ADR-052 §j](adr/0052-herald-notifications.md), [ADR-009 amendment](adr/0009-scenario-dsl.md)). Task DSL core field (destiny + scenario), not scenario-only. Mutually exclusive with `register:` (with register the address already exists → `id:` for tasks **without** register); one format - register and id in the **same address space** of the subscription. Pilot: valid only on a module task. Grammar - [destiny/tasks.md §3](destiny/tasks.md). | > **Do not confuse `id:` with other identifiers.** `id:` - task address **for alerts** (snake-case, like `register:`). This is **not** `register:` (it captures the output of the task **and** serves as an address for requisites; `id:` is ONLY an address, output does not carry) and **not** entity identifiers `sid` (host FQDN) / `aid` (Archon ID, `archon-…`) / `apply_id` (apply task ID) / the **registry-entity [`id`](#identifiers)** (kebab-case `^[a-z0-9][a-z0-9-]{0,62}$`, the immutable PK of a registry row — `incarnation.id`, `service.id`, …; [ADR-0085](adr/0085-entity-id-and-label.md)). Two different alphabets and two different address spaces: a task `id:` is snake-case and lives inside one scenario, an entity `id` is kebab-case and is global to the cluster. The per-task-changed subscription address space is `register ∪ id` and contains only the first kind. ### Scenario-only template-context variables Variables available **only in the scenario context** (not visible in destiny - see [destiny/tasks.md §10](destiny/tasks.md)). | Name | Type | Role | |---|---|---| | **`incarnation.host_count`** | int | The number of hosts in the run target **after** resolving `on:` and **before** applying `where:`. Used in the probe idiom of completeness - `failed_when: size(register.) < incarnation.host_count` ([orchestration.md §4.2](scenario/orchestration.md), [§5](scenario/orchestration.md)). | | **`incarnation.state.`** | dyn | **Read-only snapshot of `incarnation.state`** at the time of capturing the row-lock run (`stateBefore` under `FOR UPDATE`) in scenario-render CEL ([ADR-009](adr/0009-scenario-dsl.md)/[ADR-010](adr/0010-templating.md) amendment 2026-06-20, Option A). Canonical form - **with prefix `incarnation.`**: bare `state.` in scenario/destiny - error `soul-lint` (`state_naked_reference`, symmetry with bare `soulprint.` without `.self`). CEL **read only** (state is mutated only by a `core.state.` capture step, [ADR-0084](adr/0084-explicit-state-capture.md)). **The snapshot is fixed within a Passage and re-read at each Passage boundary** ([ADR-0084](adr/0084-explicit-state-capture.md): a capture commits at its step, so Passage `p` reads what the Passages before it captured; before that it was invariant for the whole run). Available in `params:`/`where:`/`apply: input:`/`vars:` and in a capture's `params:` context; in push/L0-trial without state - no-such-key. `state` remains bare+mutable **only in migration-CEL** ([ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl), there `incarnation` undeclared - isolation). | | **`soulprint.hosts`** | list | All run hosts with stable facts (`sid`/`role`(declared)/`network`/`os`/`covens`). `.where()` - filter by CEL-predicate-string ([orchestration.md §4.1](scenario/orchestration.md), [soul/soulprint.md](soul/soulprint.md)). | | **`soulprint.where()`** | list | Synonym for `soulprint.hosts.where()` (run hosts, stable layer). Predicate - CEL-string; keyword-args (`coven=...`) are not supported. | | **`register.hosts.`** | map | One register read across **every** host that produced it: `{SID → payload}`, the payload being the whole register value as `register.` has it on that host. Readable **only from an `on: keeper` task** — anywhere else (host task, destiny pass, `when:`/`changed_when:`/`until:`, migration) it is a compile error, not an empty map, because a host task's `register.` is deliberately its OWN value ([ADR-0083](adr/0083-declared-secret-state-fields.md) §5). Declares a dependency on ``, so the reader lands in a later Passage than the producer ([ADR-056](adr/0056-staged-render-passage.md)). Its reason for existing is the capture: a keeper task is the only thing that writes `incarnation.state`, and this is the only per-host root it has ([ADR-0084](adr/0084-explicit-state-capture.md) amendment 2026-08-26, [orchestration.md §7](scenario/orchestration.md)). `register: hosts` on a task is refused (`register_name_reserved`): such a register is unreadable from either side — here the accessor wins the field, and on a host task `register.hosts` is refused at compile whether or not the register exists. | ### Voyage: invocation and config keys Batch run parameters [Voyage](#domain-entities) ([ADR-043](adr/0043-voyage.md) + [amendment 2026-06-01](adr/0043-voyage.md) + [amendment 2026-06-09](adr/0043-voyage.md)). Level **request** - opt-fields invocation-payload `/v1/voyages`; level **config** - operator-invariants in `keeper.yml::voyage`. All names are Soul Stack dictionary terms (`max_failures`, `require_alive`). | Key | Level | Role | |---|---|---| | **`batch`** | request | Packet size by string: `N` (absolute number of hosts) \| `N%` (percentage 1–100 of the resolved scope, `N%` → `ceil(scope·pct/100)`). Keeper parses (grammar `^(\d+)%?$` after trim, fail-closed → `422 validation-failed`). Single field + convenient CLI (`--batch 20%`). Only `batch_mode: barrier`. On resolve, it splits into `batch_size`/`batch_percent` (additive). | | **`max_failures`** | request | Failure threshold by string: `N` (absolute) \| `N%` (percentage, `N%` → `ceil(scope·pct/100)`). Counted by **run units** (incarnations for `kind=scenario` / hosts for `kind=command`); `cancelled`/`no_match` are NOT considered failures. `on_failure: abort` ≡ `max_failures: 1`; `continue` ≡ threshold not specified. Works in both `batch_mode`. Replaces `fail_threshold`. | | **`batch_mode`** | request | Batching mode: `barrier` (default, implemented - Leg = batch `batch` + barrier between Legs) \| `window` (sliding window - the pool holds `concurrency` active units without barriers; window by hosts for `kind=command`, by incarnations for `kind=scenario` with preservation of intra-incarnation barrier+commit, [ADR-009 §7](adr/0009-scenario-dsl.md)). | | ~~**`batch_size`**~~ | request | **Deprecated** (amendment 2026-06-09), accepted. Packet size as absolute (int). Replaced by `batch`. Mixing with `batch`/`batch_percent` → `422` `voyage_batch_spec_conflict`. | | ~~**`batch_percent`**~~ | request | **Deprecated** (amendment 2026-06-09), accepted. Batch size as % of scope (int 1..100), XOR with `batch_size`. Replaced by `batch`. Mixing with `batch` → `422` `voyage_batch_spec_conflict`. Only `batch_mode: barrier`. | | ~~**`fail_threshold`**~~ | request | **Deprecated** (amendment 2026-06-09), accepted. Absolute failure threshold (int). Replaced by `max_failures` (which can also do `N%`). Mixing with `max_failures` → `422` `voyage_batch_spec_conflict`. Works in both `batch_mode`. | | **`inter_unit_interval`** | request | Per-unit pause in `batch_mode: window` (parity to `inter_batch_interval`, which is between Legs in `barrier`). Type [`duration`](#value-formats). Only window. | | **`require_alive`** | request | bool (default `false`): presence filter of living ones on the scope resolve - `SoulLeaseChecker` cuts off Souls without presence-lease ([ADR-006 Redis](adr/0006-cache-redis.md)). Snapshot-scope is not weakened (filter on resolve). | | **`max_batch_size`** | config | Invariant operator in `keeper.yml::voyage` next to `max_scope`: batch/window size ceiling (`batch_size` for `barrier`, `concurrency` for `window`). Fail-closed → `422` `voyage_batch_size_too_large` (parity `voyage_scope_too_large`). Default + `0` = no limit; negative → `value_out_of_range`. | | **`max_await_timeout`** | config | Operator-ceiling `keeper.yml`: the upper limit `await_timeout` of the onboarding barrier `core.soul.registered` ([ADR-061](adr/0061-onboarding-await-and-midrun-reresolve.md)). Type [`duration`](#value-formats), default `30m`. Fail-closed DoS-guard: step from `await_timeout` > ceiling ends with `failed` (obvious error, **not** silent truncation) - the blocking barrier should not hang forever. | > **Semantics of `voyage_targets.batch_index` in two modes.** In `batch_mode: barrier` - index of the Leg unit (two-level drill grouping). In `batch_mode: window` - **`0` for all units** (there are no packs, the run is flat; UI drill shows a flat list). > **Endpoint `POST /v1/voyages/preview`** ([ADR-043 amendment 2026-06-09](adr/0043-voyage.md)). Dry-resolve scope **without creation** Voyage - accepts the same target fragment + batch settings as `POST /v1/voyages`, returns `{scope_size, total_batches, effective_batch_size}`. **Does not reveal the SID list** (only numbers). Reuses scope resolvers in read-only, ceiling `voyage.max_scope`, RBAC-by-`kind` (`incarnation.run` for scenario / `errand.run` for command - guard as in create) and the same rate-limit. Purpose — preview of the number of batches with **late-binding** target (`coven`/`require_alive`); for a snapshot target, the number is calculated by the client himself. > **Error-code `voyage_batch_spec_conflict`** ([ADR-043 amendment 2026-06-09](adr/0043-voyage.md)). `422` when mixing the new string batch format with the old int format in one request: `batch` together with `batch_size`/`batch_percent`, or `max_failures` together with `fail_threshold`. ### State and service vars | Name | Role | |---|---| | **State_schema** | Schema of what is stored in `incarnation.state` (jsonb in the database). Declared in `service.yml`. Versioned via `state_schema_version`, and that version is **derived, not written**: it is the top of the `migrations/` ladder in the same repo, stamped into `schema.lock` — the manifest carries no version key ([ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl), NIM-735). Since [ADR-083](adr/0083-declared-secret-state-fields.md) it also declares **which fields are secret** - see `type: secret` below. Written in the **input DSL** ([input.md](input.md)) as a map `` → schema, exactly like `input:`: no root `type: object`, no `properties:` wrapper and no list `required: [names]` at any level; a **nested** object field is still an ordinary `type: object` node with its own `properties:` ([ADR-0086](adr/0086-one-schema-dialect.md), **NOT implemented — NIM-742**). | | **`type: secret`** (state_schema field) | The declaration that a state field holds a secret ([ADR-083](adr/0083-declared-secret-state-fields.md)). On a scalar field, or on a property inside `items` for a collection. The author writes **no Vault path**: it is derived as `secret////#` (collection) or `secret///#value` (scalar). Every segment is validated against the [ADR-064](adr/0064-secret-write-path.md) `secretwrite` grammar `^[a-zA-Z0-9_-]+$` and fails closed. Absorbs the retired [`revealable_secrets[]`](#service--incarnation--scenario). | | **`key:`** (state_schema collection) | Names which sibling property of a collection's `items` is the **identity** of an element, and therefore the `` segment of the derived path ([ADR-083](adr/0083-declared-secret-state-fields.md)). Sits next to `type: secret` inside `items`. Successor to `revealable_secrets`' `enumerate:` + `{key}` placeholder pair, in one place instead of two. | | **`generate_secret()`** / **`SecretRequest`** (CEL) | A CEL function taking a **map** argument (`generate_secret({"length": 32, "charset": "alphanumeric"})`) and returning an opaque **`SecretRequest`** marker, not a value ([ADR-083](adr/0083-declared-secret-state-fields.md)): no plaintext exists at render time, so none can leak through a register, a log line or a diff. Only a [`core.state.`](#specific-core-modules) capture step resolves the marker. The argument is a map because CEL has **no keyword arguments and no `=` token** - `generate_secret(length=32)` does not lex. Registered as a plain function, **not** a macro - `vault()` needs one only to inject its hidden resolver argument, and a request carries nothing hidden. The type is opaque: no readable fields, no string form, so `generate_secret({}).length` and `"pw-${ generate_secret({}) }"` are errors rather than something that renders. Available in the ordinary scenario/destiny render pass **only** (in migration-CEL, flow-control and service-vars the call is refused - a request nobody resolves is inert data that looks like it did something). The seal detector deliberately does **not** key on it: a request holds no secret material, and sealing is whole-cell, so it would mask a failed task's entire inventory out of its own diagnostics. The map is the **only** place a generation policy is written - `state_schema` deliberately carries no `generate:` default, so there are never two policies to reconcile. Grammar is the one [`core.vault.kv-present`](#specific-core-modules) already parses: `length` (8..1024 characters, default 32) + `charset` (`alphanumeric`/`hex`/`base64url`/`ascii-printable-safe`) OR `allowed_chars`, lifted to shared code so both callers read one definition; a key outside that grammar is **rejected**, not ignored. | | **`type: secret`** / **`key:`** / **`label:`** (`state_schema`) | The declaration of a secret in a service manifest ([ADR-083](adr/0083-declared-secret-state-fields.md) §1). `type: secret` marks a property whose value lives in Vault and **never** in `incarnation.state`; the platform derives the path from `(service, incarnation, state field, key)`, so the author writes no path anywhere. `key:` names the sibling property that addresses one element of a collection - required there, refused on a scalar field, and it must name a sibling of type `string` because its value becomes a path segment. `label:` is the optional UI caption, the surviving quarter of the deleted `revealable_secrets`. Exactly **two** positions are legal - a top-level property (a key of the root field map, [ADR-0086](adr/0086-one-schema-dialect.md)), or a property of a top-level array's `items` - because the derived path has one field segment and one optional key segment; a `type: secret` anywhere else is a load-time error, never a silent skip. The node's grammar stays closed to `type`/`key`/`label`, and the input DSL's wider vocabulary does not open it: `default`, `enum`, `pattern`, `min_length`/`max_length`, `secret`, `prefill_from_state` and `required_when` are all refused beside it, each because a value that never enters state never passes state validation, so any of them would read as enforced and could not be; **`description`** is refused for its own reason, that `label:` already carries the caption. **`required:` on such a property is refused** (`secret_field_required`) - no state instance can satisfy a presence check over a value that is not in state. The check runs **ahead** of the closed-grammar one deliberately ([ADR-0086](adr/0086-one-schema-dialect.md) §7): `required` is a key outside `type`/`key`/`label`, so the grammar check would otherwise claim it first and report `secret_field_unknown_key` - a vocabulary complaint where the truth is a satisfiability one, pointing the author at the wrong fix. `required: false` is refused under the same code, because the key has no meaning here either way. The reveal id is `.`, or the bare `` for a scalar. | | **`secret: true`** (`state_schema`, pre-existing) | The **other** secret marker, and not a synonym for `type: secret` ([ADR-010](adr/0010-templating.md) §7.4): it says the value **does** live in `state` and must be masked on the way out - read path, destroy capture, audit payload. `type: secret` says the value is not in state at all. Both stay; neither replaces the other. A `type: secret` path is added to the masking walk as well, so a value arriving there by some other route - an old snapshot, a migration, a bug - is masked rather than printed. | | **`__secret_request`** (reserved map key) | The travelling form of a `SecretRequest` once it leaves CEL: `{"__secret_request": {"length": 32, "allowed_chars": "..."}}` ([ADR-083](adr/0083-declared-secret-state-fields.md) §3). The two boundaries it crosses - CEL→`params`, `params`→protobuf - carry no Go types, so the request travels as ordinary data under one reserved key; the `__` prefix is the CEL layer's internal namespace. It carries the **resolved** policy (alphabet spelled out), not the author's spelling, so no charset name has to mean the same thing in two places. A [`core.state.`](#specific-core-modules) step recognises it on a property declared `type: secret` and nowhere else; anywhere else it is inert data. Hand-writing the map is a forgery with **no** privilege - identical effect, same bounds. | | **Migration** (state_schema migration) | Conversion of `incarnation.state` from version N to N+1 during service upgrade. A step is a directory `migrations/_/` holding `main.yml` and its `tests/` in the service repo. The number is the version the step leads to; the "from" is derived — the ladder is forward-only and goes by one. Grammar - flat (`rename`/`set`/`delete`/`move`) + CEL expressions in `set.value` + structural `foreach` ([ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl), full spec - [migrations.md](migrations.md)). Forward-only in MVP. Atomic: one PG transaction per chain. **Not to be confused with runtime-`state_changes`** ([ADR-057](adr/0057-state-changes-crud-verbs.md)): migration-`delete` addresses `path:` (demolish schema field/path), state_changes-`remove` addresses `match:` (remove collection item in scenario). The third axis "migrate/upgrade" is version `upgrade/` ([Upgrade v2](#upgrade-v2-directory-upgrade-key-from-upgrade-paths)). | | **`schema.lock`** (generated, service repo) | The stamped state of a service's `state_schema` beside its ladder: `version` (the top of the ladder at stamp time) and `fingerprint` (a hash of the **parsed and canonicalized** `state_schema`, never of the file text — a comment would break a text hash). Written by **`soul-lint schema-stamp `** and read by `soul-lint` on every `validate-service`; a service repository wraps the two as `make schema-stamp` and `make validate`, and neither is a target of this core repo: a lock disagreeing with the current schema means the schema was edited without a matching ladder step, or a step was added without re-stamping, and that is an error rather than a warning. It exists because a service describes the shape of `incarnation.state` **twice** — declaratively in `state_schema`, imperatively in the ladder — both by hand, with nothing reconciling them; that gap predates the derived version and is independent of it ([ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl), NIM-735; the tooling is NIM-737). Name - **DevOps term** ([criterion "large → Soul Stack, small → familiar"](#criterion-large--soul-stack-small--familiar)): a generated build artifact of a service repo, in the tradition of `go.sum` / `Cargo.lock`, not a Soul Stack dictionary entity. | | **Service vars** | A service's own default parameter values, stored in the `service-repo/vars/` directory and read in CEL as `vars.*` ([ADR-0082](adr/0082-service-vars.md)). They are the **bottom** of one flat `vars.*` namespace, under the scenario's `vars:`, a `block:`'s and a task's. **Not overridable from outside**: there is no override field on the incarnation and no successor to one — a fleet that needs different defaults forks the service repo and re-pins its `ServiceRef` ([ADR-007](adr/0007-versioning-git-ref.md)). What an operator supplies is `input:`, and only `input:`. | | **Service vars pipeline** | Declarative assembly of `vars/` ([ADR-0082](adr/0082-service-vars.md)). Default order — every `*.yaml`/`*.yml` **directly inside** `vars/`, sorted lexically, starting from `00-base.yaml` (subdirectories are not walked; `_stack.yaml` itself is excluded). Explicit order — `vars/_stack.yaml` with the `file`/`inline`/`when`/`optional`/`foreach`+`as` operators and a per-step `strategy: deep\|replace`; each step sees the `vars.*` accumulated so far plus `incarnation.*` (covens and traits included), and **nothing else** — `soulprint`, `input`, `register` and `compute` are undeclared in that env, so naming one is a compile error rather than an empty map. Service vars are **role-agnostic**: there is no `role/.yaml` stage ([ADR-008](adr/0008-coven-stable-tags.md)), and the former hard-wired `os/`/`coven/` layers are gone — conditionality is written in `_stack.yaml` or not expressed at all. | | **`error_locked`** | Incarnation status when the scenario partially fell. Any further scenarios are rejected until explicit `keeper.incarnation.unlock`. | | **`incarnation_archive`** | PG-table - archive of demolished incarnations (S-D3, cascade V3). Compliance minimum snapshot (`name` / `service` / `service_version` / `spec` / `state` / `status` + timestamps) + `archived_at`. **WITHOUT FK on live `incarnation`** - experiences physical `DELETE` + cascade. Filled in the same transaction as DELETE, BEFORE it. | | **`state_history_archive`** | PG-table - archive of the log `state_history` demolished incarnation (S-D3). Snapshot of change fields (`history_id` / `incarnation_name` / `scenario` / `state_before` / `state_after` / `apply_id` / `at`) + `archived_at`. **WITHOUT FK on live `incarnation`** - survives cascading demolition of live `state_history`. | ## Plugin infrastructure and Cloud See sections ["Plugin infrastructure"](architecture.md#plugin-infrastructure), [ADR-020](adr/0020-plugin-infrastructure.md) (regulatory manifest/handshake/lifecycle format), [`docs/keeper/plugins.md`](keeper/plugins.md) (full spec) and ["Cloud integration via `keeper.cloud`"](architecture.md#cloud-integration-via-keepercloud). ### Plugin service contracts | Name | Role | |---|---| | **`SoulModule`** | gRPC contract for plugins for Destiny steps. Host = `soul`. Implemented by an artifact in an alias-named slot (the filename is not a contract since NIM-377; repositories conventionally still build `soul-mod-`). `kind: soul_module` in manifest. Methods: `Validate`, `Plan`, `Apply` ([ADR-020](adr/0020-plugin-infrastructure.md)). `Plan` - **pure-read** (read-only discrepancy test, returns machine `PlanEvent.changed`; was no-op) per [ADR-031(b)](adr/0031-scry-drift.md#adr-031-scry--drift-detection-declarative-dry-run-reconcile); `PlanEvent.changed` - **only-add** to `proto/plugin/v1`. | | **`CloudDriver`** ⛔ **LEFT THE DICTIONARY (NIM-757 / NIM-761, removed 2026-09-04)** | gRPC contract of plugins for cloud providers. Host = `keeper` (module `keeper.cloud`). Implemented by `soul-cloud-` binaries. `kind: cloud_driver` in manifest. Methods: `Schema`, `Validate`, `Create`, `Destroy`, `Resize`, `Status`, `List`. Credentials-flow - **Option A** (Keeper resolves `credentials_ref` from Vault → plaintext to `CreateRequest.credentials`/`DestroyRequest.credentials`; the driver does not run in Vault, `vault_access` has been removed from the cloud-manifest); shared-framework (error-taxonomy / Retry / WaitUntilReady / ConfirmDestroy) - in `sdk/clouddriver`. See [ADR-017 amendment](adr/0017-keeper-side-core.md). **The separate contract is removed** — a cloud driver becomes an ordinary SoulModule plugin declaring `side: keeper`; `clouddriver.proto`, its generated Go and `sdk/clouddriver/` are deleted ([ADR-017 amendment 2026-09-01](adr/0017-keeper-side-core.md#amendment-2026-09-01-nim-757-the-clouddriver-contract-is-removed--a-cloud-driver-is-an-ordinary-plugin)). **Not implemented** — the name and everything in this row still describe what ships. | | **`SshProvider`** | gRPC contract for plugins for SSH authentication in `keeper.push`. Host = `keeper`. Implemented by `soul-ssh-` binaries. `kind: ssh_provider` in manifest. Methods: `Sign`, `Authorize`. Credentials-flow for CA providers - **Option B** (the plugin itself goes to Vault via `vault_access`, deliberately diverged from cloud-Variant A - `ssh/sign` is a Vault operation, not KV-read; [ADR-020 amendment (j)](adr/0020-plugin-infrastructure.md)). Key-ownership for CA providers (vault_ssh_ca / teleport) - **Keeper-ephemeral** (Keeper generates ephemeral keypair per-session, sends only pubkey to `SignRequest`, plugin returns only `certificate`; private does not leave Keeper; [ADR-020 amendment (k)](adr/0020-plugin-infrastructure.md)). Params-delivery — env-convention per-plugin (`SOUL_SSH_STATIC_PARAMS` / `SOUL_SSH_VAULT_PARAMS` / `SOUL_SSH_TELEPORT_PARAMS`; generic mechanism deferred post-MVP, [ADR-020 amendment (l)](adr/0020-plugin-infrastructure.md)). | | **`keeper.cloud`** ⛔ **LEFT THE DICTIONARY (NIM-757 / NIM-761, removed 2026-09-04)** | A module inside `keeper` that is responsible for cloud operations. Uses `CloudDriver` plugins. With the CloudDriver contract removed there is no keeper-side cloud module left: the driver is a plugin the keeper executes like any other. **Not implemented** — the module ships today. | | **Provider** ⛔ **LEFT THE DICTIONARY (NIM-757 / NIM-761, removed 2026-09-04)** | Configured cloud account (AWS account, GCP project). Stored in Postgres, managed via API/MCP. The registry is removed with the contract (migrations `019`/`094`, the `provider.*` permissions and audit events, `/v1/providers*`, `keeper.provider.*`); a driver's credentials become ordinary step params. **Not implemented** — the registry ships today. | | **Profile** ⛔ **LEFT THE DICTIONARY (NIM-757 / NIM-761, removed 2026-09-04)** | VM template (image, instance type, network). Stored in Postgres and reused. The parameters are validated against `profile_schema` from CloudDriver. The registry is removed with the contract (migration `020` and the FK `profiles.provider → providers(name) ON DELETE RESTRICT`, the `profile.*` permissions and audit events, `/v1/profiles*`, `keeper.profile.*`); the VM spec becomes an ordinary module param. **Not implemented** — the registry ships today. | **Official CloudDriver binaries (first set, [ADR-017 amendment](adr/0017-keeper-side-core.md)):** `soul-cloud-aws` / `soul-cloud-gcp` / `soul-cloud-azure` / `soul-cloud-yc` (Yandex Cloud) / `soul-cloud-proxmox` / `soul-cloud-openstack`. **vSphere** - community / deferred. AWS - pilot (reference). **By 2026-05-26 all 6 are committed and working** ([ADR-017 amendment 2026-05-26](adr/0017-keeper-side-core.md), Track 3 [roadmap.md](roadmap.md)). > ⛔ **The `soul-cloud-*` binary family left the dictionary with the contract (NIM-757 / NIM-761, removed 2026-09-04).** All six `examples/module/soul-cloud-*` are deleted; the surviving cloud driver is `soul-cloud-wb`, rebuilt as an ordinary SoulModule plugin declaring `side: keeper` (NIM-760). The six above remain committed and working until then. **Official SshProvider binaries (MVP set, [ADR-020 amendment 2026-05-26 (i)](adr/0020-plugin-infrastructure.md)):** `soul-ssh-static` (commit `4f95ef6`, reference) / `soul-ssh-vault` (commit `3642520`, Vault SSH CA, ephemeral keypair) / `soul-ssh-teleport` (commit `af27678`, Teleport-CA + `SignReply.proxy_jump` only-add field 4). MVP recruitment is closed. **`provider_kind` enum** — the root `provider_kind` of the [schema document](keeper/plugins.md#schema-document) (closed enum per-`kind`; different spaces, no collisions between cloud and ssh). Was `spec.provider_kind` before NIM-377 removed the `spec:` wrapper. ⚠ **The `cloud_driver` row of this table leaves the dictionary (NIM-757 / NIM-761, not implemented)** — with the kind gone there is no cloud `provider_kind` space; the `ssh_provider` space is untouched. | `kind` | `provider_kind` enum | Where | |---|---|---| | `cloud_driver` ⛔ **LEFT (NIM-757 / NIM-761, removed 2026-09-04)** | `aws` / `gcp` / `yc` / `azure` / `openstack` / `proxmox` | Schema document. Repositories conventionally name their output `soul-cloud-`; **nothing reads the filename** (NIM-377). The `kind` value itself is removed from the closed enum in `sdk/schema/` and reserved in `proto/plugin/v1/common.proto` ([ADR-020 amendment 2026-09-01](adr/0020-plugin-infrastructure.md#amendment-2026-09-01-nim-757-cloud_driver-is-removed-and-side-keeper-is-what-replaces-it)). **Not implemented** — the kind ships today. | | `ssh_provider` | `static_key` / `vault_ssh_ca` / `teleport` | Schema document. Repositories conventionally name their output `soul-ssh-` (`soul-ssh-static` ↔ `static_key`, `soul-ssh-vault` ↔ `vault_ssh_ca`, `soul-ssh-teleport` ↔ `teleport`); **nothing reads the filename**. The discriminator is `provider_kind`. | Enum extension - propose-and-wait + PR in [keeper/plugins.md → Schema document](keeper/plugins.md#schema-document) and this file. ### Plugin manifest and handshake | Name | Role | |---|---| | **Schema document** | The plugin's self-description: canonical JSON, **generated** from a `module.Def` in Go, stamped into the artifact as a trailer and also written to `dist/schema.json`. One shape for all kinds with a `kind:` discriminator. Read by Keeper at `plugin.allow` and by `soul-lint` **without executing the artifact** ([ADR-020(n)/(o)](adr/0020-plugin-infrastructure.md#amendment-2026-08-06-nim-377-the-schema-is-generated-from-go-the-artifact-carries-no-name); full field tables — [`docs/keeper/plugins.md`](keeper/plugins.md#schema-document)). | | **Manifest** _(historical)_ | The hand-written `manifest.yaml` this replaced (NIM-377). Not authored, not shipped, not parsed. The name survives in `plugin_sigils.manifest_raw` / `PluginSigil.manifest`, which now carry schema-document bytes. | | **Bundle** | The set of modules one artifact serves — `module.Bundle{Compat, Modules []Def}`, emitted as the document's `modules[]`. The host selects one by **subcommand** (`soul-mod-redis acl`); `schema` is reserved as a module name ([ADR-020(q)](adr/0020-plugin-infrastructure.md#amendment-2026-08-06-nim-377-the-schema-is-generated-from-go-the-artifact-carries-no-name)). | | **`side`** | `keeper` \| `soul` — **where the module runs**, declared **per module** (in the `modules[]` entry, beside `capabilities` / `side_effects`), default **`soul`**. [ADR-0087](adr/0087-task-side-derived-from-module-address.md), **implemented in NIM-749**: the key is `schema.Module.Side`, the empty value reads as `soul`, and anything outside the enum is `module_side_invalid`. The absent key and `side: soul` are indistinguishable **by design, permanently**: documents are signed and stored, so stamping the default in would change every artifact's sha256 and invalidate every approval. **Routed for plugins too since NIM-758** (closing NIM-688): a keeper-side address the core registry does not know resolves against the discovered plugins, and one declaring `keeper` executes in the Keeper's process ([keeper/modules.md → keeper-side plugin modules](keeper/modules.md#keeper-side-plugin-modules)). A module declaring `soul` on such an address is refused **by name**, not answered "unknown module" and not quietly sent to a host; an address nothing declares still fails `unknown keeper-side module`, loudly, never as a silent skip. Not to be confused with **`side_effects`**, its neighbour in the same object — **`side` is where the module runs; `side_effects` is what it touches.** | | **Registration alias** | The name the **operator** gives an artifact in `keeper.yml::plugins.*[].name`. Address level 1 (`redis` in `redis.acl.present`) and the host slot name. The artifact carries no name of its own, so the same bytes registered twice answer at two addresses ([ADR-020(p)](adr/0020-plugin-infrastructure.md#amendment-2026-08-06-nim-377-the-schema-is-generated-from-go-the-artifact-carries-no-name)). NOT the Sigil registry key — that keys on the artifact source ([ADR-026(a)](adr/0026-sigil.md#amendment-2026-08-06-nim-377-the-registry-keys-on-the-artifact-source-the-signature-is-not-a-control-on-declarations)). Reserved names — [below](#reserved-namespace-names). | | **`soul-mod stamp` / `soul-mod verify`** | `stamp` appends the generated schema to a built artifact and writes `dist/schema.json`; `verify` is the CI gate that the stamped schema still matches the code. | | **Handshake** | A one-line JSON string that the plugin writes to stdout when launched. JSON-payload fields: `soul_stack: "plugin-v1"` (required sanity marker; host ignores all stdout lines before the first one with this field), `protocol_version`, `kind`, `network`, `address`, `server_cert` ([ADR-020(b)](adr/0020-plugin-infrastructure.md)). | | **`kind`** | Closed enum `{soul_module, ssh_provider, soul_beacon}` (`cloud_driver` left it in NIM-761; the proto number is `reserved`) - plugin type discriminator (a **type**, not a name: it survived the removal of `namespace:`/`name:`) (`KIND_SOUL_BEACON=4` added by V5-2, see S5 closure 2026-05-26: typed PortentPayload + `soul_beacon` plugin-kind + inotify-beacon - [ADR-030 amendment 2026-05-26](adr/0030-vigil-oracle.md#amendment-2026-05-26-s5-closure)). The field in the schema document and in the handshake line (must match). ~~Expansion - via PR in `proto/plugin/vN/manifest.proto`~~ ⚠ **stale**: the closed enum lives in **`sdk/schema/schema.go`** (re-exported at `shared/plugin/document.go`, enforced in `sdk/schema/validate.go`); `pluginv1.Manifest` and its `*Spec` sub-messages have zero non-test Go references, so `manifest.proto` is a hand-synced dead document. Adding or removing a kind is an `sdk/` change ([ADR-020 amendment 2026-09-01](adr/0020-plugin-infrastructure.md#amendment-2026-09-01-nim-757-cloud_driver-is-removed-and-side-keeper-is-what-replaces-it)) — the proto contribution is `reserved`, per the never-reuse rule of [ADR-020(c)](adr/0020-plugin-infrastructure.md), which is **not** backward compatibility. ⚠ **`cloud_driver` leaves this enum (NIM-757 / NIM-761, not implemented)**, taking the set to `{soul_module, ssh_provider, soul_beacon}`; an old artifact still declaring it then fails at **schema-document validation**, not at build and not at handshake — `sdk/schema/validate.go` emits `kind_invalid` and `keeper/internal/pluginhost/slot.go:100-102` refuses the slot before the plugin is spawned. `soul_beacon` - Soul-side event-driven monitoring plugin (community implementation of the verification body [Vigil](#domain-entities), unary RPC `ValidateVigil`+`Check`, Sigil-verify is required; [ADR-030](adr/0030-vigil-oracle.md)). | | **`protocol_version`** | int. Plugin protocol version (`proto/plugin/vN/`); duplicated in the schema document and the handshake; strict match `protocol_version: N` ↔ `proto/plugin/vN/`. This is an **compat API flag, not an artifact version** - an exception to [ADR-007](adr/0007-versioning-git-ref.md) ([ADR-020(c)](adr/0020-plugin-infrastructure.md)). | | **`SupportedProtocolVersions`** | The constant in the host binary (`keeper` / `soul` / `soul-lint`) is an ordered list of supported protocol versions. MVP = `[1]`. Forward-compat only-add ([ADR-020(c)](adr/0020-plugin-infrastructure.md)). | | **`SOUL_PLUGIN_SOCKET`** | Env-var, through which the host passes the path to the Unix-socket to the plugin. Directories - `/var/run/soul-stack/plugins/` (Soul-host) and `/var/run/soul-stack-keeper/plugins/` (Keeper-host), mode `0700`, owned by service user ([ADR-020(d)](adr/0020-plugin-infrastructure.md)). | | **`SOUL_CLOUD_WAIT_BUDGET`** — read by [`sdk/cloudutil`](../sdk/cloudutil/wait.go), where the wait plumbing landed when NIM-761 removed the CloudDriver contract; the name is deliberately unchanged, since it is set on deployed Keeper units | Env-var (Go duration, e.g. `20m`) overriding how long a CloudDriver waits for new VMs to become ready; set on the Keeper unit, inherited by every `soul-cloud-` process. Default 10 min, clamped to 2h; invalid/zero/negative falls back to the default. Separate from the API-retry backoff ([keeper/plugins.md](keeper/plugins.md)). | ### Plugin names: regex Standard regex for the kebab-case identifiers of module addressing `..` (used by the schema-document validator and `soul-lint`). `Handshake` fields — see the entry above. | Field | Regex | Meaning | |---|---|---| | **registration alias** | `^[a-z][a-z0-9-]{0,62}$` ([`plugin.AliasPattern`](../shared/plugin/reserved.go)) | Address level 1, chosen by the **operator** in `keeper.yml::plugins.*[].name` and on `soul-lint --modules =`. Must not be a [reserved name](#reserved-namespace-names). The charset is the intersection of what a path segment and an address segment may hold: **no dots** (they separate address levels), no slashes or `..` (traversal), no uppercase (a case-insensitive filesystem would fold two registrations into one slot). | | **`modules[].name`** | `^[a-z][a-z0-9-]{0,62}$` | Address level 2 — **the object the module manages** (`user`, `instance`, `replica`), one entry per object; see [the discipline binding the three levels](#the-discipline-binding-the-three-levels). Also the subcommand the host passes; `schema` is reserved, and several objects in one artifact are already legal (`Modules []Module`, at least one required, duplicates refused). | | **``** | `^[a-z][a-z0-9-]{0,30}$` | Address level 3 (`installed` / `running` / `restarted` / …). | | ~~binary-name~~ | — | **REMOVED (NIM-377).** `soul-mod--` / `soul-cloud-` / `soul-ssh-` / `soul-beacon-` is no longer a convention or a contract: `dist/` holds exactly one executable and the host takes it, whatever it is called. The [ADR-016 amendment 2026-05-27](adr/0016-parity-license.md) that put the namespace into the SoulModule binary name is moot, and so is NIM-423. | Cross-link: where these fields live in the plugin - [`docs/keeper/plugins.md → Schema document`](keeper/plugins.md#schema-document); addressing modules - section ["Destiny Modules"](#destiny-modules). The per-plugin directory (states, params, behavior) is maintained not here, but under `docs/module/`: implemented [`redis`](module/redis/README.md) (interface to live Redis, `soul-mod-redis`, seven objects — re-laid-out and moved out of the origin-grouping directory by NIM-766, `user` added by NIM-767) and [`mongo`](module/mongo/README.md) (interface to live MongoDB, `soul-mod-mongo`, three objects — the same re-layout by NIM-769, PILOT standalone). With mongo moved, no plugin document sits under an origin-grouping directory any more. ### Reserved namespace names A closed list of names that **may not be used as a registration alias** ([ADR-020(s)](adr/0020-plugin-infrastructure.md#amendment-2026-08-06-nim-377-the-schema-is-generated-from-go-the-artifact-carries-no-name), absorbing **NIM-375**). Checked on **both** surfaces — at registration (`keeper.yml::plugins.*[].name`) and in destiny `required_modules:` — because a name rejected in only one of them is a name that fails late, on the host, in the middle of a run. | Tier | Names | Why | |---|---|---| | **Mandatory** | `core`, `keeper`, `soul` | Already mean something. `core` is the built-in namespace ([ADR-015](adr/0015-core-modules-mvp.md)); `keeper` and `soul` are the two sides of the system. An alias shadowing one of these makes `core.pkg.installed` ambiguous. | | **Second tier** | `destiny`, `scenario`, `service`, `incarnation`, `soulprint`, `coven`, `archon`, `sigil`, `herald`, `provider`, `soulstack`, `soul-stack`, `local`, `default`, `internal`, `test`, `example` | Dictionary entities and conventional placeholders. Reserved before anyone takes them, because an alias is baked into every destiny that uses it and taking one back later is a fleet-wide rename. `herald` and `provider` joined in **NIM-706**: both are dictionary entities, and the platform writes a Vault path family under each name, which is what made them worth taking early. | The list is closed: adding to it is propose-and-wait plus a PR here, and it should be done **before** a name is plausibly in use, not after. One list, one predicate ([`plugin.IsReserved`](../shared/plugin/reserved.go)) — a second copy is how the two ends drift apart, and a gap between them is the shadowing this prevents. The lookup folds case and trims space, so `Core ` is refused alongside `core`. Checked on three surfaces, all reaching that one predicate: | Surface | Diagnostic | |---|---| | Registration — `keeper.yml::plugins.*[].name` | The catalog entry is refused; the alias never enters the cluster. | | `destiny.yml → required_modules[]` and `service.yml → modules[]` | **`reserved_module_namespace`** (error) — a reserved name at address level 1. `core.*` keeps its own **`core_module_in_modules_list`**, because it is the one reserved name an author reaches for by accident rather than by collision, and "core modules are always available" answers the question they actually had. | | `soul-lint --modules =` | Fatal (exit 2) — refusing it at the flag also tells the author early that the alias will not survive `plugin.allow`. | ### Reserved Vault namespaces A second, **narrower** closed list, and a different question from the one above. A registration alias that collides only *shadows an address*; a **service name** that collides *destroys a secret*, so this list is enforced where a service is named rather than where a plugin is registered (**NIM-706**). The reason is that four families of Vault paths share one KV mount and each of them fixes its **first** segment: | Path family | Written by | Shape | |---|---|---| | keeper's own runtime secrets | `keeper` ([ADR-014](adr/0014-operator-identity.md)) | `/keeper/` | | herald credentials | [`keeper/internal/secretwrite`](../keeper/internal/secretwrite/writer.go) ([ADR-064](adr/0064-secret-write-path.md)) | `/herald//` | | provider credentials | same | `/provider//credentials` | | a service's **declared** secrets | the platform, derived ([ADR-0083](adr/0083-declared-secret-state-fields.md) §1) | `///[/]` | A service named `herald` therefore derives its incarnation onto the herald family's `` slot and its state field onto that family's `` slot — the same KV entry, two writers. Vault KV v2 **replaces** an entry rather than merging into it, so on that path the second write silently deletes the first one's fields, with no error anywhere. | Reserved as a service name | Why | |---|---| | `keeper`, `herald`, `provider` | Each one already names a path family the platform writes itself. | | `internal` | Not written today. Reserved with the other three because taking it later is a rename of every incarnation of a live service, and this is the one moment it costs nothing. | The lookup folds case and trims space ([`config.IsReservedVaultNamespace`](../shared/config/reserved_namespace.go)); the comparison is on the **whole** name, so `keeper-notes` is a perfectly good service — it owns a disjoint prefix and never meets `/keeper/`. Checked at three surfaces, all reaching that one predicate. It was four until [NIM-726](adr/0085-entity-id-and-label.md) removed `service.yml → name`: that surface could only judge a name the manifest stated, and the manifest states none. | Surface | Diagnostic | |---|---| | Registration — `POST /v1/services` and the MCP `service.register` / `service.update` tools | `serviceregistry.ErrReservedName` → **422 validation-failed**, not 409: nothing holds the name, it is unusable by construction, and a conflict status sends the operator hunting for the service that supposedly owns it. | | The derivation itself — [`config.SecretField.VaultPath`](../shared/config/secret_field.go) | Refuses to emit a path at all. The fail-closed floor under the two above, so a row that predates the rule cannot be handed a colliding path. | | Reveal — `GET /v1/incarnations/{id}/secrets/{id}` | Denied before Vault is read, audit `reason=floor_denied`. The path-shaped half of the same check also catches a **mount** that spells a reserved word (`vault.kv_mount: keeper`), which a name comparison structurally cannot see. | #### Reserved state-field names One derived path lives **inside** a service's own namespace and is therefore out of reach of any rule about service names: keeper issues an incarnation's TLS material to `///tls/{cert,key}` ([`keeper/internal/certissue`](../keeper/internal/certissue/issue.go)). A `state_schema` collection secret on a state field named `tls` with an element key `cert` derives that identical path. The element key is state **data** and cannot be constrained, so the fence is on the state field name, at the last static point there is: | Reserved as a `state_schema` field carrying `type: secret` | Written by | |---|---| | `tls` | `certissue` — an incarnation's certificate and private key. | Diagnostic **`secret_field_reserved_state_name`** (error), raised at manifest load. Only a field that *declares a secret* is checked: a plain `tls:` object holding ports and cipher lists derives nothing and is untouched ([`examples/services/redis/service.yml`](../examples/service/redis/service.yml) and `dragonfly` both have one). Both lists are closed. Adding to either is propose-and-wait plus a PR here. ### Host behavior after handshake: `plugin_init_failed` / `plugin_crash` Names of reasons (values ​​in the open directory `TaskError.reason`) with which the host records the non-graceful exit of the plugin after a successful handshake. The complete scenario table is [`docs/keeper/plugins.md → Host behavior after handshake`](keeper/plugins.md). | Name | When fixed | |---|---| | **`plugin_init_failed`** | The plugin exited with exit code ≠ 0 after the handshake, but **before the start of Apply** (before the first RPC). | | **`plugin_crash`** | The plugin exited with exit code ≠ 0 **in the middle of the Apply-stream** (panic / OOM / SIGSEGV / any non-graceful exit). | Normalizing the full catalog `TaskError.reason` is a separate backlog task along with closing `proto/plugin/v1/` and audit-pipeline. ### `core.module.installed`: reasons for install step The values of the open directory `TaskError.reason`, which the install step `core.module.installed` records the failure of delivery of the SoulModule plugin ([ADR-065](adr/0065-core-module-installed.md)). | Name | When fixed | |---|---| | **`module_not_allowed`** | There is no active Sigil-permit for the **registration alias** with `kind: soul_module` - refusal to allow-check **UP TO a single network byte**; or `params.ref` is specified, and an active tolerance exists on another ref (pin matching, [ADR-065(c)](adr/0065-core-module-installed.md)). ⚠ **Correction, carried as a rider on NIM-794:** this row said the permit is keyed on `(namespace, name)`, which has been stale since NIM-438 re-keyed the registry onto `(source, ref)` under an alias — the artifact declares no name of its own, so there is nothing for that pair to be read from. The lookup keys on the alias: `soul/internal/coremod/module/installed.go:63`. | | **`module_fetch_failed`** | Transport error `FetchModule`: Keeper is unavailable / `Unimplemented` for the old Keeper / stream interruption / `plugins.max_artifact_size_mb` exceeded / rate-limit per-SID. ⚠ **Extended — not replaced, and not a new code — by the 2026-09-04 amendment (NIM-794 / NIM-795 — the keeper half is not implemented; the Soul half shipped as NIM-796).** The catalog above is open by declaration, and the step that fails is the same one (step 3, the fetch). Under source-pull the endpoint is the artifact source rather than the Keeper, so this reason additionally covers a class of causes the `FetchModule` wording does not describe: the **source unreachable**, an **HTTP 404** for the row's `path`, a **TLS failure**, a **redirect** that is not followed. ★ **The Soul-side ceiling shipped with NIM-796.** The `plugins.max_artifact_size_mb` named above is a **Keeper-side** field and still does not bound a source fetch; what bounds one is the package constant `maxArtifactBytes` (`soul/internal/coremod/module/source.go:28-38`) — `config.DefaultPluginMaxArtifactSizeMB` (256, `shared/config/keeper.go:1422`) × 1 MiB — enforced at `source.go:166-173` by reading one byte past the cap, so an artifact sitting exactly at the limit is not mistaken for one truncated at it. It is a **constant, not a `soul.yml` field**, and it reuses Keeper's *default* rather than a cluster's configured value, so a cluster that raised its own ceiling has artifacts this path refuses and `FetchModule` would serve — refusing is the safe direction for a bound on an allocation an untrusted source controls. `fetchAll` (`soul/internal/coremod/module/installed.go:124-144`) is still unbounded, and still correct: its peer is the Keeper, which caps what it sends ([ADR-065 amendment](adr/0065-core-module-installed.md#amendment-2026-09-04-nim-794-the-fetch-step-goes-to-the-source-and-fetchmodule-stays-as-the-egress-free-path)). | | **`module_verify_failed`** | The downloaded bytes did not pass verification: sha256 ≠ `binary_sha256` tolerance, invalid Sigil signature or mismatch `manifest_sha256`. The binary **does not materialize** (verify - before atomic rename). | ### `required_capabilities`-enum Closed enum in `manifest.yaml`; `soul-lint` checks against `plugin_runtime.allowed_capabilities` host ([ADR-020(f)](adr/0020-plugin-infrastructure.md), full table - [`docs/keeper/plugins.md`](keeper/plugins.md)). | Capability | Meaning | |---|---| | **`run_as_root`** | The host process must have UID 0 when running the plugin. | | **`network_outbound`** | The plugin makes outgoing network calls. | | **`network_inbound`** | The plugin listens to the port. | | **`vault_access`** | The plugin accesses Vault through the client helper SDK. | | **`fs_write_root`** | The plugin writes beyond `/var/lib/soul-stack/`. | | **`exec_subprocess`** | The plugin runs external commands via `os/exec`. | ### `side_effects` - resource types Closed enum of resource types that the plugin touches. Strict contract: runtime violation (the plugin touches a resource not from `side_effects`) → the step is marked `failed`, the reason `policy_violation` is reflected in the diagnostic channel `TaskEvent` / `RunResult` (the exact form of the field is a separate audit-pipeline standardization task for `side_effects`, see backlog in [ADR-020(g)](adr/0020-plugin-infrastructure.md)). The complete table is [`docs/keeper/plugins.md`](keeper/plugins.md). | Resource type | Meaning | |---|---| | **`service`** | service name (`haproxy`). | | **`file`** | absolute path to the file. | | **`package`** | OS package name. | | **`port`** | tcp/udp port (int). | | **`user`** | OS user name. | | **`group`** | OS group name. | | **`directory`** | absolute path to the directory. | | **`cron`** | cron task name. | | **`mount`** | mountpoint. | ### Host config | Name | Role | |---|---| | **`plugin_runtime`** | Block in [`keeper.yml`](keeper/config.md) / `soul.yml` with plugin launch parameters (`startup_timeout`, `shutdown_grace`, `allowed_capabilities`, `conflict_policy`, opt. `enable_tls`). Block rationing is a deferred task; defaults are fixed in [ADR-020(d)](adr/0020-plugin-infrastructure.md). | ### Sigil: registry and integrity fields Names of the plugin integrity trust model **Sigil** ([ADR-026](adr/0026-sigil.md), spec - [keeper/plugins.md → Integrity-model](keeper/plugins.md#integrity-model)). | Name | Role | |---|---| | **`plugin_sigils`** | PG table - Keeper-side allow-list of plugins: trust key `(source, ref)` plus a separate active-unique `alias` (migration 115, NIM-377/438), value `sha256` (hex, lowercase) + audit permission fields (who/when allowed). Authoritative source "which binary is accepted". Managed via OpenAPI/MCP (explicit permission by Archon). Column **`commit_sha`** - git-verified origin of the binary ([ADR-026(g)](adr/0026-sigil.md), audit mark OUTSIDE the signed block) - **entered at impl A1-S3**. | | **`sigil_signing_keys`** | PG table - registry of trust-anchor-**signature keys** Sigil ([ADR-026(h)](adr/0026-sigil.md), migration 037, R3 multi-anchor). Key `key_id` (SHA-256 SPKI, hex) → `pubkey_pem` (SPKI, public part) + `vault_ref` (private in Vault KV) + `is_primary` / `status` (active\|retired) + audit-FK on `operators`. **Private is NEVER in Postgres.** Invariants: exactly one primary among active (partial unique index `sigil_signing_keys_one_primary`), ≥1 active in case of Retire. Managed via OpenAPI/MCP (operator-facing rotation, R3-S7). NOT to be confused with `plugin_sigils` (that one is the binary permissions, this one is their signature keys). | | **`binary_sha256`** | New **optional** field in `proto/plugin` manifest (only-add, [ADR-026(c)/(e)](adr/0026-sigil.md)). The SHA-256 binary, **sewn** into the signed block Sigil → manifest, ceases to be forged. | Sigil Soul distribution transport - only-add proto-message in `oneof payload` existing `EventStream` ([ADR-012(c)](adr/0012-keeper-soul-grpc.md), without new RPC); specific message-/field-names are assigned to a separate propose-and-wait during implementation (as `augur.proto` relative to [ADR-025](adr/0025-augur.md)). ### Git plugin directory resolver (F-fetch) Keeper git-resolve `keeper.yml::plugins.{ssh_providers,soul_modules}` itself ([ADR-026(g)](adr/0026-sigil.md), A1-S1; `soul_modules` - [ADR-065](adr/0065-core-module-installed.md); spec - [keeper/plugins.md → What Keeper does resolver](keeper/plugins.md)). ⚠ **Git-resolution becomes SCOPED to `source_kind: git` with the 2026-09-04 amendment (NIM-794 / NIM-795, not implemented — today every entry is git-resolved, and that is what the section below describes).** An entry declaring `source_kind: artifact` is never cloned: there is no checkout, no `commit_sha`, and `ref` is a label the operator asserts rather than something the Keeper verified — which costs a provenance marker, not a control. It is served by a second resolver behind an interface laid down for the purpose, with exactly one implementation (`artifact` over https) ([ADR-020 amendment](adr/0020-plugin-infrastructure.md#amendment-2026-09-04-nim-794-the-catalog-entry-gains-a-source-kind-and-an-explicit-artifact-list), [ADR-026 amendment](adr/0026-sigil.md#amendment-2026-09-04-nim-794-the-grant-carries-a-list-of-artifacts-and-the-bytes-stop-travelling-through-the-keeper)). | Name | Role | |---|---| | **`keeper/internal/plugingit`** | Go package of the plugin directory git resolver: go-git F-fetch (clone→fetch→checkout), R-nested-cache, atomic-slot filling + `current`-symlink. Without the system fork `git`. | | **`dist/`** | Convention: The ALREADY assembled plugin binary in the repository is located in `dist/`. F-fetch - Keeper fetches a finished artifact, does not compile. `` - by `kind` (`soul-cloud-` / `soul-ssh-`). | | **`///`** + symlink **`current`** | R-nested cache layout: immutable per-commit slot + atomic pointer to the active slot (single-active-per-pair). | | **`plugins.work_root`** / **`plugins.fetch_timeout`** | Config fields `keeper.yml`: root of working git clones (STRICTLY outside `cache_root`) and ceiling of go-git operations chain (default 120s). | | **`plugins.soul_modules`** ⚠ **the entry shape changes (NIM-794 / NIM-795, not implemented — `{name, source, ref}` ships today)** | The third type of entries in the `keeper.yml::plugins` catalog is SoulModule plugins (`{name, source, ref}`, symmetry `ssh_providers`; [ADR-065](adr/0065-core-module-installed.md), amendment [ADR-020](adr/0020-plugin-infrastructure.md)). Resolved by the same plugingit resolver in `cache_root`, allowed by the same Sigil flow; distribution to Soul hosts - RPC `FetchModule` + `core.module.installed`. ⚠ **2026-09-04 amendment:** the entry gains `source_kind` (`git` / `artifact`; `git` is what every entry means today and stays the default shape), and an `artifact` entry carries `base_url` plus an explicit `artifacts[]` list instead of a single resolvable `source`. Distribution then runs **from the artifact source to the host**, with `FetchModule` retained — not deprecated — for hosts without egress ([ADR-020 amendment](adr/0020-plugin-infrastructure.md#amendment-2026-09-04-nim-794-the-catalog-entry-gains-a-source-kind-and-an-explicit-artifact-list)). | | **`source_kind`** ⚠ **Not implemented — the catalog entry ships as `{name, source, ref}`** | A **closed enum** `git` / `artifact` on a `plugins.soul_modules[]` catalog entry, saying **how the bytes are obtained**: `git` = today's plugingit resolve (the default and the meaning of every existing entry), `artifact` = a prebuilt binary fetched from a repository over https. Deliberately **not** spelled `kind`: that name is already the plugin-*type* discriminator (`sdk/schema/schema.go`, the `kind` row above), an entry inside `soul_modules:` carrying `kind: artifact` would contradict its own container, and the two would meet in `keeper/internal/pluginhost/pluginhost.go:220-224`, which already maps the schema `kind` onto the catalog list name. The `_kind` construction beside `kind` follows the settled `provider_kind` precedent — a closed enum in its own space, no collision ([ADR-020 amendment](adr/0020-plugin-infrastructure.md#amendment-2026-09-04-nim-794-the-catalog-entry-gains-a-source-kind-and-an-explicit-artifact-list)). | | **`base_url`** ⚠ **Not implemented — the catalog entry ships as `{name, source, ref}`** | On a `source_kind: artifact` catalog entry, the base address the entry's artifacts sit under (e.g. an internal Nexus repository). Each artifact row's `path` is resolved beneath it. It replaces `source` for that arm — nothing is cloned, so there is no git remote to name. v1 assumes the repository is **anonymously readable**: authentication to the source is deliberately deferred, because a credential on every managed host is its own security decision ([ADR-026 amendment](adr/0026-sigil.md#amendment-2026-09-04-nim-794-the-grant-carries-a-list-of-artifacts-and-the-bytes-stop-travelling-through-the-keeper)). | | **`artifacts[]`** ⚠ **Not implemented — the catalog entry ships as `{name, source, ref}`** | The explicit per-platform list on a `source_kind: artifact` entry; one row `{os, arch, path, sha256}` per platform, where `os`/`arch` are matched against the host's own Soulprint facts (`os.family`, collapsed to `linux` for the Linux distro families, and `os.arch`) with the running binary's platform as the fallback (`Module.hostPlatform`, `soul/internal/coremod/module/source.go:315-331`), and `sha256` is the digest approved for that platform. **Each row names its own `path` in full — there is deliberately no template and no `{os}` substitution**, because an expressive template in a URL that yields executable bytes turns an address into a small program evaluated on the host; the explicit list closes that structurally. It also cannot be avoided: the artifact carries no self-name, so nothing in the bytes says which platform they are for, and the mapping has to be stated rather than derived. **No matching row for this host = a closed refusal**, never a fallback row ([ADR-026 amendment](adr/0026-sigil.md#amendment-2026-09-04-nim-794-the-grant-carries-a-list-of-artifacts-and-the-bytes-stop-travelling-through-the-keeper)). | | **`ErrRefNotResolved`** / **`ErrManifestNotFound`** / **`ErrArtifactNotFound`** / **`ErrSourceUnavailable`** | Sentinel errors in resolving one directory entry (per-entry warnings, fail-closed). | ## Conventions for the location of files and Go modules ### Extensions | Extension | Purpose | |---|---| | **`.tmpl`** | Mandatory extension for **Go text/template** files, rendered by the `core.file.rendered` module ([ADR-010](adr/0010-templating.md)). The templates are located in `templates/.tmpl` inside the service-/destiny-repo. A file without the `.tmpl` extension is not accepted by the `core.file.rendered` module (validation error `soul-lint`). The historical `.j2` is no longer used. | ### Go modules of the main repo The layout is fixed in [ADR-011](adr/0011-go-layout.md). Module path (org `souls-guild`) - `github.com/souls-guild/soul-stack/`. | Name | Destination | |---|---| | **`go.work`** | Workspace file in the root of the main repo; lists all Go modules of the project (`./keeper`, `./soul`, `./soul-lint`, `./shared`, `./sdk`, `./proto`, `./proto/plugin`). One per repository, at the root. | | **`shared/`** | Go module with cross-code of all binaries (metrics/OTel, hot-reload-config, log rotation, client Vault, mTLS-helpers, CEL/text-template). Subpackages - `obs/`, `log/`, `config/`, `vault/`, `tlsx/`, `cel/`, `tmpl/`. `vault/` contains **client side only**; server-side Vault operations live in `keeper/internal/vault/` and are not exported through `shared/`. | | **`sdk/`** | Public Go module - SDK for plugin authors. Subpackages: `module/` (SoulModule), `clouddriver/` (CloudDriver), `sshprovider/` (SshProvider), `handshake/` (general handshake-helper). The core modules of both sides implement the same interfaces from `sdk/module/` as the custom modules in separate repositories. | | **`proto/plugin/`** | A separate nested Go submodule for plugin contracts (`soulmodule.proto`, `clouddriver.proto`, `sshprovider.proto` + `handshake.proto`) with its own `go.mod`. Plugin authors pull only this minimal dependency, without Keeper↔Soul or Operator API. | | **`cmd//main.go`** | Convention for the entry-point of each binary **within the module** (`keeper/cmd/keeper/main.go`, `soul/cmd/soul/main.go`, `soul-lint/cmd/soul-lint/main.go`). The `cmd/` subdirectory is saved even with one binary per module - useful for future utilities (`keeper-migrate`, etc.). | > **NB (OpenAPI epic, [ADR-054](adr/0054-openapi-code-first.md) replaced [ADR-051](adr/0051-operator-api-codegen.md)).** The **Operator API** form is specified by **Go handler types** (huma v2 full-typed, code-first, `keeper/internal/api/huma_*.go`); OpenAPI spec [`docs/keeper/openapi.yaml`](keeper/openapi.yaml) - derived snapshot (huma aggregator `HumaFullSpecYAML`), not source. Spec-first-framework `oapi-codegen` + package `keeper/internal/api/oapi/` **demolished** (2026-06-13); the previous form source `proto/operator/v1` was retired earlier (amend [ADR-011](adr/0011-go-layout.md)). `huma` (as before `oapi-codegen`) is a tool, not a dictionary entity; **no new dictionary names are introduced**. > **NB (OpenAPI viewer route `/docs` is a deliberate exception to the dictionary, [ADR-054 §OpenAPI viewer](adr/0054-openapi-code-first.md)).** Keeper's visual OpenAPI viewer is mounted on route **`GET /docs`** (+ static `GET /docs/assets/*`). The name `/docs` is **industry standard**, chosen by the user over the dictionary options (`/scriptorium`, `/codex`) deliberately: the viewer is primary for the operator who is looking for documentation along a recognizable conventional path, and not by a "soulful" name. This is the only **authorized exception** to the "names are from the Soul Stack dictionary only" rule for the meta route; The exception does NOT apply to domain entities (`/v1/*`, registries, modules). Viewer is a meta-route outside `/v1` (next to `/healthz`/`/readyz`/`/openapi.yaml`), does not carry a dictionary subject of its own. > **NB (the embed-UI route `/ui` is a deliberate exception to the dictionary, [ADR-055](adr/0055-embed-ui-bundle.md)).** The optionally embedded UI (`soul-stack-web`) is distributed on the route **`GET /ui`** (+ `GET /ui/*` with SPA-fallback on `index.html`). Name `/ui` - **industry-standard** (as `/docs`): The operator looks for a web interface along a recognizable conventional path. Authorized exception to "names are dictionary only" for meta route, same as `/docs`; does NOT apply to domain entities (`/v1/*`). `/ui`-tree - public static outside `/v1` (parity `/docs`; protection - at the API boundary `/v1`), shares listener Operator API, no new ports. Toggle - config key `web_ui_enabled` (see below). > **NB (package `keeper/internal/webui/` + config key `web_ui_enabled`, [ADR-055](adr/0055-embed-ui-bundle.md)).** Package **`keeper/internal/webui/`** carries go:embed of the vendored UI build snapshot (`soul-stack-web`) and distributes it to `/ui`; the static artifact is located in the subfolder **`keeper/internal/webui/assets/`** (name `assets/`, NOT `dist/` - the gitignore rule `dist/` would silently eat the embed tree). Static distribution technical package (like `keeper/internal/api/docsassets/` for `/docs`), not a dictionary entity; no new dictionary names are introduced. Root config key keeper **`web_ui_enabled`** - scalar `*bool`: `nil` (omitted) → **default-ON** (UI out of the box for beta); explicit `false` → opt-out (statics are not mounted). Build-snapshot sync from companion is `scenarios/sync-webui.sh`, drift-guard is `make check-webui` (skip without companion; tracing plugin-template `sync-template.sh`/`check-template`). The standard typing of the config key is [`docs/keeper/config.md`](keeper/config.md) (docs-writer during implementation). ## Soulprint Fields Typed MVP schema fixed [ADR-018](adr/0018-soulprint-typed.md); full spec - [`soul/soulprint.md`](soul/soulprint.md). | Message proto | Contains | Semantics | |---|---|---| | **`SoulprintFacts`** | `sid` / `hostname` / `os` / `kernel` / `cpu` / `memory` / `network` | Root; collected by a Soul agent, sent to `SoulprintReport.typed_facts`. | | **`OsFacts`** | `family` / `distro` / `version` / `codename` / `arch` / `pkg_mgr` / `init_system` | `pkg_mgr` and `init_system` - collected by the Soul agent from the mapping table, used by `core.pkg.*` and `core.service.*`. | | **`KernelFacts`** | `version` / `release` | | | **`CpuFacts`** | `count` / `model` / `vendor` | | | **`MemoryFacts`** | `total_mb` / `available_mb` / `swap_mb` | Volumes are in **MB**, not bytes. | | **`NetworkFacts`** | `primary_ip` / `fqdn` / `interfaces[]` | `primary_ip` - convenience heuristic (default-route interface). | | **`NetworkInterface`** | `name` / `ipv4[]` / `ipv6[]` / `mac` / `mtu` | | **The canonical CEL form** is `soulprint.self.` (`soulprint.self.os.family`, `soulprint.self.memory.total_mb`, `soulprint.self.network.primary_ip`). Bare `soulprint.` without `.self` - validation error `soul-lint`. Symmetry with `register.self.*`. **`covens` is not in `SoulprintFacts`** - this is Keeper-registry-data (`souls.coven[]` in Postgres). `soulprint.self.covens` - virtual projection of Keeper-side data in Soulprint-namespace with CEL resolution. ## Messages proto Keeper↔Soul Names of message types in `proto/keeper/v1/*.proto`. Fixed [ADR-012](adr/0012-keeper-soul-grpc.md). | Name | Direction | Role | |---|---|---| | **`BootstrapRequest`** / **`BootstrapReply`** | Soul → Keeper unary | Onboarding: Soul presents `bootstrap_token` + CSR (public key in PEM), receives the issued SoulSeed certificate and CA chain. It is transmitted **before** the mTLS stream, on a separate listener (server-only TLS). `BootstrapReply` additionally carries optional. `sigil_pubkey_pem` (field 5, [ADR-026](architecture.md), S2b) - PEM/SPKI of the public ed25519 Sigil signing key; Soul persists its trust-anchor next to SoulSeed to verify plugin permissions (S6) - and multi-anchor `sigil_pubkey_pem_set` (field 6, [ADR-026(h)](architecture.md), R3) - a complete set of SPKI PEM signature keys for seamless rotation. **Priority set > single**: if `sigil_pubkey_pem_set` is non-empty, single `sigil_pubkey_pem` is ignored; **both empty = Sigil disabled** on this host. | | **`Hello`** / **`HelloReply`** | both, first EventStream message | Soul after mTLS-handshake sends `Hello` with the binary version and optional snapshot Soulprint; Keeper responds with `HelloReply` with `session_id` (= KID + server time + ULID), server time and acknowledgment. SID **not** in payload as identity-claim - taken from mTLS peer cert. | | **`ApplyRequest`** | Keeper → Soul | "Apply plan" command. Contains `apply_id`, `repeated RenderedTask tasks` (plan **after** Keeper-side render, see ADR-012(d)), `attempt` (fencing-epoch, [ADR-027](adr/0027-apply-work-queue.md)), `dry_run` (with `true` Soul calls `Plan` instead of `Apply`, read-only-pass; **only-add** [ADR-031(c)](adr/0031-scry-drift.md#adr-031-scry--drift-detection-declarative-dry-run-reconcile); no Keeper path sets it since NIM-446, the field stays under the ADR-012 only-add rule) and `passage` (index [Passage](#domain-entities) staged-render, 0-based; **only-add** [ADR-056](adr/0056-staged-render-passage.md); one host receives N `ApplyRequest` - one per Passage). | | **`TaskEvent`** | Soul → Keeper | The final result of one task (aggregation on Soul after completion of the SoulModule sub-process): `task_idx`, `apply_id`, `passage` (index [Passage](#domain-entities), **only-add** [ADR-056](adr/0056-staged-render-passage.md)), `register` (DSL core: `.changed`/`.failed`/`.timed_out`) + fields declared in destiny `output:` - ★ that second half is **unbuilt**: nothing fills a destiny's `output:` (a task-level `output:` is refused, `output_unsupported`), so a `TaskEvent` carries the DSL core alone. The progress of long-running steps is not transferred to the MVP. | | **`TaskNotice`** | Soul → Keeper (inside `TaskEvent.notices`) | **Something the operator needs to know about a task that ran ANYWAY** ([ADR-0076](adr/0076-engine-compat-window.md)(u), **only-add**): `code` (machine-readable kind, sharing the [`shared/diag`](../shared/diag) vocabulary — today only `deprecated_param`), `module`, `param`, `message` (the operator-facing sentence, rendered by the side that HOLDS the manifest so every surface says it identically). Deliberately **not** a `TaskError`: an error means the task was refused and the host left untouched, a notice rides along with work that succeeded. Originates on the **Soul** because the contract is the manifest compiled into that binary — a park mid-upgrade legitimately answers differently host to host. Keeper lands it in the `task.executed` audit payload, the SSE frame, and `apply_runs.notices` (per host, deduplicated by `(code, module, param)`). **Never masked** — a notice carries manifest metadata, never a param value (it was exempt from `no_log:` for that reason before [ADR-083](adr/0083-declared-secret-state-fields.md) §8 removed the key). | | **`RunResult`** | Soul → Keeper | Final run report apply: `apply_id`, `passage` (index [Passage](#domain-entities), **only-add** [ADR-056](adr/0056-staged-render-passage.md); per-Passage report - N `RunResult` to `(apply_id, sid, passage)`), status (`success` / `failed` / `cancelled` / `error_locked`). Its `state_changes` field carried the aggregated delta for Keeper's end-of-run `incarnation.state` commit and is now **reserved, never populated** ([ADR-0084](adr/0084-explicit-state-capture.md) retired that commit; the field stays because [ADR-012](adr/0012-keeper-soul-grpc.md) is forward-compat only-add). Replaces the previously proposed `StateReport` (conflict with reserved "State" = `incarnation.state`). | | **`CancelApply`** | Keeper → Soul | Command to cancel the current apply (`apply_id`). Soul is trying to stop the in-flight SoulModule sub-process, sending `RunResult` with the status `cancelled`. | | **`SoulprintReport`** | Soul → Keeper | Periodic background push of facts about the host (`refresh_interval`). Payload - `google.protobuf.Struct` until closing open Q No. 6 (then there will be a typed message). | | **`HostUtilization`** | Soul → Keeper | **Live host utilization** (the **Host-Utilization** layer, [ADR-072](adr/0072-host-utilization.md)) — an economical push on top of the presence stream, **separate** from the static `SoulprintReport` (utilization is volatile, not a targeting fact, and does not enter the `soulprint.*` namespace). Fields — **DevOps terms**: `cpu_pct` / `load1` / `load5` / `load15` / `mem_used_mb` / `mem_total_mb` / `swap_used_mb` / `disks[]` (nested **`DiskUtilization`**: `mount` / `used_mb` / `total_mb` / `inodes_used` / `inodes_total`) / `uptime_sec` / `net_rx_bps` / `net_tx_bps` / `net_err_ps` (aggregate physical-NIC throughput + a combined errors/drops rate, NIM-127). **Only-add** to `FromSoul.oneof payload` (**field 10**), new file `proto/keeper/v1/utilization.proto`; no new RPC. Pulse 30s default / floor 10s. Collector set `{cpu,mem,disk,load,uptime,net}` (inode rides `disk`). SID — from the mTLS peer cert (the payload echo is not trusted, [ADR-012(i)](adr/0012-keeper-soul-grpc.md#adr-012-keepersoul-grpc-contract-one-eventstream-with-oneof-keeper-side-render-forward-compat-only-add)); latest in Redis (`soul::util`); presence liveness **DOES NOT** depend on utilization. | | **`SeedRotationRequest`** / **`SeedRotationReply`** | Soul → Keeper, then back | Rotation of SoulSeed on a live stream: Soul for `expires_at - 24h` sends a new CSR to the EventStream, Keeper responds with a new certificate, Soul atomically swaps. It is not allocated as a separate RPC - it is part of the oneof EventStream. | | **`AugurRequest`** / **`AugurReply`** | Soul → Keeper, then back | Augur request for live access to an external system ([ADR-025](adr/0025-augur.md)). `AugurRequest{request_id, apply_id, omen_name, query}` (SID - from mTLS peer cert, not in payload, [ADR-012(i)](adr/0012-keeper-soul-grpc.md)); `AugurReply{request_id, status, oneof result, error}`. **Only-add** in EventStream oneof, new file `proto/keeper/v1/augur.proto`; There is no new RPC. Spec - [keeper/augur.md](keeper/augur.md). | | **`PluginSigil`** | Keeper → Soul | Transport of one Sigil Seal of Trust ([ADR-026](architecture.md), S2b): `PluginSigil{namespace, name, ref, binary_sha256 (hex lowercase), signature (raw 64B ed25519), manifest (raw manifest.yaml bytes)}`. Self-sufficient for Soul-side verify (S6): re-assembles the signed block and verifies the signature with the trust-anchor `BootstrapReply.sigil_pubkey_pem`. **Role - broadcast notification of a new admission, NOT mutation of an active set** ([ADR-026(h)](architecture.md)): Soul does not upsert on it, the authority of the set is only `SigilSnapshot`. **Only-add** to `FromKeeper.oneof payload` (field 5), new file `proto/keeper/v1/sigil.proto`; There is no new RPC. S2b introduces only a contract - distribution of messages by Keeper and verify on Soul - S6. ⚠ **The scalar `binary_sha256` becomes a list with the 2026-09-04 amendment (NIM-794 / NIM-795, not implemented)** — see `PluginArtifact` below. | | **`PluginArtifact`** ⚠ **Not implemented — the catalog entry ships as `{name, source, ref}`, and the grant carries one scalar digest** | Keeper → Soul | One platform row inside a Sigil grant: `PluginArtifact{os, arch, path, sha256}`, added to `PluginSigil` as `repeated PluginArtifact artifacts = 10` (only-add; fields 1, 2 and 6 are already reserved there). The Soul selects the row matching its own Soulprint facts (`os.family`, collapsed to `linux` for the Linux distro families, and `os.arch`) with the running binary's platform as the fallback (`Module.hostPlatform`, `soul/internal/coremod/module/source.go:315-331`), and verifies the fetched bytes against that row's digest; **no matching row is a closed refusal**. ★ **`binary_sha256 = 4` must become `reserved` the moment it stops being authoritative** ([ADR-012](adr/0012-keeper-soul-grpc.md) never-reuse). Keeping it populated as "one of the N" for compatibility is the trap to refuse: an old Soul would **silently install whichever row the Keeper happened to put there** — an artifact for the wrong platform, materialized under a valid signature, with no error to notice. Replacing one length-prefixed digest with a list also changes which fields the signed block covers, so the DST goes to **`soul-stack/sigil/v3`** and every existing grant is deleted, as at v1→v2 ([ADR-026 amendment](adr/0026-sigil.md#amendment-2026-09-04-nim-794-the-grant-carries-a-list-of-artifacts-and-the-bytes-stop-travelling-through-the-keeper)). | | **`SigilSnapshot`** | Keeper → Soul | **Full active set of permissions** plugins: `SigilSnapshot{repeated PluginSigil sigils}` ([ADR-026(h)](architecture.md), field 7). **ONLY source of truth** active set on the Soul side; **ReplaceAll** is applied (Soul replaces the entire set, not upsert) - Soul forgets the permission missing in the snapshot (near-instant revoke/retire without restarting). Empty `sigils[]` = no plugins allowed. **Only-add** to `FromKeeper.oneof payload`. | | **`SigilTrustAnchors`** | Keeper → Soul | **Full set of trust anchors** Sigil signature: `SigilTrustAnchors{repeated string pubkey_pem}` (SPKI PEM ed25519 keys, [ADR-026(h)](architecture.md), field 8). **ReplaceAll** is used: runtime delivery **completely replaces** bootstrap-anchors (replace, not merge), multi-anchor for continuous rotation of the signature key (any anchor from the set verifies the signature). **Only-add** to `FromKeeper.oneof payload`. | | **`PortentEvent`** | Soul → Keeper | Event beacon ([Vigil](#domain-entities), [ADR-030](adr/0030-vigil-oracle.md)): `PortentEvent{beacon_name, data (google.protobuf.Struct, [deprecated=true] from V5-1 - legacy payload, filled in parallel with typed payload during the one-release hand-off period), payload (oneof of 7 typed messages: FileChangedPortent / ServiceDownPortent / PortClosedPortent / DiskFullPortent / ProcessAbsentPortent / HttpUnhealthyPortent / InotifyPortent + custom Struct for plugin-beacon, V5-1+V5-3, [ADR-030 amendment 2026-05-26](adr/0030-vigil-oracle.md#amendment-2026-05-26-s5-closure)), collected_at (Soul-side), sid (ECHO; authority - mTLS peer cert, [ADR-012(i)](adr/0012-keeper-soul-grpc.md)), optional dedup_key, optional severity}`. **Edge-triggered** (for state change). For Oracle - untrusted input. **Only-add** to `FromSoul.oneof payload` (field 7), file `proto/keeper/v1/beacon.proto`; There is no new RPC. Hard-cut `data` - S5-final (post-1-release, parity with push S7). | | **`VigilSnapshot`** | Keeper → Soul | **Full Vigil active set** for this SID ([ADR-030](adr/0030-vigil-oracle.md), field 9): `VigilSnapshot{repeated VigilDef vigils}`, where `VigilDef{name, interval (duration convention), check (core-beacon address, e.g. "core.beacon.file_changed"), params (google.protobuf.Struct)}`. **ReplaceAll** is applied (as `SigilSnapshot` / `SigilTrustAnchors`): Soul-scheduler replaces the entire local set, the missing Vigil stops. Empty `vigils[]` = no active checks. **Only-add** to `FromKeeper.oneof payload`, new file `proto/keeper/v1/beacon.proto`; There is no new RPC. | | **`WardRoster`** | Soul → Keeper | **Snapshot of Soul-driven apply runs** (Soul-reconcile, [ADR-027(g)](adr/0027-apply-work-queue.md), S6, field 8): `WardRoster{repeated ActiveApply active}`, `ActiveApply{apply_id, attempt, (status reserved, not read in MVP)}`. Places on [Ward](#domain-entities): declares which Ward missions are still in flight. Sent **immediately after `Hello` and BEFORE the first app message** on each (re)connect, **ReplaceAll** is applied (as `SigilSnapshot`/`VigilSnapshot`). Keeper, by the roster, terminals the orphaned `dispatched` SID lines to the status `orphaned` (closes the hole "Keeper and Soul are both dead after recoil": reclaim is narrowed to `claimed`, we do not do Reaper dispatched-timeout). Empty `active[]` = "nothing is running" (Soul process restart) → all `dispatched` SID lines are orphaned. The old Soul without `WardRoster` does not send it → sweep does not start (fail-safe hangup). **Only-add** to `FromSoul.oneof payload`. | | **`DriftReport`** ⚠️ WITHDRAWN | — | **Removed 2026-08-05 (NIM-446)** together with the Scry circuit. It was never a Keeper↔Soul proto message in any case — a keeper-internal aggregate and API/MCP response shape. Kept in this table so the name is not reused. | | **`ConsoleOpen`** / **`ConsoleStdin`** / **`ConsoleResize`** / **`ConsoleClose`** | Keeper → Soul | **Control plane of an interactive console (PTY) session** — a LIVING pty on the host, not an Errand (Errand = one module call → one final blob ≤64 KiB, [ADR-033](adr/0033-errand.md)). `ConsoleOpen{session_id, target_sid, cols, rows, shell?}` spawns a shell under a pty; `ConsoleStdin{session_id, data}` writes raw keystrokes (incl. `^C`/`^D`) to the pty master; `ConsoleResize{session_id, cols, rows}` → `TIOCSWINSZ` → `SIGWINCH`; `ConsoleClose{session_id, reason}` ends the session. `session_id` — ULID minted by Keeper, unique **per stream** (does not survive a reconnect); `target_sid` — echo for logs, authority is the mTLS peer cert ([ADR-012(i)](adr/0012-keeper-soul-grpc.md)). `shell` MUST be an absolute path (no PATH resolution — no shadowed-binary console). **Only-add** to `FromKeeper.oneof payload` (**fields 13-16**), new file `proto/keeper/v1/console.proto`. ★ Since [NIM-188](adr/0074-interactive-console-pty.md) the last three ride the session's **dedicated `ConsoleStream` RPC** when it has one, wrapped in `ConsoleToSoul`; `ConsoleOpen` always stays on EventStream — it is what makes the Soul dial. Soul-side spec — [soul/console.md](soul/console.md). | | **`ConsoleOpened`** / **`ConsoleChunk`** / **`ConsoleExit`** | Soul → Keeper | **Data plane and terminal of a console session.** `ConsoleOpened{session_id, pid, shell}` — the pty is live (exactly once, before any chunk). `ConsoleChunk{session_id, stream, data, seq, dropped_bytes}` — terminal output; a pty MERGES stdout and stderr onto one fd, so `stream` is always `STDOUT` in practice (`ConsoleStream` enum is only-add room for a future non-pty exec mode — consumers must NOT wait for `STDERR`). `seq` — gap-free per-session counter from 1; `dropped_bytes` — output discarded by **Soul-side flow control** before this chunk. ★ Only on the EventStream carrier, where the console shares one write mutex with the apply cycle and a flood must be dropped-with-counter rather than stall `TaskEvent`/`RunResult`; on the dedicated `ConsoleStream` ([NIM-188](adr/0074-interactive-console-pty.md)) there is no shared mutex, the queue throttles the pty instead of discarding, and the field reads **0**. `ConsoleExit{session_id, exit_code, reason, error_message}` — the single terminal per session (`ConsoleExitReason` enum: `PROCESS_EXITED` / `CLOSED_BY_KEEPER` / `OPEN_FAILED` / `SOUL_SHUTDOWN` / `LIMIT_EXCEEDED`); a refused session sends ONLY `ConsoleExit`, so Keeper always receives exactly one terminal per `session_id` it minted. **Only-add** to `FromSoul.oneof payload` (**fields 11-13**); since NIM-188 they ride the session's dedicated stream wrapped in `ConsoleFromSoul` when it has one. Invariant: a console **never outlives its EventStream session** (kill-on-disconnect) — nor, now, the console stream that carries it. | | **`ConsoleAttach`** / **`ConsoleAttached`** / **`ConsoleFromSoul`** / **`ConsoleToSoul`** | both | **The envelope of the dedicated console transport** ([ADR-0074 amendment 2026-07-27](adr/0074-interactive-console-pty.md), NIM-188): `rpc ConsoleStream(stream ConsoleFromSoul) returns (stream ConsoleToSoul)` on `service Keeper`, one bidi stream per console session, dialed by Soul on the SAME mTLS listener and connection as EventStream (the [`FetchModule`](adr/0065-core-module-installed.md) precedent). `ConsoleAttach{session_id}` is the Soul's mandatory FIRST frame — it names the session the stream belongs to; `ConsoleAttached{session_id}` is Keeper's ack, and it is load-bearing: without a positive signal a Soul cannot tell "this Keeper has no such RPC" from "the answer has not arrived", and would have to guess its carrier. `ConsoleFromSoul` wraps `ConsoleAttach`/`ConsoleOpened`/`ConsoleChunk`/`ConsoleExit`; `ConsoleToSoul` wraps `ConsoleStdin`/`ConsoleResize`/`ConsoleClose`/`ConsoleAttached` — `ConsoleOpen` is deliberately absent (only a client may open a stream). ★ A `session_id` is a **route, not a credential**: Keeper hands a stream the session's frames only when the SID that dialed owns the session (mTLS peer cert, [ADR-012(i)](adr/0012-keeper-soul-grpc.md)), and attaching creates nothing — `soul.console` is checked at the operator's socket. | | **`PluginFetchRequest`** | Soul → Keeper (request RPC `FetchModule`) | Request for bytes of the SoulModule plugin binary to the content address - request **third RPC `service Keeper`**: server-streaming **`FetchModule(PluginFetchRequest) returns (stream PluginChunk)`** ([ADR-065](adr/0065-core-module-installed.md); only-add [ADR-012(c)](adr/0012-keeper-soul-grpc.md)). Same mTLS-listener as EventStream; bytes travel in a separate HTTP/2 stream (NOT via EventStream). **Content-addressed:** Keeper provides ONLY bytes whose sha256 is in the active Sigil permission `kind: soul_module`; authorization - mTLS peer-cert (SID from SAN). The exact composition of the fields is slice S1. | | **`PluginChunk`** | Keeper → Soul (server-stream response `FetchModule`) | Chunk of plugin binary bytes. On Soul, before atomic rename - full Sigil-verify the collected bytes: sha256 == `binary_sha256` permission + signature + `manifest_sha256` ([ADR-065](adr/0065-core-module-installed.md), review `shared/pluginhost`). | `FromSoul` and `FromKeeper` are wrapper-messages with `oneof payload` listing all messages by direction (see ADR-012(a)). `PluginFetchRequest`/`PluginChunk` - outside of EventStream-oneof (separate RPC messages `FetchModule`). ### Soul-capabilities Canonical string values `Hello.capabilities` ([repeated string], [ADR-056](adr/0056-staged-render-passage.md) §S5 forward-compat): Soul announces what it supports when connecting, Keeper persists the set next to presence (Redis, lifecycle = SID-lease) and checks it BEFORE dispatch. Empty set = old Soul without announcement (forward-compat [ADR-012(c)](adr/0012-keeper-soul-grpc.md) only-add) → does not support any features (fail-closed). Constants - `shared/config/soul_capability.go` (one line on the keeper and soul side). [ADR-0076](adr/0076-engine-compat-window.md) fixes this set as the **Soul-side axis of engine compatibility** (the Keeper-side axis is the declared [compat window](#engine-compat-window-compat--effective-window-adr-0076)): the announcement generalizes beyond protocol features to the modules and Soul-side features a binary actually implements, so that a plan requiring something the host does not announce is rejected per-host **before** dispatch instead of being silently ignored. The axis stays capability-based rather than a numeric min-soul-version — a version number cannot express "this binary has module X" across a fleet with differing module sets; `soul_version` remains audit-only (persisted next to the set, field `ver` of the heartbeat Hash). **Protocol features** — one value per Keeper↔Soul message-level feature: | Value | Derived from | Meaning | |---|---|---| | **`passage`** | [Passage](#domain-entities) | Soul echoes `ApplyRequest.passage` to `TaskEvent`/`RunResult`, that is, it can participate in staged-render (N > 1 Passage, [ADR-056](adr/0056-staged-render-passage.md)). Soul without this sign under the staged scenario is rejected by the keeper BEFORE dispatch (`soul_passage_unsupported`, fail-closed): otherwise the barrier of the next Passage would be waiting for the terminal, which the old binary will not send. | | **`console`** | [`ConsoleOpen`](#messages-proto-keepersoul) | Soul understands the only-add `console_*` messages and can host an interactive pty session ([soul/console.md](soul/console.md)). Keeper checks it BEFORE minting a console session (fail-closed, same shape as `passage`): an old binary drops `ConsoleOpen` into the default branch of its recv-loop and never answers, which would leave the operator watching a dead terminal until an idle timeout. | | **`console_stream`** | [`ConsoleStream`](#messages-proto-keepersoul) RPC | Soul carries a console session on the dedicated `ConsoleStream` RPC rather than the EventStream `console_*` members ([ADR-0074 amendment 2026-07-27](adr/0074-interactive-console-pty.md), NIM-188). ★ **Not fail-closed, unlike every other value here** — it is an announcement, not a gate: a Soul without it is served over EventStream exactly as before, and a Soul with it still falls back when the Keeper it reached answers `Unimplemented`. Keeper uses it to know whether to expect an attach and to report which carrier a session ended up on. | **Soul-side DSL features** — the parts of a `RenderedTask` that Keeper only *threads through* and Soul is what enforces. A binary that ignores one runs the task anyway and reports the raw outcome, which is the silent-wrong-result [ADR-0076](adr/0076-engine-compat-window.md) closes: | Value | Derived from | Meaning | |---|---|---| | **`flow_control`** | [flow-control CEL](adr/0012-keeper-soul-grpc.md) (d) | Soul evaluates `when:` / `changed_when:` / `failed_when:` itself — Keeper passes them as CEL strings because they read `register.*`, known only during the run. | | **`retry`** | `retry:` ([destiny/tasks.md §9](destiny/tasks.md)) | Soul enforces the retry loop (count/delay) and its `until:` exit predicate. Required only for a real loop — `retry: {count: 1}` is one attempt, which a binary without the loop does anyway. | | **`dry_run`** | [ADR-031(b)/(c)](adr/0031-scry-drift.md) | Soul honors `ApplyRequest.dry_run` / `ErrandRequest.dry_run` by calling `SoulModule.Plan` instead of `Apply`. Required of a host before an Errand dry-run, where an unannounced binary would MUTATE it during an operation that promised a pure read. Since NIM-446 no Keeper path sets `ApplyRequest.dry_run`; the capability lives on for Errand and for the plugin contract. | **Modules** — one value per core module the binary carries, `module:
` (`module:core.pkg`, not `module:core.pkg.installed`: the registry key is the module, states are dispatched inside its implementation). Announced from the Soul-side registry (`soul/internal/coremod`), so it states what the binary actually serves rather than what the catalog describes. Keeper requires one per task in the rendered plan, attributed to the hosts that task targets. **Plugin modules are deliberately outside the axis**: `core.module.installed` can install one mid-run ([ADR-065](adr/0065-core-module-installed.md)), long after the announcement was made at connect time, so gating on one would reject a legitimate install-then-use scenario; that hole stays with param-level strictness (NIM-163). | Value | Role | |---|---| | **`soul_capability_unsupported`** (abort reason) | Fail-closed abort before dispatch when a target host did not announce a module or Soul-side feature the plan needs from it. Sibling of `soul_passage_unsupported` (same axis, narrower question) and of [`keeper_version_unsupported`](#engine-compat-window-compat--effective-window-adr-0076) (the other axis). The message names every host and what each is missing, so a fleet is upgraded in one round. | Set extension - a separate capability line (derived from an already fixed name) + line in this table; a new independent name - propose-and-wait. ### Augur: nested proto types and registries Subsystem names **Augur** ([ADR-025](adr/0025-augur.md), spec - [keeper/augur.md](keeper/augur.md)). **`source_type` enum** (column `omens.source_type`) — descriptive closed enum of the external system type: | Value | External system | Delegation (`delegate=true`) | |---|---|---| | **`vault`** | HashiCorp Vault (KV). | Mintable scoped/short-TTL/limited-use Vault token (`auth/token/create`). | | **`prometheus`** | Prometheus (live-query). | Scoped static pre-scoped read-key (not master-cred). | | **`elk`** | Elasticsearch/ELK (index-read). | Scoped static pre-scoped read-key (not master-cred). | The enum extension is propose-and-wait + PR in [keeper/augur.md](keeper/augur.md) and this file. **`AugurStatus` enum** (field `AugurReply.status`) — the outcome of the authorization/execution of an Augur request on Keeper: | Value | Meaning | |---|---| | **`AUGUR_STATUS_UNSPECIFIED`** (`0`) | Not specified. Soul interprets it as **DENIED** (default-deny): the absence of an explicit OK is a ban, not a "continue". | | **`AUGUR_STATUS_OK`** (`1`) | Access allowed, data / credential in `AugurReply.result`. | | **`AUGUR_STATUS_DENIED`** (`2`) | Access is denied by authorization (Omen not found / Soul not in Rite / query outside the allow-list). `error` may be the cause. | | **`AUGUR_STATUS_ERROR`** (`3`) | Execution failure on the Keeper/Omen side (external system unavailable, etc.). | **Proto-types `result` in `AugurReply`** (option `oneof`, selected by `delegate` / `source_type`): | Name | When | Meaning | |---|---|---| | **`inline_data`** | `delegate=false` (any `source_type`) | The value read by the Keeper and transmitted to the Soul through the Keeper (broker, MVP-1). | | **`ScopedVaultToken`** | `delegate=true` + `vault` | Ephemeral scoped Vault token (TTL/num_uses/policies from Rite); Soul reads Vault directly (MVP-2). | | **`ScopedStaticCred`** | `delegate=true` + `prometheus` / `elk` | Scoped read-only static cred (pre-scoped read-key); Soul makes a direct read-only request (MVP-2). | **Augur PG tables:** | Table | Role | |---|---| | **`omens`** | Registry of external systems (Omen): `name` PK (kebab), `source_type` enum, `endpoint`, `auth_ref` (always vault-ref - master-cred not in the database). Managed via OpenAPI/MCP. | | **`rites`** | Grant registry (Rite): `omen` FK `ON DELETE CASCADE`, [Subject](#domain-entities) columns `sid TEXT[]` / `service`+`incarnation` / `coven TEXT[]` / `trait_key`+`trait_value` (CHECK `rites_subject_one_of`, migration 113), `allow` JSONB (vault: paths/policies; prom: queries; elk: indices), `delegate` bool default `false`, `token_ttl` / `token_num_uses` (only for vault-delegate, NULL for prom/elk). | ## RBAC: role registry, membership and the term "FK" RBAC-storage entirely in Postgres ([ADR-028](adr/0028-rbac-storage.md#adr-028-rbac-storage--postgres), regulatory spec - [keeper/rbac.md](keeper/rbac.md)). The `rbac:` block in `keeper.yml` has been removed (hard-cut; `rbac:` → `unknown_key`). Archons - registry `operators` ([ADR-014](adr/0014-operator-identity.md)). **Registry fields `operators` (domains).** Two orthogonal operator string enum fields (DO NOT confuse): | Field | Domain | Semantics | |---|---|---| | **`auth_method`** | `{jwt, mtls, combined, ldap, oidc}` | **How ​​the operator logs in.** `jwt` (MVP), `mtls`/`combined` (post-MVP, only-add), `ldap`/`oidc` ([ADR-058(a)](adr/0058-operator-auth-ldap-oidc.md)). | | **`created_via`** | `{bootstrap, user, ldap, oidc, system}` | **Where the operator is from** ([ADR-058](adr/0058-operator-auth-ldap-oidc.md), [ADR-014 amend 2026-06-23](adr/0014-operator-identity.md)). TEXT NOT NULL DEFAULT `'user'`. `bootstrap` = `keeper init`; `user` = `POST /v1/operators`; `ldap`/`oidc` = federated auto-provision; `system` = seeded `archon-system`. The Bootstrap invariant "exactly one first Archon" is supported by partial-unique `WHERE created_via='bootstrap'` (and not by `created_by_aid IS NULL`); federated/system operators set `created_by_aid=NULL` legally. | ### RBAC PG tables | Table | Role | |---|---| | **`rbac_roles`** | Directory of roles. `name` PK (kebab-case, CHECK on format), `description`, `builtin` BOOL (protection from `role.delete` / `role.update`), `created_at`, `created_by_aid` FK→`operators(aid)` NULL-able, `default_scope` ([ADR-047](adr/0047-purview.md)), `parent_role` self-FK→`rbac_roles(name)` `ON DELETE RESTRICT` NULL-able (derived roles, [ADR-078](adr/0078-rbac-derived-roles.md); NULL = plain role). Seed role `cluster-admin` with `builtin=true` (E1). | | **`rbac_role_permissions`** | Role permissions. `role_name` FK→`rbac_roles(name)` `ON DELETE CASCADE`, `permission` TEXT (stored as a **RAW string**, parsed by `ParsePermission` - [rbac.md → Format permissions](keeper/rbac.md)), PK `(role_name, permission)`. | | **`rbac_role_operators`** | **Membership** "role ↔ operator". `role_name` FK→`rbac_roles(name)` `ON DELETE CASCADE`, `aid` FK→`operators(aid)`, `granted_at`, `granted_by_aid` FK→`operators(aid)` NULL-able, PK `(role_name, aid)`. What was missing before → cause BUG-1 ([ADR-028](adr/0028-rbac-storage.md#adr-028-rbac-storage--postgres)). | ### Permissions `role.*` RBAC management via OpenAPI/MCP ([ADR-028(e)](adr/0028-rbac-storage.md#adr-028-rbac-storage--postgres), directory - [rbac.md → Directory permissions](keeper/rbac.md)): `role.create` / `role.delete` / `role.list` / `role.update` + membership pair `role.grant-operator` / `role.revoke-operator`. The self-lockout invariant is extended to `role.delete` / `role.update` / `role.revoke-operator` (you cannot leave a cluster without an active AID with an effective `*`). ### Synod - group of archons ([ADR-049](adr/0049-synod.md)) [Synod](#domain-entities) - intermediate level **Archon → Synod → Roles** ([ADR-049](adr/0049-synod.md)): A group of archons banding roles. There is no scope of its own - scope lives on roles ([Purview](#purview---scope-resolver-adr-047)); flat (without nesting). Effective roles of an archon = direct `rbac_role_operators` ∪ roles through all his Synods. **Synod PG tables** (pattern `rbac_*` [ADR-028](adr/0028-rbac-storage.md#adr-028-rbac-storage--postgres)): | Table | Role | |---|---| | **`synods`** | Catalog of groups. `name` PK (kebab-case, CHECK for format - like `rbac_roles.name`), `description`, `builtin` BOOL (protection from `synod.delete`), `created_at`, `created_by_aid` FK→`operators(aid)` NULL-able. Symmetry `rbac_roles`. | | **`synod_operators`** | **Membership** "Synod ↔ archon." `synod_name` FK→`synods(name)` `ON DELETE CASCADE`, `aid` FK→`operators(aid)`, `added_at`, `added_by_aid` FK→`operators(aid)` NULL-able, PK `(synod_name, aid)`. Symmetry `rbac_role_operators`. | | **`synod_roles`** | **Bundle** "Synod ↔ role." `synod_name` FK→`synods(name)` `ON DELETE CASCADE`, `role_name` FK→`rbac_roles(name)` `ON DELETE CASCADE`, `granted_at`, `granted_by_aid` FK→`operators(aid)` NULL-able, PK `(synod_name, role_name)`. CASCADE on both sides. | **Permission-family `synod.*`** (NoSelector - cluster-level group management, not scoped; like `role.*` / `operator.*`): `synod.create` / `synod.update` (pattern `*.update` - changes ONLY `description`; `name` PK immutable, ADR-049 amend) / `synod.delete` / `synod.list` + membership pair `synod.add-operator` / `synod.remove-operator` + bundle pair `synod.grant-role` / `synod.revoke-role`. **Endpoints:** `/v1/synods` (`synod.create` POST / `synod.list` GET), `/v1/synods/{name}` (`synod.update` PATCH / `synod.delete` DELETE), `/v1/synods/{name}/operators` (`add-operator` POST / `remove-operator` DELETE `{aid}`), `/v1/synods/{name}/roles` (`grant-role` POST / `revoke-role` DELETE `{role_name}`). `synod.update` - without subset-check/self-lockout, builtin ALLOWED (description does not grant/take away rights). **Security-invariant** ([ADR-049 §f](adr/0049-synod.md)): `synod.grant-role` / `synod.add-operator` - under least-privilege subset (effective initiator rights = direct ∪ via Synod); The self-lockout invariant is extended to `synod.delete` / `synod.revoke-role` / `synod.remove-operator` (effective `*` can come via Synod). Resolve effective roles - addition to the snapshot assembly of the enforcer ([ADR-028(d)](adr/0028-rbac-storage.md#adr-028-rbac-storage--postgres)); The Purview matching layer does not change. ### Permission-family `herald.*` / `tiding.*` ([ADR-052](adr/0052-herald-notifications.md)) CRUD management of [Herald](#domain-entities)-channels and [Tiding](#domain-entities)-notification rules (catalog-driven, [ADR-042](adr/0042-backend-driven-ui.md) - UI fetches the permissions directory, not hardcoding): - **`herald.create`** / **`herald.read`** / **`herald.list`** / **`herald.update`** / **`herald.delete`** - endpoints `/v1/heralds` (POST `create` / GET `list`), `/v1/heralds/{id}` (GET `read` / **PUT** `update` (replace, like Push-Provider) / DELETE `delete`). - **`tiding.create`** / **`tiding.read`** / **`tiding.list`** / **`tiding.update`** / **`tiding.delete`** - endpoints `/v1/tidings` (POST `create` / GET `list`), `/v1/tidings/{id}` (GET `read` / **PUT** `update` (replace) / DELETE `delete`). NoSelector - cluster-level channel/rule management (like `role.*`/`synod.*`/`omen.*`). MCP-tools - `keeper.herald.create`/`read`/`list`/`update`/`delete` and `keeper.tiding.create`/`read`/`list`/`update`/`delete` (parity `keeper.augur.omen.*` / `keeper.oracle.decree.*`). The exact permissions directory is [keeper/rbac.md → Permissions directory](keeper/rbac.md) (docs-writer when implementing S4). ### Permission-family `provisioning.*` ([ADR-058(i)](adr/0058-operator-auth-ldap-oidc.md)) Runtime policy management of **CREATE** operator methods (`provisioning_allowed_methods` in `keeper_settings`). NoSelector - cluster-level policy (as `operator.*`/`role.*`): - **`provisioning.read`** — `GET /v1/provisioning-policy` (read-only, WITHOUT audit; `policy_set=false` → key not specified = default "everything is allowed"). - **`provisioning.update`** - `PUT /v1/provisioning-policy` (replace semantics; empty list → 422 anti-lockout; method outside `{user,ldap,oidc}` → 422; writes audit `provisioning.policy_changed`). Catalog - [keeper/rbac.md → Provisioning](keeper/rbac.md#provisioning-2--adr-058). ### Permission-family `setting.*` ([ADR-0073(i)](adr/0073-keeper-runtime-config-pg.md)) The [SettingsStore](#modules-and-subsystems-inside-keeper) overlay — the `cfg_*` rows of `keeper_settings` merged onto `keeper.yml`. NoSelector — settings are cluster-level (as `provisioning.*` / `role.*`). A family of its own rather than `service.*`, even though both live in `keeper_settings`: editing a cluster-wide runtime tunable is a different privilege from registering a Service, and an operator may be granted it without any other cluster-admin power. - **`setting.read`** — `GET /v1/settings` (the catalog: type, range bounds, default, effective value and `source ∈ {default, file, pg}` per key; read-only, WITHOUT audit). - **`setting.update`** — `PUT /v1/settings/{key}` (override a key cluster-wide; unknown key → 404, unparsable/out-of-range value → 422 with the row unwritten; writes audit `setting.updated`). - **`setting.delete`** — `DELETE /v1/settings/{key}` (drop the override so the file value or built-in default is back in effect; no override → 404; writes audit `setting.deleted`). Catalog - [keeper/rbac.md → Settings](keeper/rbac.md#settings--adr-0073). ### Removed permissions `incarnation.update-hosts` / `incarnation.update` Both named the editing of declared `spec.hosts[]` (`PATCH /v1/incarnations/{id}/hosts`) — `incarnation.update` as a deprecated alias canonicalized on snapshot load. The field, the endpoint and both names are **gone** ([ADR-044 amendment 2026-07-30](adr/0044-choir.md#amendment-2026-07-30-nim-330-spechosts-is-removed-voice-is-the-only-source-of-a-declared-role), NIM-330); the permission catalog is a closed enum, so migration `109` deletes the two strings out of `rbac_role_permissions` — an unparseable grant would abort the enforcer snapshot load, not merely fail a check. A declared role is now a Choir Voice attribute, guarded by `choir.add-voice` / `choir.remove-voice`. The alias-canonicalization machinery went with them: `ParsePermission` no longer rewrites any name, and a rename is done the way migration `095` did it — remove the old name and migrate the rows. ### Permission `incarnation.rerun-last` Permission for atomic rerun of the **last crashed** scenario from `error_locked` - **`incarnation.rerun-last`** (REST `POST /v1/incarnations/{id}/rerun-last`, MCP-tool `keeper.incarnation.rerun-last`, operationId `rerunLastIncarnation`). Action - kebab (`rerun-last`), grammar `.` ([rbac.md → permissions directory, Incarnation](keeper/rbac.md)). Under one `FOR UPDATE` removes `error_locked` (state is not touched, snapshot in `state_history`) and transfers the incarnation `error_locked → applying` bypassing `ready`, restarting **the last fallen scenario** - bootstrap (`create`/...) on the create path OR day-2 operation (`add_user`/…) - with the saved input of the failed run (create-path - `incarnation.spec.input`; day-2-path - `apply_runs.recipe.input`; unavailable → 409 fail-closed). Mandatory `reason` + confirm in the UI. Works only from `error_locked`; for other cases - the usual `unlock` + manual run. Audit event - `incarnation.rerun_last` (does NOT reuse `incarnation.unlocked`). See [Atomicity and `error_locked`](architecture.md#atomicity-and-error_locked). ### Permission `incarnation.traits-set` Permission for holistic replacement of operator-set trait-marks of incarnation (`incarnation.traits` jsonb - source of truth, [ADR-060](adr/0060-traits.md) R1 slice a) - **`incarnation.traits-set`** (REST `PUT /v1/incarnations/{id}/traits`, MCP-tool `keeper.incarnation.traits-set`). Action - kebab (`traits-set`), grammar `.` (pattern `soul.traits-assign`). Scope is the same as the other incarnation mutations (`coven=`/`service=`/`incarnation=` by path-`name`); trait-**key** NOT scope-dimension. Audit event - **`incarnation.traits_changed`** (KEYS only, not values). **Per-host counterpart - `soul.traits-assign`** (`POST /v1/souls/traits` / `keeper.soul.traits-assign`), which is **first-class again since [ADR-080](adr/0080-label-inheritance-union.md)** (NIM-121): the two permissions label two different things (an incarnation vs one host) and neither overwrites the other, see [Trait in glossary](#domain-entities). Field `traits` in `IncarnationCreateRequest` (`POST /v1/incarnations`) is an operator-set of labels on create (same source of truth). ### Permission-family `.label-set` ([ADR-0085](adr/0085-entity-id-and-label.md)) Permission for replacing the **display caption** of one registry row - **`.label-set`**, one name per registry: `incarnation.label-set` / `service.label-set` / `provider.label-set` / `profile.label-set` / `push-provider.label-set` / `omen.label-set` / `herald.label-set` / `tiding.label-set` / `vigil.label-set` / `decree.label-set` (REST `PUT /v1//{name}/label`, MCP `keeper..label-set`). Action - kebab `-`, grammar `.`, the pattern set by [`incarnation.traits-set`](#permission-incarnationtraits-set), which grants the same kind of thing: a mutable operator-set attribute on a row. **One name per registry, not one shared name.** The catalog grammar is `.` and `registry` is not an entity of this dictionary; and a role that may caption incarnations has no business captioning Heralds. **Deliberately NOT `.update`.** Half of these registries have no update at all - `provider` / `profile` / `omen` / `vigil` / `decree` are immutable by decision, and `incarnation.update` was DELETED by migration `109` ([NIM-330](#removed-permissions-incarnationupdate-hosts--incarnationupdate)) - and where an update does exist it REPLACES the whole row, so granting a caption edit through it would have granted a rewrite of a Herald's `secret_ref`. **This is the narrowest write in the catalog**, and that is a property of the field rather than of the permission: a caption participates in **nothing derived** - no Vault path, no RBAC scope, no snapshot directory, no `incarnation.<...>` in CEL ([ADR-0085](adr/0085-entity-id-and-label.md) THE INVARIANT). Holding it lets an operator move a word on a screen and nothing else. Consequently `incarnation.label-set` carries only gate (a) - the incarnation scope - and no second, pair-level gate of the kind `incarnation.traits-set` needs, because a caption is in no scope dimension and so hands nobody visibility. Audit events - **`.label_changed`**, payload `{id, old_label, new_label}` (see [Audit-events](#audit-events)). Selector - `NoSelector` for the registry families that are already NoSelector; `incarnation.label-set` carries the same scope as every other incarnation mutation. Directory - [keeper/rbac.md → Permissions directory](keeper/rbac.md). ### Permission `incarnation.view-secrets` Permission for revealing (reveal) the plaintext value of an incarnation secret - declared since [ADR-083](adr/0083-declared-secret-state-fields.md) as a `state_schema` field with `type: secret`, and before it by the service's `revealable_secrets` block ([ADR-070](adr/0070-secret-reveal-path.md); the right, the endpoints and the audit event are unchanged by that move, only the way the path is obtained) - **`incarnation.view-secrets`** (REST `POST /v1/incarnations/{id}/secrets/reveal` + discovery `GET /v1/incarnations/{id}/secrets/revealable`; MCP-tool **no** - REST-only, like `form-prefill`). Action - kebab (`view-secrets`), grammar `.` (pattern `incarnation.traits-set`). Scope is the same as for incarnation mutations (`coven=`/`service=`/`incarnation=` by path-`name`, parity `incarnation.traits-set`); outside the scope - **fail-closed 404** (parity Get: we do not burn the existence of someone else's incarnation). **It is strictly more privileged than `incarnation.get`** - to read a disguised incarnation ≠ reveal its secrets (a separate right, not a facet of `get`; authorized disclosure → DTO bypasses `MaskSecrets`). Audit event - **`incarnation.secret_revealed`** (fact WITHOUT value). Name - **DevOps term** (RBAC verb `view-secrets`, rule "small = DevOps"; the thematic name does not fit - the access limit is over an existing entity). Directory - [rbac.md → Directory permissions](keeper/rbac.md). ### Permission `soul.console` Permission for opening an interactive console (PTY) on a host over the WebSocket `GET /v1/console` ([ADR-0074](adr/0074-interactive-console-pty.md), [keeper/console.md](keeper/console.md)) - **`soul.console`**. Action - a plain word, grammar `.`; resource is `soul` because the console acts on a host, not on a registry (pattern `soul.issue-token` / `soul.ssh-target-update`). **Strictly stronger than [`errand.run`](#domain-entities) and independent in BOTH directions**: an Errand is one named module call with declared params and a capped final blob, checkable against a module allow-list before it runs; a console is an arbitrary interactive shell running as the Soul daemon's user (typically root), so neither right may imply the other. Selectors - the existing `host=` / `coven=`, bare = unrestricted; **no new selector keys** (RBAC keys stay `{service, coven, incarnation, host}`, narrowing beyond them goes through Purview dimensions, [ADR-047 §S4](adr/0047-purview.md)). The `coven=` form narrows **every** surface of the right (NIM-650): playback resolves scope through `ResolvePurview`, and the per-`open`-frame check, `keeper.soul.run-command` and the verb-shell half of the Errand path resolve `host=` plus one context per Coven label the host carries, admitting if any one passes - the same shape NIM-588 gave the three per-host Soul mutations, and the same primitive ([`soul.HostContextsBySID`](../keeper/internal/soul/rbac_scope.go)). Until NIM-650 the live surfaces built a host-only context and a missing dimension fails closed, so a `coven=`-scoped grant denied every live session it named while narrowing playback correctly. **Checked twice**, because the target host is not in the URL: an existence-gate before the WebSocket upgrade (403, no socket) + a scope-aware `host=` check per `open` frame (a session-scoped error frame, the socket's other panes survive) - the upgrade gate must NOT be a scope-aware check with a nil context, which would fail-closed on exactly the `host=`-scoped roles ([ADR-047 §g G1](adr/0047-purview.md)). Audit events - **`console.opened`** / **`console.closed`** (the fact of a session, recorded independently of session recording) and **`console.command`** for the non-interactive form. **MCP tool - `keeper.soul.run-command`** ([ADR-0074 amendment](adr/0074-interactive-console-pty.md), NIM-147): the same right gates the one-shot command line, because dropping the tty removes the echo and not the privilege. It is the one `keeper.soul.*` tool NOT paired with a `soul.` permission, and the one with no REST twin. Catalog - [keeper/rbac.md → Soul](keeper/rbac.md). ### Permission `soul.forget` Permission for erasing a host from the registry over `DELETE /v1/souls/{sid}` (MCP tool `keeper.soul.forget`, [keeper/operator-api/souls.md](keeper/operator-api/souls.md), NIM-386) - **`soul.forget`**. Action - a plain word, grammar `.`; resource is `soul` because the act is on one host (pattern `soul.console` / `soul.issue-token`). Selectors - **`host=` from the path and `coven=`**; bare = unrestricted; **no new selector keys**. The route's selector (`SoulSIDScopeSelector`) reads the host's `souls.coven` list and offers the enforcer one RBAC context per coven plus the host on its own, and the gate admits if any of them passes - so `soul.forget on coven=web` restricts the call to hosts carrying `web` rather than refusing all of them. The fan-out is required by [ADR-008](adr/0008-coven-stable-tags.md): a host holds a *list* of covens, and a single context could only have asked about one. Both forms a coven takes narrow identically - the `on coven=…` suffix and a bare `soul.forget` inside a role whose `default_scope` is a coven (a role's `default_scope` is inherited by its bare permissions, and the two meet at the same scope expression before the check). If the coven cannot be read - unknown host, database down - the context keeps the host alone, so coven-scoped grants fail closed and an `on host=` grant is untouched. The other two routes on this selector (`soul.issue-token`, `soul.ssh-target-update`) behave the same way, on REST and on MCP alike ([ADR-004](adr/0004-binaries.md) makes both surfaces primary). Until NIM-588 the context held `host` and nothing else, and a dimension absent from the context fails closed ([keeper/rbac.md](keeper/rbac.md)), so every coven-narrowed grant on the three denied every call. `errand.run` and the live `soul.console` were moved onto this same shape by NIM-650 - the exec route shares this very selector, and the in-handler gates build the identical set through [`soul.HostContextsBySID`](../keeper/internal/soul/rbac_scope.go). **`soul.delete` was deliberately NOT the name**: the operation is not a row delete but a release - it revokes the host's seeds, burns its unredeemed bootstrap tokens, tears the live EventStream down across the cluster and purges the per-SID Redis keys, and it reports what the cascade took rather than answering a bare 204. Naming it `delete` would invite exactly the reading the operation is built to refuse, that erasing the record is the same as freeing what the record held. **ONE verb, no state gate**: forgetting is legal in every `souls.status`, including a connected host - a state gate would leave the original complaint (16 permanently `disconnected` rows collectable only by hand-SQL) unanswered whenever the state was wrong. The safety property is not "you may only forget dead hosts" but "a forgotten host cannot come back": seed auth is an allowlist over `soul_seeds.fingerprint`, the seed rows hang off `souls.sid` ON DELETE CASCADE, so the allowlist entry goes with the row and the next connect fails closed. **Strictly stronger than [`soul.console`](#permission-soulconsole) and independent of it in both directions** - a console runs commands on a host that stays registered, a forget removes the host and grants nothing on it. Audit event - **`soul.forgotten`**. Error type - `teardown-unavailable` (503, see [Error codes](#error-codes)). Catalog - [keeper/rbac.md → Soul](keeper/rbac.md). ### Purview - scope resolver ([ADR-047](adr/0047-purview.md)) | Name | Type | Meaning | |---|---|---| | **Purview** | Entity (result type) | Resolver of the allowed scope operator: `ResolveScope(aid, resource, action) → Purview`. Generalizes `Enforcer.CovenScope` (`(covens, unrestricted)`) to a **boolean scope** over dimensions `coven`/`service`/`incarnation`/`host`/`trait` + flags `unrestricted`/`deny` (NIM-128 — the former `regexes`/`soulprint`/`state` dimensions removed, [ADR-047 amendment](adr/0047-purview.md#amendment-2026-07-18-nim-128--boolean-scope-grammar)). Upper limit of operator visibility/targeting (souls-list, incarnations-list, target Cadence/Vigil/Voyage). NOT to be confused with `Ward` (claim apply-tasks, [ADR-027](adr/0027-apply-work-queue.md)) - another subsystem. | | **`Purview.Deny`** | Terminal flag `Purview` | "Access denied by scope" - terminal, cuts off before collecting measurements. First real use (ADR-047 §g G1): `ResolvePurview` for the **revoked operator** returns `Purview{Deny:true}` (mirror of the revoked branch `Enforcer.Check`). Single revoked-aware resolution point for all read-souls paths: `HoldsAction`→`false`→403, `soulpurview.Resolve`→Empty→404, `InScope`→false. Before G1, the field was a blank (always false). NOT to be confused with `default_policy: deny` (general base enforcer policy) - `Deny` is a per-(aid,resource,action)-result. | | **`default_scope`** | Field `rbac_roles` | **Boolean scope** expression inherited by all role permissions (NIM-128). Per-permission `on ` overrides. Empty explicitly entered dimension → default-deny; `*`-permission and bare-permission (without default_scope and without per-perm selector) are exceptions (allow), see [ADR-047(b)](adr/0047-purview.md). **On a role with `parent_role` the same field is the attenuating DELTA**, not the absolute scope ([ADR-078](adr/0078-rbac-derived-roles.md)) — see the row below. | | **`parent_role`** | Field `rbac_roles` | The role a **derived role** inherits from ([ADR-078](adr/0078-rbac-derived-roles.md), self-FK `rbac_roles(name)` `ON DELETE RESTRICT`, [migration 102](../keeper/migrations/102_rbac_roles_parent_role.up.sql)). NULL = a **plain** role (what every role is today). The name is deliberately **plain, not metaphorical** — a derived role is a role, **NOT a new dictionary entity**: same table, same `role.*` family, same endpoints; the UI label is "derived role / inherits from X". Effective scope = parent's effective scope `AND` the child's own `default_scope` (the delta); effective permissions = the child's own rows `∩` the parent's effective set (**variant B** — a child may hold fewer permissions, not just a narrower area). A child can never exceed its parent (**attenuation**); narrowing the parent cascades down at the next snapshot build. Chain capped at **4 roles**, cycles and self-parent refused by the schema. | **Purview scope conditions (NIM-128 boolean grammar).** The scope after `on` is a **boolean expression** over five condition types joined with `AND`/`OR` and parenthesised groups ([rbac.md → Selector grammar](keeper/rbac.md), [ADR-047 amendment](adr/0047-purview.md#amendment-2026-07-18-nim-128--boolean-scope-grammar)). The former pattern-matching types `regex`/`soulprint`/`state` are **removed** ([migration `100_rbac_drop_pattern_selectors`](../keeper/migrations/100_rbac_drop_pattern_selectors.up.sql)): | Condition | Meaning | |---|---| | `coven` / `service` / `incarnation` / `host` (`=v` / `in (a,b)`) | Exact / OR-list match on the named dimension (permission grammar, unchanged base keys). | | **`host matches `** | Match SID/hostname with a **glob** (`*`, `?`, full anchored) — replaces the removed `regex`. Compiled internally to an anchored RE2 (no backtracking, length ≤256 vs ReDoS) and SQL `LIKE` for pushdown; RE2 syntax not exposed. | | **`trait.=v`** | Exact scalar-equality against the resource's traits (dot-notation; realises [ADR-060](adr/0060-traits.md) p. 7). On an **incarnation** that is `incarnation.traits[key]`; on a **host** it is `souls.traits[key]` - what an operator attached to that host, with nothing added by membership ([NIM-281](adr/0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited)). Replaces the exploratory `trait=key:value`. | ### Enforcer methods and middleware Authorization surface in the code: enforcer methods (`keeper/internal/rbac`) and middleware wrappers (`keeper/internal/api/middleware`). `Check` / `ResolvePurview` / `RequirePermission` existed previously; `HoldsAction` / `RequireAction` introduced ADR-047 §g G1 for read visibility. | Name | Layer | Meaning | |---|---|---| | **`Check`** | enforcer-method | Scope-aware check `Check(aid, resource, action, context) error`: "whether permission is applicable in this scope context." The context comes from path/body. For scoped-permission with an empty context it gives a false deny (the key selector is not in the nil context) - therefore it is NOT suitable as a read endpoint gate. Remains a **mutation** gate (scope is known from path/body). | | **`ResolvePurview`** | enforcer-method | Resolve [Purview](#purview---scope-resolver-adr-047) operator for (resource, action). With G1 **revoked-aware**: revoked-AID → `Purview{Deny:true}` (single point for all read-souls paths). | | **`HoldsAction`** | enforcer-method | Existence-check `HoldsAction(aid, resource, action) bool`: "whether the statement holds the action IN PRINCIPLE, in any scope, ignoring the context selector." Implemented on top of `ResolvePurview`: `true` if `Unrestricted` OR any dimension (coven/service/incarnation/host/trait) AND NOT `Deny` is populated. Exists to allow a scoped operator to access its own list without false deny scope-aware `Check`; narrowing by scope - further in the handler. | | **`RequirePermission`** | middleware factory | Scope-aware-gate on top of `Check`: extracts the scope context (`SelectorExtractor` from path/body) and cuts the operator whose context does not match the selector. Gate **mutations**. | | **`RequireAction`** | middleware factory | Existence-gate over `HoldsAction` (ADR-047 §g G1): allows any action holder, scope-narrowing makes handler. Gate **read-visibility** souls (`GET /v1/souls`, `/{sid}`, `/{sid}/soulprint`, `/{sid}/history`), where the scope-context at the gate stage is not yet known (resolved from database lines). | ### Redis pub/sub-channels Keeper↔Keeper HA cluster internal cross-instance coordination wire identifiers ([ADR-006](adr/0006-cache-redis.md)) are NOT a custom surface. All carry a JSON-envelope with `origin_kid` (KID of the publishing instance) and **self-filter by KID** (the node ignores its own publishing echo). Operator - visible operational summary of the same keys/channels - [operations/infra.md → What lies in Redis](operations/infra.md). | Channel | Role | |---|---| | **`events:shard:`** | **Cluster-routing apply/run events between Keeper instances** (role (c) [ADR-006](adr/0006-cache-redis.md), `applybus`). Carries `TaskEvent` / `RunResult` / `ErrandResult` runs: SSE subscriber `GET /mcp/events?apply_id=X` to Keeper-A receives run events whose Soul is connected to Keeper-B. Channel **sharded**: fixed set `events:shard:`, where `n = fnv32a(apply_id) % 256` (K=256 shards), Redis-bridge rises per-shard, forward-loop filters by `envelope.apply_id`. Replaces the previous per-applyID channel `apply:` (was limited to `maxclients` with a large number of Souls - a separate subscription for each id). Prefix `events` (not `apply`) - the channel carries both `apply.*` and `errand.*`. Envelope: `origin_kid` / `kind` / `apply_id` / `at` / `payload`. Implementation - `keeper/internal/redis/applybus.go` (`ApplyBusChannel`/`ApplyBusShardIndex`/`ApplyBusShardCount`) + `keeper/internal/applybus/bus.go` ([ADR-006(c.1)](adr/0006-cache-redis.md)). | | **`rbac:invalidate`** | The "enforcer snapshot is out of date" signal. Mutation of the role / permissions / membership on any node publishes an envelope `{origin_kid, at}` → all nodes re-read the snapshot from the database. Best-effort + TTL-poll fallback ([rbac.md → As enforcer resolve](keeper/rbac.md), [ADR-028(d)](adr/0028-rbac-storage.md#adr-028-rbac-storage--postgres)). | | **`service:invalidate`** | Signal "the Service-registry / cluster-settings snapshot is stale" ([ADR-029](adr/0029-service-registry.md), S2; `keeper/internal/redis/serviceinvalidate.go`). A `service_registry` CRUD or a `keeper_settings` `SetSetting` on any node publishes `{origin_kid, at}` → the other nodes re-read the snapshot from Postgres (`serviceregistry.Holder.WatchInvalidations`). Best-effort + TTL-poll fallback (`DefaultRefreshInterval` 10s — pub/sub has no persistence, it only shortens the typical delay to milliseconds). **Also carries the [SettingsStore](#modules-and-subsystems-inside-keeper) `cfg_*` overlay** ([ADR-0073](adr/0073-keeper-runtime-config-pg.md)) — no separate channel; the overlay re-merges on the event and swaps **only on a real difference** (idempotence guard), so an unrelated service-CRUD wake-up is a no-op. | | **`herald:invalidate`** | Signal "snapshot of enabled [Tiding](#domain-entities) notification-dispatcher rules is out of date" ([ADR-052](adr/0052-herald-notifications.md), S4). CRUD [Herald](#domain-entities)/[Tiding](#domain-entities) on any node publishes the envelope `{name, at}` → each node pulls its `Dispatcher.InvalidateRules` (the next match re-reads the enabled snapshot from the PG). Best-effort + TTL-poll fallback (`DefaultRuleCacheTTL=15s`). Style `:` as `rbac:invalidate`. | ### The term "FK" - disambiguation Since [ADR-028](adr/0028-rbac-storage.md#adr-028-rbac-storage--postgres), the term "FK" in RBAC means **real PG foreign key**: `created_by_aid` / `changed_by_aid`, `rbac_role_operators.aid`, `granted_by_aid` - all refer to `operators(aid)`. The former metaphorical "FK" of the YAML list `roles[].operators` ([ADR-014](adr/0014-operator-identity.md), before ADR-028) is **membership** (the "role ↔ operator" binding), now materialized by the line `rbac_role_operators`, rather than a link in the file. In new texts: "membership/binding" = string `rbac_role_operators`; "FK" = real PG-foreign-key. ## Audit-events Audit-event is a structured record of an action in Keeper's audit-pipeline. General normalization of the pipeline (storage = Postgres table `audit_log`, schema, write-path, retention via Reaper rule `purge_audit_old`, OTel dual-write) - **[ADR-022](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)**, [keeper/storage.md → Table `audit_log`](keeper/storage.md), block [`keeper.yml → audit`](keeper/config.md#audit). **Name Convention - `.`** (lowercase, dot-separated ASCII; `` - `[a-z][a-z0-9_]*`; `` - `[a-z][a-z0-9-]*`, allowed `_` for compound verbs and `-` for kebab-compounds: `reload_succeeded`, `access_denied`, `permissions-updated`, `operator-granted`, `kv-read`). Similar to RBAC permissions ([keeper/rbac.md → Format permissions](keeper/rbac.md)) - but separate namespaces (permission `incarnation.read` and audit-event `incarnation.created` do not intersect). **Directory open**: new names are added by regular PR to this section when normalizing the corresponding write-path subsystems (see write-path table in [ADR-022(g)](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)). The full list of names in this document **is not standardized** - it is filled in upon implementation. After the first mention of a name in a normative formulation, it is stable—renaming requires a backward-compat plan. **`.label_changed` (10 names, [ADR-0085](adr/0085-entity-id-and-label.md) / NIM-728)** - an Archon replaced the display caption of a registry row: `incarnation.label_changed` / `service.label_changed` / `provider.label_changed` / `profile.label_changed` / `push-provider.label_changed` / `omen.label_changed` / `herald.label_changed` / `tiding.label_changed` / `vigil.label_changed` / `decree.label_changed`. `source: api`/`mcp`, permission [`.label-set`](#permission-family-resourcelabel-set). Payload `{id, old_label, new_label}` ([ADR-0085](adr/0085-entity-id-and-label.md) / NIM-729 renamed the identifier key from `name`) - the identifier that was ADDRESSED and the caption as it NOW reads, explicitly `null` when cleared (an omitted key would make "cleared" and "written before the field existed" one record). Only the new value is carried, matching `synod.updated`, the one other free-text mutation. `id` is the identifier and is NOT what changed - there is no rename operation anywhere - and the caption participates in nothing derived, so one of these events can never be the cause of a secret, a permission or a run behaving differently afterwards. One type per registry, not one shared type with a `kind`: every other event here is prefixed by its entity, and an audit filter over one entity has to keep working the same way for all of them. **Where the machine-readable list lives**: the authoritative set is the `EventType` const block in `shared/audit/event_types.go`. It is published to clients as the `enum` of `AuditEvent.type` in [`docs/keeper/openapi.yaml`](keeper/openapi.yaml), derived from those declarations by `make gen-audit-catalog` (see `shared/audit/event_types_gen_test.go`) — a name added to the code and not regenerated fails the build. This is what lets a console assert it can label every event keeper emits, in both directions; do not transcribe the list anywhere else. The `?type=` filter on `GET /v1/audit` deliberately stays unconstrained: it queries history, which may hold types since retired from the catalog. ### Categories by `source` (5 MVP values) `source` — closed enum that records who initiated the event ([ADR-022(b)](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)). It depends on the category which fields `audit_log` are required (`archon_aid` can be `NULL` for `signal` / `keeper_internal`), and which event-types fall into it. | `source` | Who writes | `archon_aid` | Typical event-types | |---|---|---|---| | **`signal`** | hot-reload pipeline responding to `SIGHUP` ([ADR-021](adr/0021-hot-reload-config.md)). | `NULL` (operator at the keyboard on the host, identity is not authenticated by Keeper). | `config.reload_succeeded` / `config.reload_failed`. | | **`api`** | HTTP-middleware Operator API ([keeper/operator-api.md](keeper/operator-api.md)). | AID from JWT-claim `sub`. | `operator.*`, `incarnation.*`, `push.*`, `cloud.*`, `config.reload_succeeded` (API-path). | | **`mcp`** | MCP-handler. | AID from JWT. | the same as for `api` - `.` are the same, only `source` is different. | | **`keeper_internal`** | Reaper, scenario-runner, Toll/Conductor-leaders, Voyage-worker, bootstrap. | `NULL` (there is no initiating Archon - this is an autonomous initiative of the Keeper). | `reaper.*`, `operator.created` (bootstrap of the first Archon - `created_by_aid: NULL` invariant [ADR-014](adr/0014-operator-identity.md)); scenario-runner terminals `incarnation.run_completed` / `incarnation.destroy_completed` / `incarnation.destroy_failed` and keeper-side `task.executed` (tasks `on: keeper`, symmetrically Soul-side `soul_grpc`); `input.vault_resolved`, `core.vault.kv-read`; Voyage-finalize `scenario_run.*` / `command_run.*` / `voyage.reclaimed`; Toll flags `cluster.degraded_set` / `cluster.degraded_cleared`; Herald-delivery `herald.delivered` / `herald.failed`. (`cadence.*`-spawn is a separate `background`, not `keeper_internal`.) | | **`soul_grpc`** | Keeper-side forwarder of events from Soul via gRPC EventStream ([ADR-012](adr/0012-keeper-soul-grpc.md)). | `NULL` (Soul is a machine actor, does not have AID); `correlation_id` = `apply_id` (for `eventstream.lease_force_released` - `sid`). | `task.*`, `apply.*`, `soulprint.received`, `eventstream.*` (the mechanics of the stream itself on the Keeper side are `eventstream.lease_force_released`). | | **`background`** | A scheduled Keeper subsystem that starts work with no operator behind it — [Conductor](#modules-and-subsystems-inside-keeper) Cadence spawn ([ADR-046 §8](adr/0046-cadence.md)). (It was introduced for the `scry_background` Reaper rule, removed in NIM-446.) | `NULL` (no initiator identified); `correlation_id` = the spawned run's id. | `cadence.spawned` / `cadence.skipped_overlap` / `cadence.skipped_forbidden`. | | **`config_bootstrap`** | One-shot legacy-import at Keeper startup ([ADR-032 amendment S7-4](adr/0032-push-orchestrator.md)): migration of inline-`keeper.yml` blocks to PG sources under an explicit opt-in flag (`push.auto_import_legacy_*`). | `NULL` (system-action, initiative not of the operator, but of the config); `correlation_id` is empty. | `soul.ssh-target.imported_from_config`, `push-provider.imported_from_config`. | Enum extension `source` - propose-and-wait (closed for typed indexes and RBAC filter `GET /v1/audit`). ### Registered event-types Starting set of names already recorded in regulatory ADRs. Column "Category" - `source` enum above; "Subsystem" - where the write-path lives. | Name | Category (`source`) | Subsystem/ADR | When is it written | Key fields `payload` | |---|---|---|---|---| | **`config.reload_succeeded`** | `signal` or `api` / `mcp` | hot-reload config ([ADR-021](adr/0021-hot-reload-config.md)) | After a successful atomic swap of the new config. | `source`, `archon.aid` (for `api`/`mcp`), `changed_paths` (list YAML-paths), `correlation_id`. | | **`config.reload_failed`** | `signal` or `api` / `mcp` | hot-reload config ([ADR-021](adr/0021-hot-reload-config.md)) | After a failed validation (the in-memory state is unchanged, the file is not modified). | `source`, `archon.aid` (if applicable), `validation_errors[]`, `phase ∈ {parse, schema_validate, semantic_validate}`. | | **`audit.disabled`** | `keeper_internal` | audit write-path gate ([ADR-022(i) amendment](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)) | `audit.enabled` went `true → false` on this instance. Written **in-line on the write path, ahead of the first event it suppresses**, and bypasses the gate it announces — a trail that goes quiet must say why. | `kid`. | | **`audit.enabled`** | `keeper_internal` | audit write-path gate ([ADR-022(i) amendment](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)) | `audit.enabled` went `false → true` on this instance; bounds the blind window at its other end. | `kid`. | | **`incarnation.scenario_started`** | `api` / `mcp` | running named scenario ([ADR-009](adr/0009-scenario-dsl.md), source-taxonomy [ADR-022](architecture.md)) | After taking `POST /v1/incarnations/{id}/scenarios/{scenario}` (202). | `archon.aid`, `name`, `scenario`, `apply_id`. | | **`incarnation.unlocked`** | `api` / `mcp` | removal `error_locked` ([ADR-009](adr/0009-scenario-dsl.md), source-taxonomy [ADR-022](architecture.md)) | After `POST /v1/incarnations/{id}/unlock` (200). | `archon.aid`, `name`, `previous_status`, `reason`. | | **`incarnation.rerun_last`** | `api` / `mcp` | atomic rerun **last failed** scenario from `error_locked` ([Atomicity and `error_locked`](architecture.md#atomicity-and-error_locked)) | After `POST /v1/incarnations/{id}/rerun-last` - under one `FOR UPDATE` `error_locked` was removed and the incarnation was transferred to `error_locked → applying` bypassing `ready`, the last fallen scenario (bootstrap `create`/... or day-2 `add_user`/...) was restarted with the saved input. DOES NOT reuse `incarnation.unlocked`. | `archon.aid`, `name`, `scenario`, `previous_status`, `reason`, `apply_id`. | | **`incarnation.upgrade_started`** | `api` / `mcp` | translation to new `state_schema_version` ([ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl), source-taxonomy [ADR-022](architecture.md)) | After `POST /v1/incarnations/{id}/upgrade` (202; migration completed synchronously within the request). | `archon.aid`, `name`, `to_version`, `apply_id`. | | **`incarnation.destroy_started`** | `api` / `mcp` | initiate destroy incarnation (S-D1) | After transferring incarnation to `destroying` (service layer; before teardown scenario `destroy` - S-D2, and DELETE lines - S-D3). | `archon.aid`, `name`, `previous_status`, `force` (`true` - destroy without teardown, S-D3). | | **`incarnation.destroy_completed`** | `keeper_internal` | destroy terminal: line destroyed (S-D3, stage V3) | After commit archive+single-winner-DELETE (scenario-runner after successful teardown). archive in `incarnation_archive` / `state_history_archive` was written BEFORE DELETE, the cascade destroyed live `state_history` / `apply_runs` / `apply_task_register`. Single-winner: only the owner of the `destroying` transition (`RowsAffected==1`). | `name`, `force`, `archive_status` (the terminal status stamped on the archived row: `destroyed` / `force_destroyed`); on the force path also `teardown: "skipped"` and, when anything outlived the record, `unreleased` = `{provider, vm_ids, sids}` (NIM-395) - what the skipped teardown did NOT release. archon_aid column `NULL`. `provider`/`vm_ids` are read from `incarnation.state` and are the ONE deliberate exception to "state/spec are not duplicated in audit": the archive has no read API, so without this event the fate of the cloud machines is unknowable after the fact. They are masked before they are read (vault-ref + key-name layers; the `state_schema` layer is NIM-531). `unreleased` is omitted entirely when the force abandoned nothing - an empty object would only ask whether it means "checked, clean" or "could not tell". | | **`incarnation.destroy_failed`** | `keeper_internal` | terminal destroy: teardown fell (S-D2b/S-D3) | After transferring incarnation to `destroy_failed` (scenario-runner on teardown failure; instance NOT deleted, state - last known-good). | `name`, `apply_id`, `reason` (masked cause - cause could carry a vault-ref; archon_aid column `NULL`). | | **`incarnation.run_completed`** | `keeper_internal` | **per-incarnation** summary scenario-run ([ADR-052 §k](adr/0052-herald-notifications.md)) | `scenario.Runner` at the TERMINAL of a regular run: at the SUCCESSFUL final (after the barrier, next to the commit state) AND at the TERMINAL FAILURE (after `lockIncarnation`, single-winner - amend §k). `TerminalDestroy` does NOT issue (its own terminal `destroy_completed`/`destroy_failed`). **One event per incarnation-run, NOT per-host** - distinguish from **per-host `run.completed`** (that one is `RunResult` from Soul, `source: soul_grpc`); here keeper-internal convolution, `archon_aid` column `NULL`, `correlation_id = apply_id`. Level — per-incarnation scenario-run, **not** total for Voyage (voyage total — `scenario_run.*`/`command_run.*`). | `incarnation`, `scenario`, `apply_id`, `status` (∈ `success`/`failed`; `error_locked` collapses into `failed`), `changed_tasks` - array of `{idx, name, register, id, module, changed_hosts, total_hosts}` tasks that have changed on at least one host (on failure - partial/empty); opt. `cadence_id` (only when the run is spawned by [Cadence](adr/0046-cadence.md)-schedule; manual run does not carry the key). Source changed = aggregate of `audit_log` (`task.executed`+`TASK_STATUS_CHANGED`); loop-fold at address `register ∪ id`; counters - union of unique `sid`. **Secret hygiene:** only metadata + counts, NO payload values ​​`register`/params. | | **`incarnation.traits_changed`** | `api` / `mcp` | holistic replacement of operator-set trait incarnation marks ([ADR-060](adr/0060-traits.md) R1 slice a, `incarnation.traits` jsonb) | After `PUT /v1/incarnations/{id}/traits` (200) or MCP-tool `keeper.incarnation.traits-set`. Writes handler after commit (payload - old/new snapshot). `incarnation.traits` holds the incarnation's own labels; member hosts inherit them at read time (ADR-080, no projection), and `soul.traits-changed` records the per-host counterpart. | `archon.aid`, `name`, `old_keys`, `new_keys` — sorted lists of trait-**KEYS** before and after replacement. ★TRAIT VALUES in payload **NOT included** (secret hygiene: trait-value can carry host infrastructure data; audit-trail records the fact of mutation and a set of keys) - symmetrically `soul.traits-changed`. | | **`incarnation.secret_revealed`** | `api` | disclosure of the plaintext secret of the incarnation under the right `incarnation.view-secrets` ([ADR-070](adr/0070-secret-reveal-path.md)) | After `POST /v1/incarnations/{id}/secrets/reveal`: self-audit writes handler on success (`result: "ok"`, AFTER reading from Vault) AND on **each denied branch after incarnation resolution** (`result: "denied"` + `reason` ∈ `out_of_scope`/`unknown_secret_id`/`key_not_in_state`/`ref_invalid`/`out_of_service_scope`/`floor_denied`/`vault_miss`/`read_error`/`field_missing`) — security-trail on brute-force keys and attempts at someone else's incarnation/service. audit-fail does not fail reveal (warn, parity `input.vault_resolved`). discovery `GET .../secrets/revealable` - read, NOT audited. | `archon.aid`, `name`, `secret_id`, `key`, `path` (logical Vault path, no secret), `result` (`ok`/`denied`), `reason` (for denied). ★VALUE of the secret in the payload **NEVER put** (secret hygiene: the value leaves the domain only with the body of the HTTP response; leak-guard tests for each sink) - symmetrically `input.vault_resolved`. | | **`input.vault_resolved`** | `keeper_internal` | scoped-resolve `vault:`-ref in operator-input ([docs/input.md → "vault_scope"](input.md#vault_scope-scoped-resolve-vault-ref-in-operator-input)) | scenario-runner resolved (`result: ok`) or rejected (`result: denied`) `vault:`-ref secret field values. Single name; status in `payload.result`. denied — security signal. | `field`, `incarnation`, `scenario`, `result` (`ok`/`denied`), `aid` (initiator, in payload - `archon_aid` column `NULL`), `path` (logical Vault path, not secret), `reason` (for denied). | | **`console.opened`** | `api` | interactive console session ([keeper/console.md](keeper/console.md), NIM-143) | After the session manager registered the session and dispatched `ConsoleOpen` to the Soul (WebSocket `/v1/console`, `open` frame). `correlation_id` = the Keeper-minted session ULID. | `archon.aid`, `sid`, `session_id`. ★A console is the most privileged operator action (the pty inherits the Soul daemon's user, typically root, and its commands cannot be checked against a module allow-list the way an Errand's can), so WHO opened a shell WHERE is audited even where session recording is off. Keystrokes/output are NOT here — that is session recording, a separate slice. | | **`console.closed`** | `api` | interactive console session ([keeper/console.md](keeper/console.md), NIM-143) | At the terminal of a session, whichever side ended it: an operator `close` frame, the socket dropping (kill-on-disconnect), the idle sweep, or a `ConsoleExit` from the Soul. Exactly once per `console.opened` — the terminal transition is guarded. Same `correlation_id` as the matching open. | `archon.aid`, `sid`, `session_id`, `reason` — the Keeper-side cause (`operator detached the pane` / `operator socket closed` / `idle timeout`) or `soul:` (`soul:process_exited` / `soul:limit_exceeded` / …) when the shell itself ended. | | **`console.command`** | `mcp` | non-interactive console ([keeper/mcp-tools/souls.md](keeper/mcp-tools/souls.md), NIM-147) | After the MCP tool `keeper.soul.run-command` dispatched a command line to a host. `correlation_id` = the `errand_id` the run was carried on. Not written when the dispatch itself failed — nothing ran. | `archon.aid`, `sid`, `status`. ★Recorded beside the transport's own `errand.invoked`, because the two answer different questions: `errand.invoked` says a MODULE ran, this says an ARBITRARY command ran and that `soul.console` — not `errand.run` — authorized it. The command line is NOT in the payload, exactly as `console.opened` holds no keystrokes. | | **`role.created`** | `api` / `mcp` | RBAC-CRUD (Slice 2, [ADR-022](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)) | After `POST /v1/roles` (201) or MCP-tool `keeper.role.create`. | `archon.aid`, `name`, `permissions` (permission lines are no secret), `created_by_aid`. | | **`role.deleted`** | `api` / `mcp` | RBAC-CRUD (Slice 2, [ADR-022](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)) | After `DELETE /v1/roles/{name}` (204) or MCP-tool `keeper.role.delete`. | `archon.aid`, `name`. | | **`role.permissions-updated`** | `api` / `mcp` | RBAC-CRUD (Slice 2, [ADR-022](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)) | After `PATCH /v1/roles/{name}/permissions` (204, replace) or MCP-tool `keeper.role.update`. | `archon.aid`, `name`, `permissions` (the new set is no secret). | | **`role.operator-granted`** | `api` / `mcp` | RBAC-CRUD (Slice 2, [ADR-022](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)) | After `POST /v1/roles/{name}/operators` (204) or MCP-tool `keeper.role.grant-operator`. | `archon.aid`, `name`, `aid` (target Archon is no secret), `granted_by_aid`. | | **`role.operator-revoked`** | `api` / `mcp` | RBAC-CRUD (Slice 2, [ADR-022](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention)) | After `DELETE /v1/roles/{name}/operators/{aid}` (204) or MCP-tool `keeper.role.revoke-operator`. | `archon.aid`, `name`, `aid` (target Archon is no secret). | | **`synod.*`** | `api` / `mcp` | Synod - group of archons ([ADR-049](adr/0049-synod.md), S1+) | Synod/membership/bundle CRUD event family (operator-initiated, `archon_aid` = JWT.sub): `synod.created` / `synod.updated` (edit `description`, ADR-049 amend) / `synod.deleted` (group declaration), `synod.operator-added` / `synod.operator-removed` (archon group membership, `synod_operators`), `synod.role-granted` / `synod.role-revoked` (group bundle role, `synod_roles`). The exact payload fields are fixed by the PR when implementing write-path (S1+). RBAC style names `role.operator-granted`/`role.permissions-updated` (kebab-composite action). | `archon.aid`, `name` (Synod name), `description` (for `synod.updated` - no secret), `aid` (for `operator-*`, target Archon - no secret), `role_name` (for `role-*` - no secret), `created_by_aid`/`added_by_aid`/`granted_by_aid` (by event). | | **`service.registered`** | `api` / `mcp` | Service registry (ADR-028-pattern RBAC-storage, [Artifacts](architecture.md#soul-stack-artifacts-whats-in-git-whats-in-the-database)) | After `POST /v1/services` (201) or MCP-tool `keeper.service.register`. | `archon.aid`, `id`, `label`, `git` (git-URL is no secret), `ref`, `created_by_aid`. | | **`service.updated`** | `api` / `mcp` | Service registry (ADR-028-pattern RBAC-storage) | After `PATCH /v1/services/{id}` (200, replace mutable fields) or MCP-tool `keeper.service.update`. | `archon.aid`, `id`, `git`, `ref`. | | **`service.deregistered`** | `api` / `mcp` | Service registry (ADR-028-pattern RBAC-storage) | After `DELETE /v1/services/{id}` (204) or MCP-tool `keeper.service.deregister`. | `archon.aid`, `id`. | | **`provisioning.policy_changed`** | `api` / `mcp` | Operator Provisioning Policy ([ADR-058(i)](adr/0058-operator-auth-ldap-oidc.md)) | After `PUT /v1/provisioning-policy` (200, replace semantics of policy `provisioning_allowed_methods`). read(`GET`) is NOT audited. | `archon.aid`, `allowed_methods` (the new CSV list of allowed creation methods is not a secret). | | **`setting.updated`** | `api` / `mcp` | [SettingsStore](#modules-and-subsystems-inside-keeper) overlay ([ADR-0073(i)](adr/0073-keeper-runtime-config-pg.md)) | After `PUT /v1/settings/{key}` (200) — an Archon overrode a Keeper runtime setting cluster-wide. A rejected value (422) writes nothing. | `archon.aid`, `key`, `value` (the new value), `previous` (the prior override, if any). Operational tunables only — the overlay is closed to security gates, so no secrets. | | **`setting.deleted`** | `api` / `mcp` | [SettingsStore](#modules-and-subsystems-inside-keeper) overlay ([ADR-0073(i)](adr/0073-keeper-runtime-config-pg.md)) | After `DELETE /v1/settings/{key}` (200) — the override was dropped, so the `keeper.yml` value or the built-in default is back in effect. | `archon.aid`, `key`, `previous` (the override that was dropped). | | **`operator.provisioned`** | `api` / `mcp` | federated auto-provision ([ADR-058(d)](adr/0058-operator-auth-ldap-oidc.md)) | The first federated login created the string `operators` (`created_via='ldap'`\|`'oidc'`, `created_by_aid=NULL`). Writes Mapper. `archon_aid: NULL` (initiator is an external IdP, not an operator). | `aid` (derived), `auth_method` (`ldap`\|`oidc`), `roles` (from `group_role_map`). | | **`operator.login`** | `api` / `mcp` | federated-login ([ADR-058(f)](adr/0058-operator-auth-ldap-oidc.md)) | After releasing an internal JWT on `POST /auth/ldap/login` or `GET /auth/oidc/callback` (one event per login). | `method` (`ldap`\|`oidc`), `aid`, `provisioned` (bool - created in this login) - WITHOUT password/bind-creds/JWT/tokens. | | **`omen.created`** | `api` / `mcp` | Augur Omen registry ([ADR-025](adr/0025-augur.md), [keeper/augur.md](keeper/augur.md)) | After `POST /v1/augur/omens` (201) or MCP-tool `keeper.augur.omen.create`. | `archon.aid`, `name`, `source_type`, `endpoint` (URL is not a secret), `auth_ref` (vault-ref is not a secret; master-cred is not in the entry), `created_by_aid`. | | **`omen.revoked`** | `api` / `mcp` | Augur Omen registry ([ADR-025](adr/0025-augur.md)) | After `DELETE /v1/augur/omens/{id}` (204) or MCP-tool `keeper.augur.omen.delete`. The connected Rites were demolished in a cascade. | `archon.aid`, `name`. | | **`rite.created`** | `api` / `mcp` | Augur Rite registry ([ADR-025](adr/0025-augur.md), [keeper/augur.md](keeper/augur.md)) | After `POST /v1/augur/rites` (201) or MCP-tool `keeper.augur.rite.create`. | `archon.aid`, `id`, `omen`, `subject` (rendered form: `sid=` / `incarnation=.` / `coven=` / `trait.=`), `delegate`, `created_by_aid` (`allow`-list is NOT placed). | | **`rite.revoked`** | `api` / `mcp` | Augur Rite registry ([ADR-025](adr/0025-augur.md)) | After `DELETE /v1/augur/rites/{id}` (204) or MCP-tool `keeper.augur.rite.delete`. | `archon.aid`, `id`. | | **`vigil.created`** | `api` / `mcp` | Oracle Vigil registry ([ADR-030](adr/0030-vigil-oracle.md)) | After `POST /v1/vigils` (201) or MCP-tool `keeper.oracle.vigil.create`. | `archon.aid`, `name`, `check`, `interval`, `subject` (rendered form: `sid=` / `incarnation=.` / `coven=` / `trait.=`), `created_by_aid` (`params` NOT placed). | | **`vigil.deleted`** | `api` / `mcp` | Oracle Vigil registry ([ADR-030](adr/0030-vigil-oracle.md)) | After `DELETE /v1/vigils/{id}` (204) or MCP-tool `keeper.oracle.vigil.delete`. | `archon.aid`, `name`. | | **`decree.created`** | `api` / `mcp` | Oracle Decree registry ([ADR-030](adr/0030-vigil-oracle.md)) | After `POST /v1/decrees` (201) or MCP-tool `keeper.oracle.decree.create`. | `archon.aid`, `name`, `on_beacon`, `incarnation`, `action_scenario`, `subject` (rendered form: `sid=` / `incarnation=.` / `coven=` / `trait.=`), `created_by_aid` (`where`-CEL and `action_input` are NOT placed). | | **`decree.deleted`** | `api` / `mcp` | Oracle Decree registry ([ADR-030](adr/0030-vigil-oracle.md)) | After `DELETE /v1/decrees/{id}` (204) or MCP-tool `keeper.oracle.decree.delete`. The cascade cleans cooldown-state `oracle_fires`. | `archon.aid`, `name`. | | **`oracle.fired`** | `soul_grpc` | Oracle reactor ([ADR-030](adr/0030-vigil-oracle.md)) | Oracle matched Portent with Decree and put named-scenario in work-queue ([ADR-027](adr/0027-apply-work-queue.md)). The triggering of a reactor is a security signal (an untrusted Soul input triggered an action), audited for each triggering. `archon_aid: NULL` (Soul-initiated), `correlation_id = apply_id`. | `decree`, `subject` (authoritative sender SID from mTLS peer cert), `scenario`, `beacon`, `apply_id` (values ​​`event.data` are NOT included - untrusted input). | | **`decree.circuit_tripped`** | `soul_grpc` | circuit-breaker Oracle ([ADR-030(a)](adr/0030-vigil-oracle.md)) | Decree auto-`disable`-en circuit-breaker: N operations (`fire_count`) for window `oracle_circuit_window` reached `oracle_circuit_max_fires` → `enabled=false`. Decree mutation (region `decree.*`, symmetrically `decree.created`/`decree.deleted`), but write-path is Soul-initiated Portent flow `evaluateDecree`, not operator. It is written ONLY by single-winner (the instance whose trip won, `RowsAffected==1`) - for each trip there is exactly one event. `archon_aid: NULL`. | `decree`, `fire_count`, `window`, `trigger` (always `"circuit_breaker"`) - WITHOUT subject/beacon/event.data: trip - property of a rule (total threshold), not of an individual host; untrusted payload is not placed. | | **`errand.invoked`** | `api` / `mcp` | Errand pull-ad-hoc exec ([ADR-033](adr/0033-errand.md)) | After `POST /v1/souls/{sid}/exec`. | `archon.aid`, `sid`, `module`, `errand_id`, `timeout_seconds`, `dry_run` (`input` is NOT placed). | | **`errand.completed`** | `soul_grpc` | Errand pull-ad-hoc exec ([ADR-033](adr/0033-errand.md)) | After `ErrandResult{status:SUCCESS}`. | `sid`, `module`, `errand_id`, `exit_code`, `duration_ms`, `stdout_truncated`, `stderr_truncated`. | | **`errand.failed`** | `soul_grpc` | Errand pull-ad-hoc exec ([ADR-033](adr/0033-errand.md)) | After `ErrandResult{status:FAILED \| MODULE_NOT_ALLOWED}`. | `sid`, `module`, `errand_id`, `exit_code`, `duration_ms`, `error_message` (masked). | | **`errand.timed_out`** | `soul_grpc` | Errand pull-ad-hoc exec ([ADR-033](adr/0033-errand.md)) | After `ErrandResult{status:TIMED_OUT}`. | `sid`, `module`, `errand_id`, `duration_ms`. | | **`errand.cancelled`** | `api` / `mcp` | Errand pull-ad-hoc exec (slice E5, post-MVP, [ADR-033](adr/0033-errand.md)) | After `DELETE /v1/errands/{errand_id}`. | `archon.aid`, `errand_id`, `sid`. | | **`errand_run.invoked`** | `api` / `mcp` | ErrandRun multi-target harness ([ADR-041](adr/0041-errandrun.md)) | After a successful INSERT `errand_runs` (HTTP `POST /v1/errand-runs` or MCP-tool). Issued by **handler** (not orchestrator) - pairing of RBAC-mutating-events (`tide.started`/`push.applied`), `archon_aid` = JWT.sub of the initiator. | `archon.aid`, `errand_run_id`, `module`, `target` (declared: `sids?`/`coven[]`/`where`), `scope_size` (len(resolved_sids) after AND-merge), `concurrency`, `on_failure` (`input` NOT placed - may carry vault-resolved secrets). | | **`errand_run.completed`** | `keeper_internal` | ErrandRun-orchestrator ([ADR-041](adr/0041-errandrun.md)) | Finalize `status=succeeded` (all Errands terminal=SUCCESS). `archon_aid: NULL` (Initiator AID is already recorded in `errand_run.invoked`). | `errand_run_id`, `total`, `succeeded`, `duration_ms` (per-Errand stdout/stderr are NOT duplicated - they are in `errands` via FK). | | **`errand_run.partial_failed`** | `keeper_internal` | ErrandRun-orchestrator ([ADR-041](adr/0041-errandrun.md)) | Finalize `status=partial_failed`: some Errands FAILED/TIMED_OUT/CANCELLED, the rest SUCCESS. On `on_failure: continue` - all Errands have reached; on `on_failure: abort` - the first failed stopped pending. `archon_aid: NULL`. | `errand_run_id`, `total`, `succeeded`, `failed`, `cancelled`, `on_failure` (fixes the finalization policy branch). | | **`errand_run.failed`** | `keeper_internal` | ErrandRun-orchestrator ([ADR-041](adr/0041-errandrun.md)) | Finalize `status=failed`: general orchestrator error (target resolve fell / no_live_souls / all Errands are immediately rejected by the whitelist). NOT written if there is at least one successful Errand. `archon_aid: NULL`. | `errand_run_id`, `error_code` (∈ `target_resolve_failed`/`no_live_souls`/`whitelist_rejected`/…). | | **`errand_run.cancelled`** | `api` / `mcp` | ErrandRun cancellation ([ADR-041](adr/0041-errandrun.md)) | After `DELETE /v1/errand-runs/{id}` - cancel-fan-out on the existing `CancelErrand` ([ADR-033 slice E5](adr/0033-errand.md)). MVP: one event finalize point, `archon_aid` - initiator. | `archon.aid`, `errand_run_id`, `cancelled_by_aid`, `succeeded_before_cancel`, `in_flight_at_cancel` (snapshot of per-Errand units at the time of cancellation). | | **`scenario_run.started`** | `api` / `mcp` | Voyage `kind=scenario` invoke ([ADR-043](adr/0043-voyage.md), S5) | **Semantically replaces `tide.started`.** Written by handler `POST /v1/voyages` immediately after a successful atomic-INSERT pending/scheduled Voyage-row (`Insert` + `InsertTargets` in one PG-tx) - pairing of RBAC-mutating events (`tide.started`/`errand_run.invoked`), `archon_aid` = JWT.sub initiator. | `archon.aid`, `voyage_id`, `kind`, `scenario_name`, `target` (declared: `incarnations[]`/`service`/`coven[]`), `scope_size` (number of resolved incarnations), `batch_size` (omitempty), `concurrency`, `dry_run`, `on_failure` (`input` is NOT placed - can carry vault-resolved secrets, invariant A [ADR-027](adr/0027-apply-work-queue.md)). | | **`scenario_run.cancelled`** | `api` / `mcp` | Voyage `kind=scenario` cancel ([ADR-043](adr/0043-voyage.md), S5) | After `DELETE /v1/voyages/{id}` for pending/scheduled run (CAS translation `pending`/`scheduled → cancelled`; running-cancel - post-MVP, 409 `voyage_running_cancel_unsupported`). `archon_aid` - JWT.sub of the initiator. | `archon.aid`, `voyage_id`, `kind`, `previous_status` (non-running status, from which the run is transferred to cancelled). | | **`command_run.invoked`** | `api` / `mcp` | Voyage `kind=command` invoke ([ADR-043](adr/0043-voyage.md), S5) | **Semantically replaces `errand_run.invoked`.** Written by handler `POST /v1/voyages` immediately after a successful atomic-INSERT Voyage-row - parity `errand_run.invoked`, `archon_aid` = JWT.sub of the initiator. | `archon.aid`, `voyage_id`, `kind`, `module`, `target` (declared: `sids[]`/`coven[]`/`where`), `scope_size` (number of resolved hosts after AND-merge), `batch_size` (omitempty), `concurrency`, `dry_run`, `on_failure` (`input` NOT placed - invariant A [ADR-027](adr/0027-apply-work-queue.md)). | | **`command_run.cancelled`** | `api` / `mcp` | Voyage `kind=command` cancel ([ADR-043](adr/0043-voyage.md), S5) | After `DELETE /v1/voyages/{id}` for pending/scheduled run. The semantics of payload is parity `scenario_run.cancelled`. | `archon.aid`, `voyage_id`, `kind`, `previous_status`. | | **`scenario_run.leg_started`** | `keeper_internal` | VoyageWorker `kind=scenario` (ADR-043, A3) | Before Leg's fan-out (parity `tide.surge_started`). `archon_aid: NULL`, `correlation_id = voyage_id`. the command-family of leg-events does NOT have (by-design parity `errand_run.*` - flat fan-out without a per-Leg barrier). | `voyage_id`, `kind`, `leg_index`, `incarnations_in_leg`. | | **`scenario_run.leg_completed`** | `keeper_internal` | VoyageWorker `kind=scenario` (ADR-043, A3) | After the terminal of all Leg incarnations + Summary delta aggregation (parity `tide.surge_completed`). `archon_aid: NULL`, `correlation_id = voyage_id`. | `voyage_id`, `kind`, `leg_index`, `terminal` (`success`/`partial`/`failed`/`cancelled`), `total`, `succeeded`, `failed`, `cancelled`. | | **`scenario_run.completed`** | `keeper_internal` | VoyageWorker `kind=scenario` finalize (ADR-043, A3) | After successful `voyage.Finalize` with `status=succeeded` (all incarnations success/no_match). `archon_aid: NULL` (initiator AID fixed in `scenario_run.started`), `correlation_id = voyage_id`. | `voyage_id`, `kind`, `total_batches`, `summary` (jsonb aggregates `{total, succeeded, failed, cancelled, no_match?}`). | | **`scenario_run.partial_failed`** | `keeper_internal` | VoyageWorker `kind=scenario` finalize (ADR-043, A3) | Finalize `status=partial_failed` (some of the incarnations failed, there is at least one success). `archon_aid: NULL`, `correlation_id = voyage_id`. | `voyage_id`, `kind`, `total_batches`, `summary`, `on_failure` (policy branch). | | **`scenario_run.failed`** | `keeper_internal` | VoyageWorker `kind=scenario` finalize (ADR-043, A3) | Finalize `status=failed` (no one succeeded or failed-closed before the start of incarnations). `archon_aid: NULL`, `correlation_id = voyage_id`. | `voyage_id`, `kind`, `total_batches`, `summary`, `error_code?` (∈ `spawner_not_configured`/`empty_scenario_name`/`target_resolve_failed` - only for fail-closed paths; not available for "all incarnations failed"). | | **`scenario_run.lease_lost`** | `keeper_internal` | VoyageWorker `kind=scenario` (ADR-043, A3) | VoyageWorker detected a loss of lease in the middle of the run / before finalize (another Keeper picked up Voyage via reclaim+claim). Orchestrator quits, finalize does NOT (parity `tide.lease_lost`). `archon_aid: NULL`, `correlation_id = voyage_id`. When a lease is lost, a command run does NOT write a separate event (by-design parity `errand_run.*` - another Keeper will silently pick it up). | `voyage_id`, `kind`, `kid_who_lost`, `phase` (∈ `leg`/`finalize`). | | **`command_run.completed`** | `keeper_internal` | VoyageWorker `kind=command` finalize (ADR-043, A3) | Finalize `status=succeeded` (all success hosts). `archon_aid: NULL` (initiator AID in `command_run.invoked`), `correlation_id = voyage_id`. parity `errand_run.completed`. | `voyage_id`, `kind`, `total`, `succeeded`. | | **`command_run.partial_failed`** | `keeper_internal` | VoyageWorker `kind=command` finalize (ADR-043, A3) | Finalize `status=partial_failed` (some hosts failed, there is at least one success). `archon_aid: NULL`, `correlation_id = voyage_id`. parity `errand_run.partial_failed`. | `voyage_id`, `kind`, `total`, `succeeded`, `failed`, `cancelled`, `on_failure`. | | **`command_run.failed`** | `keeper_internal` | VoyageWorker `kind=command` finalize (ADR-043, A3) | Finalize `status=failed` (no one succeeded or failed-closed before the hosts started). `archon_aid: NULL`, `correlation_id = voyage_id`. command leg events do NOT have (by-design parity `errand_run.*`). | `voyage_id`, `kind`, `total`, `succeeded`, `error_code?` (∈ `spawner_not_configured`/`empty_module`/`target_resolve_failed`). | | **`voyage.reclaimed`** | `keeper_internal` | Reaper rule `reclaim_voyages` (ADR-043, A3) | The rancid running-Voyage was returned to `pending` (claiming Keeper is dead or has been drained): `running → pending`, `claimed_by_kid → NULL`, `attempt++`. Area `voyage.*` (NOT `scenario_run.*`/`command_run.*`) - **kind-agnostic**: SQL reclaim does not parse kind, the event is the same for both families. per-row (one per reclaimed row). `archon_aid: NULL`. parity `tide.reclaimed`. | `voyage_id`, `last_renewed_at` (pre-reclaim, omit if NULL), `attempt_after`. | | **`reaper.reconcile_orphan_applying.executed`** | `keeper_internal` | Reaper rule `reconcile_orphan_applying` ([ADR-027(m)](adr/0027-apply-work-queue.md)) | Removed the orphaned `incarnation.status='applying'` lock of the direct (standalone, not under Voyage) scenario-run of the crashed Keeper-owner: stale applying-string with NON-empty epoch (`applying_by_kid`/`applying_since`) + presence-death of the owner in Conclave → `applying → ready` via idempotent `ReleaseApplyingOrphan`. Area `reaper.*` (recovery action of leader, parity `voyage.reclaimed`). per-row (one per unlocked lock). `archon_aid: NULL`. | `incarnation`, `prev_kid` (dead `applying_by_kid`), `apply_id` (`applying_apply_id`). | | **`eventstream.lease_force_released`** | `soul_grpc` | Keeper-side EventStream-handler reconnect Soul ([ADR-027(n)](adr/0027-apply-work-queue.md), recovery-backstop S2) | Soul reconnected to another Keeper instance after the death of the previous holder; instead of refusing `AlreadyExists` (wait for TTL SID-lease 60s), handler presence-gated intercepted the lease from the **proven-dead** prev-holder (Conclave `InstanceAlive`=false → CAS-by-prev-holder `ForceAcquireSoulLease`). Security-sensitive change of ownership lease. It is written ONLY if the force-release is successful (split-brain failure with a living/unknown prev-holder and fail-safe are NOT audited - the standard "give Soul a retrain"). `archon_aid: NULL`, `correlation_id = sid`. | `sid`, `prev_kid`, `new_kid`. | | **`tide.started`** | `api` / `mcp` | Tide invocation-time chunking ([ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override), canon-refactor 2026-05-27) | After successful `tide.Insert` (HTTP `POST /v1/incarnations/{id}/scenarios/{scenario}` with `wave` field or MCP-tool `keeper.incarnation.run` Tide mode). Issued by **handler** (not orchestrator) - pairing of RBAC-mutating-events, `archon_aid` = JWT.sub. | `archon.aid`, `tide_id`, `incarnation_name`, `scenario_name`, `target` (`coven[]` + `where`, omit if both are empty), `wave` (`size`, `on_failure`), `concurrency` (`{"replace": true}` if override; omit if scenario serial: not replaced), `scope_size` (len(target_resolved_souls)), `surges_planned` (ceil(scope/wave)). | | **`tide.surge_started`** | `keeper_internal` | Tide-orchestrator ([ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)) | Before the start of each Surge (insert `apply_runs` for the wave + ApplyRequest dispatch). `archon_aid: NULL` (Tide-orchestrator-initiated, not operator). `correlation_id = apply_id`. | `tide_id`, `surge_index`, `apply_id`, `souls_in_surge` (len(SurgePlan.Targets); canon-refactor 2026-05-27 renamed from legacy-`targets_count`). | | **`tide.surge_completed`** | `keeper_internal` | Tide-orchestrator ([ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)) | After each Surge terminal + optional per-Surge state-commit (any outcome). `archon_aid: NULL`. `correlation_id = apply_id`. | `tide_id`, `surge_index`, `apply_id`, `terminal` (SurgeRecord.Terminal: `success`/`failed`/`partial`/`cancelled`), `total`, `succeeded`, `failed`, `cancelled` (SurgeOutcome-aggregates), `state_commit_error` (omitempty: only with per-Surge state-commit-fail - apply worked, but the DB-commit failed; see `docs/scenario/tide-state-commit.md` §3). | | **`tide.completed`** | `keeper_internal` | Tide-orchestrator ([ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)) | After the last successful Surge (success-path, `tides.status: succeeded`). `archon_aid: NULL`. | `tide_id`, `total_surges`, `surges_executed`, `summary` (jsonb with an array of SurgeRecords). | | **`tide.failed`** | `keeper_internal` | Tide-orchestrator ([ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)) | **Reserved**: MVP-finalize writes `partial_failed` to any failed-Surge (see `docs/scenario/tide-state-commit.md` §1 "consequence"). `archon_aid: NULL`. | `tide_id`, `current_surge_index`, `reason` (masked - summary of reasons for Surge-failure). | | **`tide.partial_failed`** | `keeper_internal` | Tide-orchestrator ([ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)) | (a) `on_surge_failure: abort` + one Surge failed, the rest did not start; (b) `on_surge_failure: continue` + one or more surges failed, the rest passed (`tides.status: partial_failed`). `archon_aid: NULL`. | `tide_id`, `total_surges`, `surges_executed`, `failed_surge_indices` (list, 1-based), `summary` (jsonb). | | **`tide.cancelled`** | `api` / `mcp` | Tide cancellation (post-MVP deferred, [ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)) | After `DELETE /v1/tides/{tide_id}`. | `archon.aid`, `tide_id`, `cancelled_at_surge` (Surge index, which was cancelled). | | **`tide.reclaimed`** | `keeper_internal` | Reaper-rule `reclaim_tides` ([ADR-040 amendment 2026-05-27](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)) | Tide reclaimed after lease-TTL expired (claiming Keeper instance is dead or gracefully drained): row transferred to `running → pending`, `claimed_by_kid → NULL`, `attempt++`. `archon_aid: NULL`. | `tide_id`, `last_renewed_at`, `attempt_after`. | | **`tide.lease_lost`** | `keeper_internal` | Tide-orchestrator renewal CAS returned 0 rows ([ADR-040 amendment 2026-05-27](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)) | During renewal / spawn-tx / finalize, TideWorker discovered that the lease had been taken away (another instance had already picked up Tide via reclaim+claim). The Orchestrator immediately quits his job. `archon_aid: NULL`. | `tide_id`, `kid_who_lost`, `phase` (∈ `renewal`/`spawn`/`finalize` - where exactly the lease went, for race diagnostics). | | **`cluster.degraded_set`** | `keeper_internal` | Toll cluster detector ([ADR-038](adr/0038-toll.md)) | When the leader Toll sets the cluster:degraded flag (rate > threshold in the sliding 60s window). `archon_aid: NULL` (cluster-initiated, not operator). | `leader_kid`, `rate`, `baseline_connected`, `threshold`, `window_seconds`. | | **`cluster.degraded_cleared`** | `keeper_internal` | Toll cluster detector ([ADR-038](adr/0038-toll.md)) | After 60s steady rate ≤ threshold (asymmetric hysteresis grace). `archon_aid: NULL`. | `leader_kid`, `rate`, `baseline_connected`, `grace_seconds`. | | **`soul.coven-changed`** | `keeper_internal` or `api` | change of Soul's Coven mark set | The two write-paths differ in the `source` field: scenario-path - keeper-side core module `core.soul.registered` ([keeper/modules.md](keeper/modules.md)), per-host, `source: keeper_internal`, `archon_aid: NULL`, payload `{sid, mode, before, after, created}`; bulk-API — `POST /v1/souls/coven` ([operator-api.md](keeper/operator-api.md)), bulk append/remove of one label by selector, `source: api`, `archon_aid` of the initiator, one event per operation (not per-chunk), payload `{mode, label, selector, matched, changed, status, scope_applied, dry_run, source}`. | see payload by `source` on the left. | | **`soul.traits-changed`** | `api` | changing the set of operator-set trait tags of Soul ([ADR-060](adr/0060-traits.md), jsonb-column `souls.traits`). **★NIM-121 (2026-07-27): the per-soul bulk-write is FIRST-CLASS** - `souls.traits` holds the labels of that host and nothing projects over them (the R1 sync-hook is removed). **★[NIM-281](adr/0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited) (2026-08-05): it is the ONLY way a host gets a trait** - the per-incarnation counterpart `incarnation.traits-set` (audit `incarnation.traits_changed`) labels the incarnation object and reaches no host; the two axes never meet. Gate (b) applies to merge/replace: the pair must be inside the operator's own trait-scope. | bulk-API `POST /v1/souls/traits` ([operator-api.md](keeper/operator-api.md)) - bulk `merge`/`replace`/`remove` traits by selector, `source: api`, `archon_aid` initiator (from claims), one event per operation (not per-chunk). Mirrors `soul.coven-changed`-bulk, separate axis of marks. | `{mode, selector, keys, matched, changed, status, scope_applied, dry_run, source}` - `keys` = list of affected trait-**keys** (for `merge`/`replace` - keys of the transferred set, for `remove` - deleted). ★TRAIT VALUES in the payload are **NOT included** (secret hygiene: trait-value can carry host infrastructure data; audit-trail records the fact of mutation and a set of keys, not the contents) - only keys. | | **`soul.ssh-target.imported_from_config`** | `config_bootstrap` | one-shot auto-import per-host SSH-props push-flow ([ADR-032 amendment S7-4](adr/0032-push-orchestrator.md)) | per-row, after successful `UpdateSshTarget` at step `runLegacyAutoImport` when starting Keeper (flag `push.auto_import_legacy_targets: true`). Idempotent: skip-row → event is not written. `archon_aid: NULL` (system-action). | `sid`, `ssh_port`, `ssh_user`, `soul_path` - cleartext (mirror `soul.ssh-target.updated`). | | **`soul.forgotten`** | `api` / `mcp` | an operator erased a host from the registry ([keeper/operator-api/souls.md](keeper/operator-api/souls.md), permission [`soul.forget`](#permission-soulforget), NIM-386) | After `DELETE /v1/souls/{sid}` (200 with body) or MCP-tool `keeper.soul.forget`, once the transaction committed and the teardown ran. NOT written on the `teardown-unavailable` branch (503) - that call deleted nothing, and an event there would record a forget that never happened. `archon_aid` = the initiator. | `sid`, `status_before`, `seeds_revoked`, `bootstraps_burned`, `memberships_severed`, `choir_voices_removed`, `local_stream_closed`, `broadcast`, `cache_keys_purged`, `warnings`. ★This event is the ONLY durable record of the act - the `souls` row and everything cascading off it (seeds, bootstrap tokens, memberships, Choir Voices) are gone by the time it is written, so a count missing here is a number nobody can recover afterwards. **`bootstraps_burned` is deliberately NOT named `tokens_burned`**: `MaskSecrets` matches key names by case-insensitive substring, so a key containing `token` would be stored as `***MASKED***` and the count lost permanently rather than protected. `warnings` names every resource the teardown could not release (a Redis purge that failed leaves `soul::hb`, which has no TTL); empty means fully released, and `cache_keys_purged` is forced to `0` beside a failure so a number never reads as a release that happened. | | **`push-provider.imported_from_config`** | `config_bootstrap` | one-shot auto-import Push-Provider env-payload params ([ADR-032 amendment S7-4](adr/0032-push-orchestrator.md)) | per-row, after a successful `pushprovider.Insert` at step `runLegacyAutoImport` (flag `push.auto_import_legacy_providers: true`). Imported rows carry `created_by_aid='archon-system'`. Idempotent. `archon_aid: NULL`. | `id`, `params_keys` (sorted list of params keys, WITHOUT values ​​- symmetry with `push-provider.created`, sensitive values ​​are not written in audit). The VALUE is the `keeper.yml::push.providers[].name` key, which keeps its spelling; the audit key is `id` because it records the registry row ([ADR-0085](adr/0085-entity-id-and-label.md) / NIM-729). | | **`choir.*`** | `api` / `mcp` | Choir - topology of hosts within the incarnation ([ADR-044](adr/0044-choir.md), S-T3) | Choir/Voice CRUD event family: `choir.created` / `choir.deleted` (group declaration), `choir.voice_added` / `choir.voice_removed` (SID membership in Choir). `archon_aid` = JWT.sub initiator. Exact payload fields - when implementing S-T3 (editing the topology - changing the declared topology, audited as a mutating operation). | `archon.aid`, `incarnation_name`, `choir_name`, `sid` (for `voice_*`), `role`/`position` (for `voice_added`, omitempty). | | **`herald.*` (CRUD)** | `api` / `mcp` | registry [Herald](#domain-entities) notification channels ([ADR-052](adr/0052-herald-notifications.md), S4) | CRUD channel family: `herald.created` (POST `/v1/heralds`), `herald.updated` (PUT `/v1/heralds/{id}`, replace), `herald.deleted` (DELETE; related [Tiding](#domain-entities) were cascaded). `archon_aid` = JWT.sub. Payload: `name`, `type`, `enabled`, `url` (for webhook - not a secret), `secret_ref` (vault-ref - not a secret; the channel secret is not in the record), `created_by_aid`. | `archon.aid`, `name`, `type`, `enabled`, `url` (for webhook - not a secret), `secret_ref` (vault-ref - not a secret; the channel secret is not in the record), `created_by_aid`. | | **`herald.delivered`** | `keeper_internal` | delivery of notification to worker ([ADR-052](adr/0052-herald-notifications.md), S3) | Terminal for successful event delivery via Herald (claim-queue worker, at-least-once). `archon_aid: NULL` (worker-initiated); statuses of in-flight attempts - Redis (hot), terminal - in audit. | `herald`, `tiding`, `event_type`, `attempt`, **`status_code`** (HTTP code of a successful webhook response) (payload notification values ​​are NOT duplicated - invariant A [ADR-027](adr/0027-apply-work-queue.md)). | | **`herald.failed`** | `keeper_internal` | delivery of notification to worker ([ADR-052](adr/0052-herald-notifications.md), S3) | Terminal delivery failure (retry exhausted / SSRF-guard rejected URL / endpoint unreachable). `archon_aid: NULL`. | `herald`, `tiding`, `event_type`, `attempt`, `error_message` (masked - `MaskSecrets`). | | **`tiding.*` (CRUD)** | `api` / `mcp` | registry [Tiding](#domain-entities) subscription rules ([ADR-052](adr/0052-herald-notifications.md), S4) | CRUD rule family: `tiding.created` (POST `/v1/tidings`), `tiding.updated` (PUT `/v1/tidings/{id}`, replace), `tiding.deleted` (DELETE). `archon_aid` = JWT.sub. Payload: `name`, `herald`, `event_types`, `only_failures`/`only_changes`, `enabled`, `incarnation`/`cadence` (omitempty), `created_by_aid`. | `archon.aid`, `name`, `event_types` (area-glob list is not a secret), `herald`, `only_failures`/`only_changes`, `enabled`, `incarnation`/`cadence` (omitempty), `created_by_aid`. | ### Examples of event-types by area (for implementation) The names below are **convention examples** illustrating how event-types are sorted into areas when normalizing the corresponding pipelines. They are **not yet recorded** by any ADR (except `config.*` above); each will be added by the PR to the "Registered event-types" table at the time of implementation of the write-path initiator with an explicit link to ADR. **Area `config.*`** - changes to `keeper.yml` ([ADR-021](adr/0021-hot-reload-config.md), already registered above): `config.reload_succeeded`, `config.reload_failed`. **Area `operator.*`** - Archon life cycle ([ADR-013](adr/0013-bootstrap-archon.md), [ADR-014](adr/0014-operator-identity.md)): `operator.created`, `operator.revoked`, `operator.token_issued`, `operator.access_denied` (attempt to call endpoint without permission). Registered above (federated [ADR-058](adr/0058-operator-auth-ldap-oidc.md)): `operator.provisioned` (auto-provision created string `operators`), `operator.login` (releasing internal JWT on federated login). **Area `provisioning.*`** - Operator CREATION method policy ([ADR-058(i)](adr/0058-operator-auth-ldap-oidc.md)). Registered above: `provisioning.policy_changed` (`api`/`mcp` - `PUT /v1/provisioning-policy`, replace policy `provisioning_allowed_methods`; read is NOT audited). **Area `setting.*`** - the [SettingsStore](#modules-and-subsystems-inside-keeper) overlay of reload-able Keeper settings ([ADR-0073(i)](adr/0073-keeper-runtime-config-pg.md)). Registered above: `setting.updated` (`PUT /v1/settings/{key}`), `setting.deleted` (`DELETE /v1/settings/{key}`); read is NOT audited. Two names rather than one `setting.changed`, so "an override was set" and "an override was dropped" stay distinguishable without parsing the payload. The **propagation** of the change on the other instances is not a `setting.*` event: it is `config.reload_succeeded` with `source: keeper_internal`, where the swap has no initiating Archon. **Area `role.*`** - RBAC-CRUD: roles, their permissions and membership ([keeper/rbac.md](keeper/rbac.md), [ADR-022](adr/0022-audit-pipeline.md#adr-022-audit-pipeline-storage-schema-retention) - authorization changes must be audited). Registered above: `role.created`, `role.deleted`, `role.permissions-updated`, `role.operator-granted`, `role.operator-revoked`. `role.list` - read-only, not written to audit. **Area `service.*`** — Service registry `service_registry` (ADR-028-pattern RBAC-storage, [Artifacts](architecture.md#soul-stack-artifacts-whats-in-git-whats-in-the-database)). Registered above: `service.registered`, `service.updated`, `service.deregistered`. `service.list` / get - read-only, not written to audit. **Area `incarnation.*`** — runtime service instances ([architecture.md → Incarnation](architecture.md#incarnation--runtime-service-instance)): `incarnation.created`, `incarnation.scenario_succeeded`, `incarnation.scenario_failed`, `incarnation.locked`, `incarnation.state_migrated` ([ADR-019](adr/0019-state-migration-dsl.md#adr-019-state_schema-migration-dsl)). Registered above (ADR-009): `incarnation.scenario_started`, `incarnation.unlocked`; (rerun-last): `incarnation.rerun_last`; (ADR-019): `incarnation.upgrade_started`; (S-D1): `incarnation.destroy_started`; (S-D3): `incarnation.destroy_completed`, `incarnation.destroy_failed`; (ADR-052 §k): `incarnation.run_completed` (per-incarnation result of scenario-run, carries `status` ∈ `success`/`failed` + `changed_tasks` + opt. `cadence_id`; issued on terminal failure except destroy; separate from per-host `run.completed`); (ADR-060 R1 slice a): `incarnation.traits_changed` (replacement of operator-set trait incarnation marks, payload - only old/new KEYS, not values). **Area `push.*`** - module `keeper.push` ([keeper/push.md](keeper/push.md)): `push.applied`, `push.failed`, `push.connection_refused`. `push.applied` | `push.completed` | `push.failed` | `push.partial_failed` - finalized 2026-05-26 (ADR-032 push-orchestrator Variant C, [architecture.md](adr/0032-push-orchestrator.md)). **Area `reaper.*`** - Reaper ([keeper/reaper.md](keeper/reaper.md)): `reaper.cycle_started`, `reaper.purge_souls.executed`, `reaper.expire_pending_seeds.executed`, `reaper.purge_audit_old.executed`, `reaper.dry_run` (for `dry_run: true` cycle). Registered above: `reaper.reconcile_orphan_applying.executed` (`keeper_internal` - rule `reconcile_orphan_applying` removed the orphaned `incarnation.status='applying'` lock standalone-run of the crashed Keeper owner, per-row, parity `voyage.reclaimed`; [ADR-027(m)](adr/0027-apply-work-queue.md)). **Area `cluster.*`** — cluster-wide Keeper cluster events ([ADR-038](adr/0038-toll.md)): registered above `cluster.degraded_set`, `cluster.degraded_cleared` (Toll detector). The source is always `keeper_internal` (cluster-level, not operator-initiated). **Area `tide.*`** _(superseded-by-`scenario_run.*`)_ - Tide invocation-time chunking ([ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)). Registered above: `tide.started` (`api`/`mcp` - handler writes immediately after successful `tide.Insert`, canon-refactor 2026-05-27), `tide.surge_started` / `tide.surge_completed` / `tide.completed` / `tide.failed` / `tide.partial_failed` (`keeper_internal` - Tide-orchestrator), `tide.reclaimed` / `tide.lease_lost` (`keeper_internal` - Reaper / TideWorker, failover-mechanics from [amendment 2026-05-27](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)), `tide.cancelled` (`api`/`mcp`, deferred post-MVP). `correlation_id = apply_id` for per-Surge events. **Removed in Wave 5, replaced by `scenario_run.*` (+ `voyage.reclaimed`).** `EventTide*`-constants cut from `shared/audit/event_types.go` (commit `6f9baff`, A3), `tide.*`-family is NOT issued anymore - Voyage `kind=scenario` writes `scenario_run.*` ([ADR-043](adr/0043-voyage.md)). The names are preserved here as a historical record. **Area `errand_run.*`** _(superseded-by-`command_run.*`)_ - multi-target ErrandRun harness ([ADR-041](adr/0041-errandrun.md)). Registered above: `errand_run.invoked` (`api`/`mcp` - handler writes immediately after a successful INSERT pending-ErrandRun-row, parity `tide.started`), `errand_run.completed` / `errand_run.partial_failed` / `errand_run.failed` (`keeper_internal` - ErrandRun-orchestrator finalize), `errand_run.cancelled` (`api`/`mcp` - `DELETE /v1/errand-runs/{id}`). **Removed in Wave 5, replaced by `command_run.*`.** `EventErrandRun*` - constants cut from `shared/audit/event_types.go` (commit `6f9baff`, A3), `errand_run.*` - family is NOT issued anymore - Voyage `kind=command` writes `command_run.*` ([ADR-043](adr/0043-voyage.md)). The names are preserved here as a historical record. **`errand.*` (single Errand) NOT affected:** single-SID Errand (`POST /v1/souls/{sid}/exec`, ADR-033) - separate active primitive, writes `errand.invoked` / `errand.completed` / `errand.failed` / `errand.timed_out` / `errand.cancelled` (constants `EventTypeErrand*` are present in the code). **Area `scenario_run.*`** - Voyage `kind=scenario` ([ADR-043](adr/0043-voyage.md)). **Semantically replaces `tide.*`.** Registered above: `scenario_run.started` / `scenario_run.cancelled` (`api`/`mcp` - handler `POST`/`DELETE /v1/voyages`, S5); finalize-family `keeper_internal` from `VoyageWorker` (A3): per-Leg `scenario_run.leg_started` / `scenario_run.leg_completed` (parity `tide.surge_started`/`tide.surge_completed`), terminal `scenario_run.completed` / `scenario_run.partial_failed` / `scenario_run.failed`, and `scenario_run.lease_lost` (parity `tide.lease_lost`). `correlation_id = voyage_id` on all finalize/leg/lease_lost events (Leg = N incarnations, there is no single apply_id on Leg - correlation by `voyage_id`, like the invoke/cancel handler). `error_code` in `scenario_run.failed` - only for fail-closed paths before the start of incarnations. `tide.*` removed in Wave 5 (historical predecessor). **Area `command_run.*`** - Voyage `kind=command` ([ADR-043](adr/0043-voyage.md)). **Semantically replaces `errand_run.*`.** Registered above: `command_run.invoked` / `command_run.cancelled` (`api`/`mcp` - handler `POST`/`DELETE /v1/voyages`, S5); finalize family `keeper_internal` from `VoyageWorker` (A3): `command_run.completed` / `command_run.partial_failed` / `command_run.failed` (parity `errand_run.*`). **WITHOUT leg-events** (by-design parity `errand_run.*` - flat fan-out on hosts without a per-Leg barrier) and **WITHOUT `command_run.lease_lost`** (losing a lease in the middle of a command run does NOT write a separate event - the run will silently pick up another Keeper via reclaim; lease_lost - scenario-only). `correlation_id = voyage_id`. `error_code` to `command_run.failed` - only for fail-closed paths. `errand_run.*` removed in Wave 5 (historical predecessor). **Area `voyage.*`** - kind-agnostic Voyage run events ([ADR-043](adr/0043-voyage.md), A3). Registered above: `voyage.reclaimed` (`keeper_internal` - Reaper rule `reclaim_voyages`, per-row when returning a rancid running-Voyage to `pending`, parity `tide.reclaimed`). A separate area has been introduced (and not `scenario_run.reclaimed`/`command_run.reclaimed`) because SQL reclaim does NOT parse kind strings - the event is the same for scenario/command (just as `apply_runs` reclaim does not distinguish the source). `archon_aid: NULL`. **Area `choir.*`** - topology of hosts inside the Choir/Voice incarnation ([ADR-044](adr/0044-choir.md), S-T3). Registered as a family in the table above: `choir.created` / `choir.deleted` (CRUD declared group [Choir](#domain-entities)), `choir.voice_added` / `choir.voice_removed` ([Voice](#domain-entities) SID membership in Choir). The source is always `api` / `mcp` (operator-initiated editing of declared topology via CRUD-API or keeper-side core module S-T5). The exact payload fields are fixed by PR when implementing S-T3. **Area `cadence.*`** - [Cadence](#domain-entities) schedule spawning Voyage ([ADR-046](adr/0046-cadence.md), S2-S4; not yet implemented). CRUD family `api` / `mcp` (operator-initiated, `archon_aid` = JWT.sub): `cadence.created` / `cadence.updated` / `cadence.deleted`. Spawn family - background periodic keeper rule executed by **[Conductor](#modules-and-subsystems-inside-keeper)** leader on tick ([ADR-048](adr/0048-conductor.md); S0 design ADR-046 planned Reaper rule `spawn_due_cadence`, introduced in Conductor 2026-06-02), **`source: background`** (a scheduled keeper subsystem with no operator behind it; the source predates Conductor and was kept after the move, new source `scheduler` is NOT entered), `archon_aid: NULL` (background keeper rule does not have an identified initiator operator; recipe authorship lives in `voyages.started_by_aid` child Voyage, and not in the audit-source, [ADR-046 §7/§8](adr/0046-cadence.md)): `cadence.spawned` (`{cadence_id, voyage_id, scheduled_for}` - child Voyage was spawned), `cadence.skipped_overlap` (`{cadence_id, scheduled_for, reason: overlap}` - `overlap_policy: skip` missed spawn due to the previous child being alive), `cadence.skipped_forbidden` (`{cadence_id, scheduled_for, reason: console_required, module}` - the recipe's creator no longer holds the permission the module needs, [ADR-0074 amendment](adr/0074-interactive-console-pty.md) / NIM-197; a separate type from `skipped_overlap` because an overlap skip is normal scheduling while this one is a schedule that has stopped and needs an operator). The exact payload fields are fixed by PR when implementing S3. **Area `herald.*` / `tiding.*`** - Herald/Tiding run event notifications ([ADR-052](adr/0052-herald-notifications.md), S3-S4). Registered above: CRUD families `herald.created` / `herald.updated` / `herald.deleted` and `tiding.created` / `tiding.updated` / `tiding.deleted` (`api` / `mcp` - operator-initiated CRUD registries [Herald](#domain-entities)/[Tiding](#domain-entities) via `/v1/heralds`/`/v1/tidings`); delivery terminals `herald.delivered` / `herald.failed` (`keeper_internal` - claim-queue worker, at-least-once; statuses of in-flight attempts in Redis (hot→Redis), terminal - in audit). Payload of terminals does NOT carry notification values ​​(invariant A [ADR-027](adr/0027-apply-work-queue.md) - can carry vault-resolved data). The exact payload fields are fixed by PR when implementing S3/S4. **Area `task.*`** - task events from Soul via gRPC ([ADR-012](adr/0012-keeper-soul-grpc.md)): `task.policy_violation` (violation `side_effects`, [ADR-020(g)](adr/0020-plugin-infrastructure.md)), `task.failed`, `task.plugin_init_failed`, `task.plugin_crash`. **`task.executed`** - terminal of each run task (`payload.status` = name `keeperv1.TaskStatus`, `correlation_id = apply_id`); issued **by both parties**: Soul-side tasks - `source: soul_grpc` (handler `TaskEvent`, `sid` host), keeper-side tasks `on: keeper` - `source: keeper_internal` (`scenario.dispatchKeeperTasks`, `sid = keeper`), so that keeper tasks are included in the `changed_tasks` rollup / task-subscription Tiding ([ADR-052 amend §k/§l](adr/0052-herald-notifications.md)). Payload - general form (`audit.BuildTaskExecutedPayload`): address (`sid`/`task_idx`) + `status`, without `register_data`/output/params; `error.message` only on failure - nothing suppresses it per task since [ADR-083](adr/0083-declared-secret-state-fields.md) §8 removed `no_log:`. **Area `eventstream.*`** is the mechanics of the gRPC EventStream itself on the Keeper side ([ADR-012](adr/0012-keeper-soul-grpc.md)), separate from the Soul data events (`task.*`/`apply.*`/`soulprint.received`). Registered above: `eventstream.lease_force_released` (`soul_grpc` - presence-gated interception of SID-lease from a proven dead prev-holder when reconnecting Soul to another Keeper instance, [ADR-027(n)](adr/0027-apply-work-queue.md) recovery-backstop S2; `correlation_id = sid`, `archon_aid: NULL`, written only when successful force-release). A separate area has been introduced (and not `apply.*`/`soulprint.*`), because the event is about the lifecycle of the transport (change of lease ownership), and not about the applied load of the run. Source `soul_grpc` is the same handler path as Soul's Outbound event forwarder. **Area `plugin.*`** - Sigil plugin integrity ([ADR-026](adr/0026-sigil.md)). CRUD registry `plugin_sigils` - category `api` / `mcp`: `plugin.allowed` (Archon admitted a release to the allow-list; payload `{alias, source, ref, kind, artifact_sha256[], allowed_by_aid}` — the scalar `sha256` key was REMOVED rather than repurposed by NIM-793, so a reader of `payload->>'sha256'` breaks loudly instead of silently parsing one digest out of several), `plugin.revoked` (revoked permission). Sigil verification failure before seal/exec - category `soul_grpc` (host-side, `archon_aid: NULL`): `plugin.verify_failed` (binary digest ≠ Sigil or invalid Keeper signature). **Area `sigil.*`** - rotation of trust-anchor-**signature keys** Sigil ([ADR-026(h)](adr/0026-sigil.md), R3-S7). CRUD registry `sigil_signing_keys` - category `api` / `mcp`, `payload` carries `key_id` + caller AID (**private is NEVER in payload**): `sigil.key-introduced` (entered a new signature key), `sigil.key-retired` (displayed the key), `sigil.key-primary-set` (made the key primary). Names `key-` (kebab-composite ``) - convention `.`, separate space from RBAC-perm `sigil.key-`. **Area `augur.*` / `omen.*` / `rite.*`** - Augur external access broker ([ADR-025](adr/0025-augur.md), [keeper/augur.md](keeper/augur.md)). Live-fetch from Soul - category `soul_grpc` (`archon_aid: NULL`, `correlation_id = apply_id`; **does not introduce a new value in the `source` enum**): `augur.fetch_brokered` (the broker read and returned, `delegate=false`), `augur.token_minted` (minted a scoped Vault token), `augur.cred_issued` (issued scoped static cred), `augur.access_denied` (authorization check failed). CRUD registries - category `api` / `mcp`: `omen.created` / `omen.revoked`, `rite.created` / `rite.revoked`. When implementing each subsystem, PR adds specific names to the "Registered event-types" table indicating the required fields `payload` and a link to the standard ADR. ## push_runs statuses Enumeration of `push_runs.status` values (push-orchestrator internal contract, [ADR-032](adr/0032-push-orchestrator.md)). Not to be confused with `apply_runs.status` (`success`/`failed`/`cancelled`/`orphaned`/`no_match`) - push-orchestrator does not overlap with Acolyte work-queue. `push_runs.status: pending | running | succeeded | partial_failed | failed` - **`pending`** — insert-row, executeAsync has not started yet. - **`running`** - executeAsync took the prog, it goes. - **`succeeded`** - all hosts are ok. - **`partial_failed`** - some hosts passed, some fell. - **`failed`** - all hosts crashed OR render-error/lookup-error before start. ## Tides statuses Enumeration of `tides.status` values (Tide-orchestrator internal contract, [ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)). Not to be confused with `apply_runs.status` (`success`/`failed`/`cancelled`/`orphaned`/`no_match`) - Tide lives one level higher: one Tide → N `apply_runs` (one each on Surge, FK `apply_runs.tide_id`). `tides.status: pending | running | succeeded | failed | partial_failed | cancelled` - **`pending`** — Tide has been created, no Keeper instance has made a claim yet. After `reclaim_tides` Reaper rule ([ADR-040 amendment 2026-05-27](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)) - Tide also returns here, which has `claim_expires_at < NOW()` (`attempt++`, parity [ADR-027](adr/0027-apply-work-queue.md) `claimed → planned`). - **`running`** — Tide claimed Keeper instance (`claimed_by_kid` NOT NULL, `claim_expires_at` NOT NULL — CHECK-invariant), surges are executed sequentially. - **`succeeded`** — all surges ended in success. - **`failed`** — `on_surge_failure: abort` + one Surge failed (the rest did not start). - **`partial_failed`** — `on_surge_failure: continue` + one or more surges failed, the rest passed. - **`cancelled`** - Operator canceled Tide (`DELETE /v1/tides/{tide_id}`, deferred post-MVP). There is NO separate terminal `orphaned` (unlike `apply_runs.status`): Reaper rule `reclaim_tides` returns Tide to `pending` for re-claim by another Keeper instance, rather than closing it with a terminal. Symmetry with [ADR-027](adr/0027-apply-work-queue.md) - Ward-claim reclaim also returns `claimed → planned`, does not introduce a separate `orphaned` for rotten claims. ## Parameters invocation-time override (Tide) Request-body fields `POST /v1/incarnations/{id}/scenarios/{scenario}` (and mirror MCP-tool `keeper.incarnation.run`) that activate Tide mode ([ADR-040](adr/0040-tide.md#adr-040-tide--invocation-time-scope-chunking--target-override)). Without `wave` - classic single-run; with `wave` - Tide-orchestrator. | Field | Type | Semantics | |---|---|---| | `target.coven[]` | `list` | Override scenario `on:` — **AND-merge** with a coven list (narrowing only, not expanding). Security invariant [ADR-009 §3](adr/0009-scenario-dsl.md). | | `target.where` | CEL string | Override scenario `where:` — **AND-merge** with CEL-predicate (∧ existing scenario `where:`). | | `wave.size` | int | Wave-size (souls per Surge). If total scope ≤ `wave_size` - one Surge (Tide degenerates into a classic single-run execution, but the `tides` record is created). | | `wave.on_failure` | enum (`abort` \| `continue`) | Failure-policy between surges: `abort` (default, fail-fast) or `continue` (roll out to the end, terminal = `partial_failed`). `stop-and-wait` - deferred post-MVP. | | `concurrency` | int (nullable) | **REPLACE** scenario `serial:` (runtime-knob). Without override - scenario `serial:` is saved. | ## Error codes Error code - stable error name in a formalized channel: HTTP-response under RFC 7807, field `TaskError.reason` in `proto/keeper/v1/`, MCP-tool error `code`, diagnostic output of YAML / manifest / destiny parsers, field `validation_errors[]` audit-event `config.reload_failed`. It differs from audit-event names (`.`) in its role: audit-event describes **what happened**, error code — **why it was rejected** or **what failed**. Three categories with different shapes according to the place of use: | Category | Convention | Where is it used | Start catalog | |---|---|---|---| | **Parser / validation errors** | `snake_case` (lowercase ASCII, no dots/hyphens) | Parsers YAML/manifest/destiny; diagnostic output `soul-lint` and CLI; field `validation_errors[]` audit-event `config.reload_failed`. | `unknown_key`, `unknown_capability`, `reserved_module_namespace`, `unknown_permission`, `unknown_status`, `tls_not_implemented`, `multiple_resource_types_in_side_effect_entry`, `audit_retention_mismatch`, `io_error`, `yaml_parse_error`, `empty_document`, `multi_document_not_allowed`, `config_kind_indeterminate`, `type_mismatch`, `enum_invalid`, `host_port_invalid`, `port_out_of_range`, `duration_invalid`, `value_out_of_range`, `pool_size_invalid`, `otel_endpoint_required`, `missing_required_field`, `kid_invalid_format`, `aid_invalid_format`, `sid_invalid_format`, `vault_ref_invalid_format`, `path_not_found`, `symlink_write_not_supported`, `round_trip_warning`, `atomic_rename_failed`, `non_scalar_patch_target`, `name_invalid_format`, `required_module_invalid_format`, `core_module_in_modules_list`, `conflicting_module_ref`, `module_install_name_not_an_alias`, `input_type_invalid`, `input_enum_type_mismatch`, `input_default_type_mismatch`, `input_key_invalid_for_type`, `input_pattern_invalid`, `input_format_invalid`, `input_pattern_format_conflict`, `input_min_conflict`, `input_max_conflict`, `input_required_default_conflict`, `input_allow_empty_min_length_conflict`, `input_required_value_invalid`, `input_default_not_in_enum`, `input_param_name_invalid`, `input_enum_unsupported_for_type`, `input_vault_scope_requires_secret`, `input_vault_scope_invalid`, `input_required_when_invalid`, `input_type_unknown`, `input_type_cycle`, `input_type_duplicate`, `input_type_ref_conflict`, `input_type_ref_overlay_conflict`, `input_required_list_removed`, `section_key_conflict`, `state_schema_root_not_object`, `state_schema_invalid`, `state_schema_legacy_json_schema_form`, `task_discriminator_missing`, `task_discriminator_multiple`, `module_format_invalid`, `serial_run_once_conflict`, `register_on_block_invalid`, `changed_when_on_block_invalid`, `failed_when_on_block_invalid`, `retry_on_block_invalid`, `timeout_on_block_invalid`, `output_on_block_invalid`, `params_on_block_invalid`, `async_on_block_invalid`, `changed_when_on_apply_invalid`, `failed_when_on_apply_invalid`, `retry_on_apply_invalid`, `timeout_on_apply_invalid`, `params_on_apply_invalid`, `async_on_apply_invalid`, `async_on_keeper_invalid`, `block_on_keeper_invalid`, `apply_when_dynamic_unsupported`, `when_on_keeper_dynamic_unsupported`, `state_store_after_use`, `state_stale_same_passage_read`, `state_wide_match`, `require_forward_reference`, `register_identifier_invalid`, `register_name_reserved`, `id_identifier_invalid`, `id_register_conflict`, `id_unsupported_target`, `duplicate_task_address`, `unknown_register_reference`, `register_dependency_cycle`, `within_block_register_dependency`, `cross_passage_requisite_unsupported`, `cross_passage_when_unsupported`, `secret_field_reserved_state_name`, `secret_field_unsupported_location`, `secret_field_name_unsafe`, `secret_field_key_on_scalar`, `secret_field_key_required`, `secret_field_key_unknown`, `secret_field_key_not_string`, `secret_field_required`, `secret_field_unknown_key`, `secret_collection_key_duplicate`, `rbac_no_cluster_admin`, `mcp_listener_required`, `grpc_bootstrap_listener_required`, `grpc_event_stream_listener_required`, `bootstrap_eventstream_port_conflict`, `event_stream_port_required`, `bootstrap_port_required`, `errand_module_not_allowed`, `errand_dry_run_unsupported`, `errand_timeout_exceeded`, `tide_run_once_conflict`, `tide_target_override_invalid`, `tide_wave_size_invalid`, `vault_path_in_own_namespace`, `own_namespace_fence_unchecked`, `on_keeper_redundant`, `on_covens_on_keeper_module`, `on_keeper_on_soul_module`, `keeper_module_in_destiny`, `module_side_invalid`, `service_tree_no_scenarios`, `migration_layout_retired`, `migration_step_name_invalid`, `migration_step_number_invalid`, `migration_step_duplicate`, `migration_step_main_missing`, `migration_chain_broken`, `migration_ladder_unreadable`, `migration_version_key`, `lint_internal_panic` | | **HTTP error types (RFC 7807)** | `kebab-case` (lowercase ASCII, hyphenated) | `application/problem+json` field `type` - URN suffix under `https://soul-stack.com/errors/`. | `incarnation-locked`, `would-lock-out-cluster`, `incarnation-already-exists`, `validation-failed`, `not-found`, `malformed-request`, `errand-not-found`, `tempo-exceeded` (429, per-AID rate-limit [Tempo](#modules-and-subsystems-inside-keeper) / [ADR-050](adr/0050-tempo.md#adr-050-tempo--per-aid-rate-limiting-write-api); symbolic `TypeTempoExceeded`), `assert-failed` (422, scenario `assert:`-predicate failed at the pre-flight gate of run creation - [ADR-009](adr/0009-scenario-dsl.md)/[ADR-027](adr/0027-apply-work-queue.md) amendment 2026-06-23; symbolic `TypeAssertFailed`; incarnation is NOT created, separate URN from `validation-failed`), `provisioning-method-disabled` (operator CREATION method is prohibited by policy `provisioning_allowed_methods`, [ADR-058(i)](adr/0058-operator-auth-ldap-oidc.md); symbolic `TypeProvisioningMethodDisabled`), `auth-throttled` (429, the anti-bruteforce limit of the public login endpoint has been exceeded - throttle the frequency of attempts OR lockout after a series of failures, [LoginGuard](#modules-and-subsystems-inside-keeper) / [ADR-058(g)](adr/0058-operator-auth-ldap-oidc.md); symbolic `TypeAuthThrottled`; `Retry-After` + anti-oracle detail without specifying scope/reasons), `cluster-degraded` (503, [Toll](#modules-and-subsystems-inside-keeper) / [ADR-038](adr/0038-toll.md) sheds the write path after a mass outflow of Souls; symbolic `TypeClusterDegraded`; a property of the CLUSTER - the request never ran), `teardown-unavailable` (503, `DELETE /v1/souls/{sid}` could not publish the cluster-wide teardown notice and stopped before deleting anything - [souls.md](keeper/operator-api/souls.md); symbolic `TypeTeardownUnavailable`; a separate URN from `cluster-degraded` deliberately - same status, different fact: there the cluster refused the request, here the request refused to half-finish) | | **Runtime task/plugin errors** | `snake_case` (lowercase ASCII, no dots/hyphens) | `proto/keeper/v1/` field `TaskError.reason`; MCP-tool error `code`. | `plugin_init_failed`, `plugin_crash`, `module_not_allowed` (`policy_violation` follows the convention but is **emitted by nothing** — see [ADR-020(g)](adr/0020-plugin-infrastructure.md#amendment-2026-08-06-nim-377-the-schema-is-generated-from-go-the-artifact-carries-no-name)) | Extension of the directory is a regular PR in the file that introduces the error (without a separate propose-and-wait: this is a formalization of the observed pattern, not the introduction of a new dictionary entity). After the first mention in the normative formulation, the name is considered stable - renaming requires a backward-compat plan. Family `_on_block_invalid` (Category 1) - fail-closed failure of a module-specific key at the `block` level: `block` is a grouping construct, not a module-task, so module-specific keys at the block-task itself are rejected by the parser. Codes: `register_on_block_invalid`, `changed_when_on_block_invalid`, `failed_when_on_block_invalid`, `retry_on_block_invalid`, `timeout_on_block_invalid`, `params_on_block_invalid`, `async_on_block_invalid` - each is formulated as "`` is not allowed at the block level (block - grouping construction, not a module task)". Block-inherited keys (`when:` / `where:` / `serial:` / requisites / `vars:`) are **not** included here - they are valid at the block level. Behavior `block:` - [`docs/destiny/tasks.md §6.5`](destiny/tasks.md). Family `_on_apply_invalid` (Category 1) - the same fail-closed cut at the other construct that expands into a group: a module-specific key on an `apply:` task. Codes: `changed_when_on_apply_invalid`, `failed_when_on_apply_invalid`, `retry_on_apply_invalid`, `timeout_on_apply_invalid`, `params_on_apply_invalid`. Membership rule: the key **works on a module task and is lost on an applier** - render reads only the applier's `apply:`/`register:`/`vars:`/requisites and its `when:`/`where:`/`on:`/`run_once:`/`serial:`, so nothing else reaches a rendered task. Each carries its own reason (a module result to re-judge, one call's retry/timeout, module arguments, an unimplemented group mask) rather than one shared sentence. **Not** in the family: `output:`, which is unread on every task type and is therefore refused by its own code `output_unsupported` rather than per-discriminator ([`docs/destiny/tasks.md §9`](destiny/tasks.md), [`docs/destiny/output.md`](destiny/output.md)); `async:`, `id:` and `loop:`, already refused by `async_on_apply_invalid`, `id_unsupported_target` and `loop_unsupported_target`; `vars:`, which left the family in NIM-336 once it was resolved into the env that renders `apply.input`; and the keys an applier answers (`register:`, `when:`, `where:`, `on:`, `serial:`, `run_once:`, requisites, `name:`). The full table of what an applier answers and what it refuses - [`docs/scenario/orchestration.md §2.1.2`](scenario/orchestration.md). **`vault_path_in_own_namespace`** (Category 1, [ADR-083](adr/0083-declared-secret-state-fields.md) §7) - an author-written Vault path resolving under the prefix the platform derives a service's own secrets into (`//…`). Raised on `${ vault(...) }` in any CEL string (including a path assembled by concatenation, where the literal still carries the prefix), on a `vault:` reference in `params:` or any other value, and on the `path:` / `targets:` of [`core.vault.kv-read` / `core.vault.kv-present`](#specific-core-modules). The fence is on the **namespace**, not on the mechanism: every channel survives unchanged for paths outside the prefix (a shared TLS CA, another service's credential). The mount is not compared - what makes a path the service's own is the service segment, and load time does not know `vault.kv_mount` - nor is the incarnation segment, which at load is still `${ incarnation.id }`; the fence is therefore service-wide, strictly broader than the runtime gate it pairs with (`keeper/internal/render.ownNamespaceRef`, [ADR-083](adr/0083-declared-secret-state-fields.md) §6). Produced by `soul-lint` over the include-expanded task list, by keeper at `artifact.LoadScenarioManifestResolved`, and as a render abort at `render.Pipeline.Render` - the last is the only one that sees an `include:` body, which is parsed after the load-time entry point returns. Two cases it deliberately does not reach: a path assembled entirely out of variables (`vault(vars.some_ref)`), which names no segment static text could compare, and [`vault_scope`](input.md#vault_scope-scoped-resolve-vault-ref-in-operator-input) on an input field, whose prefix-glob form (`secret/*`) can cover the namespace while spelling no matchable segment - both belong to the runtime gate. Group one schema dialect (Category 1, [ADR-0086](adr/0086-one-schema-dialect.md)) - three codes closing the move of `state_schema` from JSON Schema into the input DSL. All three are **decided and not implemented**: the engine still requires the old envelope, and none of the three is emitted by anything yet (engine - **NIM-742**, `soul-lint list-secret-paths` - **NIM-743**, corpus rewrite - **NIM-744**). - `state_schema_legacy_json_schema_form` - a `state_schema` written as the old JSON Schema envelope, `type: object` with a `properties:` wrapper at the root. Refused **by name** rather than left to a generic diagnostic, because under the new dialect that envelope is a perfectly well-formed map: it parses **silently** as two state fields called `type` and `properties`, moving every real field one level down and validating an incarnation against a schema nobody wrote. The change is breaking with no transition window - no manifest parses under both forms - so the one thing the refusal must never do is stay quiet. The replacement is the field map itself ([`docs/service/manifest.md`](service/manifest.md#format-state_schema)). - `input_required_list_removed` - the object-level list form `required: [names]`. It leaves the **whole** dialect, not only the `state_schema` root: nested `type: object` nodes and `types.yml` included. The replacement is the per-field boolean `required: true` on each child - the key `input:` has always had, now the only spelling of requiredness anywhere ([`docs/input.md`](input.md)). - `input_type_ref_overlay_conflict` - the fail-closed half of the `state_schema`-only overlay. A `$type` node has always overlaid `description` / `required` / `required_when` onto the resolved type; in `state_schema` it may additionally carry its own `properties:`, so a state field reuses a shared type and adds what that field alone holds. The merge is **add-only shallow**, the [ADR-009](adr/0009-scenario-dsl.md) `extends:` covenant: a property declared on both the reference and the type is this code, never last-wins and never a deep merge. Inside `input:` the overlay does **not** widen - `$type` beside `type:` / `properties:` / `items:` stays `input_type_ref_conflict`. Group intra-host concurrency (Category 1, [ADR-0075](adr/0075-intra-host-async-tasks.md)) - fail-closed diagnostics of the `async:`/`require:` pair, each closing a case render or the runner would otherwise accept and not execute: - `async_on_apply_invalid` - `async:` on an `apply:` task. The sibling of `async_on_block_invalid` at the other construct that expands into a group: asynchrony of a whole group is the deferred slice, and render carries the flag onto none of the expanded tasks, so the key would be accepted and do nothing. Put `async:` on the tasks inside the destiny instead. [`docs/destiny/tasks.md §6`](destiny/tasks.md). - `async_on_keeper_invalid` - `async:` next to `on: keeper`. The third and last construct where the flag has no meaning: `async:` is Soul-side task concurrency and a keeper task is executed by the keeper's own runner, never reaching a Soul. Render refused it before; the code moves the refusal offline, with a line and a column. `require:` on a keeper task stays legal - redundant, since the keeper executor runs its tasks in plan order. [`docs/destiny/tasks.md §8`](destiny/tasks.md). - `when_on_keeper_dynamic_unsupported` - a `when:` on a keeper-side task (`on: keeper`) reading `register.*`/`soulprint.*`. `when:` is a Soul-side predicate: it rides the RenderedTask to a Soul runner, which evaluates it in its own flow-control sandbox — and a keeper task never reaches a Soul. Nothing in the keeper executor reads the field, so the key was accepted and dropped: the file said the step was conditional and the step ran every time. [ADR-0084](adr/0084-explicit-state-capture.md) made `core.state.` the only writer of incarnation state, which is what turns that from a cosmetic gap into an ERROR. A static `when:` (`input.`/`vars.`/`incarnation.`) is untouched and stays the working form — the keeper decides it at render, before the task is routed keeper-side. The same line as `apply_when_dynamic_unsupported` and `include_when_dynamic_unsupported`, drawn by one shared `IsStaticPredicate` so the linter cannot disagree with the render it predicts; the replacements are the condition inside the value (`${ cond ? a : b }`, which sees a previous keeper task's register) or the step on the Soul side. [`docs/keeper/modules.md`](keeper/modules.md). - `output_unsupported` - a task-level `output:`, refused on every task kind (`module:` / `apply:` / `include:` / `block:`, a child of a `block:`, a keeper-side task) and in both entities that carry tasks (a scenario and a destiny's own `tasks/main.yml`). [`docs/destiny/tasks.md §9`](destiny/tasks.md) described the key as working in both directions - a task-level `output:` fills the destiny's declared top-level fields, and an undeclared name is a validation error - and neither half was ever built: the key decoded into a struct field that the provenance walkers scanned for `register.*`/`vault(...)` refs and that nothing else read. The author got silence and read the page as a promise. Its own code, not a member of the two `_on_*_invalid` families: those mean "this key works elsewhere and is lost HERE", which would misreport an unimplemented key as a merely misplaced one. Task-level fill and the projection into `register..` are two halves of one planned slice ([`docs/destiny/output.md`](destiny/output.md), [`docs/scenario/orchestration.md §2.1.1`](scenario/orchestration.md)); when it lands the key becomes legal on a module task and this code is withdrawn. The replacement inside one entity is `register:` plus `register..`. - `apply_when_dynamic_unsupported` - a `when:` on an `apply:` task reading `register.*`/`soulprint.*`. An applier's condition is decided Keeper-side, before its destiny is rendered: a static one is evaluated at render, a dynamic one has nowhere to be answered and cannot be handed to the group either (a destiny task's flow context is built in the isolated destiny env, where `input.` and `vars.` name different things — in a scenario `vars.*` also holds the service's own `vars/`, in a destiny only its own `vars.yml`, [ADR-0082](adr/0082-service-vars.md)). Until it was refused the key was dropped and the destiny applied everywhere, the author's gate included. Same shape as `include_when_dynamic_unsupported`, at the other construct expanded before its predicate could be evaluated; the replacements are `where:` and `onchanges:`/`onfail:`. [`docs/scenario/orchestration.md §2.1.2`](scenario/orchestration.md). - `block_on_keeper_invalid` - `on: keeper` and `block:` on the same task, or `on: keeper` on a task nested inside a block. A block is a host-side grouping: `on:` picks the hosts it fans its children out to, and `keeper` is not a host. Raised at whichever level the author wrote it, because neither works - the outer form used to reach the keeper renderer with no `module:` at all and panic, the inner one is refused by the roster resolve. Keeper-side tasks go flat, in the scenario's own task list. Detected offline (`soul-lint`), at config parse, and fail-closed at render (`ErrUnsupportedDSL`). [`docs/keeper/modules.md`](keeper/modules.md). - `require_forward_reference` - a `require:` barrier naming a source that is not EARLIER in the plan (a later task, or the task's own `register:`). A barrier resolves its targets in the main flow at the awaiting task's plan position, so a source launched later is never in the set: the runner does not deadlock and does not fail, it silently does not wait. Rejecting forward edges also makes a `require:` cycle unrepresentable. Detected offline (`soul-lint`) and at config parse. [`docs/destiny/tasks.md §8`](destiny/tasks.md). Group state capture ordering ([ADR-0084](adr/0084-explicit-state-capture.md) §"The ordering guard") - the write of `incarnation.state` became a task standing at a position the author chose, so the ordering the retired `state_changes:` block enforced structurally is now linted: - `state_store_after_use` - a task **uses** a generated value (a register a `core.state.` step captures) before the capture that stores it. ERROR. Generate → store → use is crash-safe at every point; generate → use → store leaves a window in which a live host is configured with a value that exists nowhere else - the exact failure the ADR is written against. Move the capture above the consumer. [`docs/scenario/orchestration.md §7.1`](scenario/orchestration.md). - `state_stale_same_passage_read` - a task interpolates `${ incarnation.state. }` that a capture **earlier in the same Passage** writes. ERROR. An interpolated read refreshes only at a Passage boundary, so it renders the pre-capture value while the verb engine, which always reads live, would have written the new one - two mechanisms disagreeing inside one Passage, and L0 cannot reproduce the difference (the trial harness threads state task-by-task). Read the capture's `register:` instead, or put the reader in a later Passage. [`docs/scenario/orchestration.md §7.1`](scenario/orchestration.md). - `state_wide_match` - a `modify`/`remove` capture whose `match:` is absent, empty or a literal `true`, so it repatches or demolishes the WHOLE collection. **WARN**, not an error: "clear every user" is legitimate, it is just far more often a predicate the author forgot to narrow. Ported from the [ADR-057](adr/0057-state-changes-crud-verbs.md) §d fuse - the one safeguard of the removed grammar with no equivalent on the module path, since a manifest can require a param but cannot call one suspicious when it says `true`. [`docs/scenario/orchestration.md §7.1`](scenario/orchestration.md). Group derived task side (Category 1, [ADR-0087](adr/0087-task-side-derived-from-module-address.md)) - **implemented in NIM-749**; all four are emitted, and they replace `state_capture_not_on_keeper`, which enforced the opposite rule for one base. All four are core-address diagnostics only: on a **plugin** address `on: keeper` stays legal — permanently, since the side of a plugin is declared in its schema document, which a scenario cannot read (NIM-758 landed the executor without moving that boundary). Removing the key from the service repositories themselves is NIM-750. - `on_keeper_redundant` - `on: keeper` on a **keeper-side core** address (`core.bootstrap` / `core.cert` / `core.choir` / `core.cloud` / `core.soul` / `core.state` / `core.vault`). ERROR. The address already routes the step, so the key states a second time what the platform derives, and the two can disagree. The fix is to delete the line. Supersedes `state_capture_not_on_keeper`, which said the same thing about one base and in the opposite direction. - `on_covens_on_keeper_module` - a coven list on a keeper-side core address. ERROR, and **never silently ignored**: a keeper task has no roster, so the targeting the file declares cannot be honoured, and dropping it quietly is the defect class [ADR-0084](adr/0084-explicit-state-capture.md) F-D removed for `when:` - the file says the step is targeted, the step ignores it. - `on_keeper_on_soul_module` - `on: keeper` on a **Soul-side core** address. ERROR, and a **separate code** from `on_keeper_redundant` rather than a shared "bad `on:`": the two are opposite mistakes with opposite remedies - "delete the line" against "this step cannot run where you sent it". Scoped to core modules the binary carries a DECLARATION for, which is narrower than the `core` namespace on purpose: the keeper's registry is built from its Deps, so a `core.*` address with no declaration can still be a registered keeper-side module (the integration harness registers `core.probe.*` exactly that way). The cost is one named blind spot - `core.augur` is Soul-served and declaration-less, so the key on it is still accepted offline and still dies mid-run as `unknown keeper-side module`. - `keeper_module_in_destiny` - a keeper-side core address in a **destiny** task. ERROR. `renderKeeperTask` exists only on the scenario path and a destiny task is Soul-side by construction, so such a step reaches a host and fails there. The retired `state_capture_not_on_keeper` was carrying this duty for `core.state` alone; this code generalises it to all seven keeper-side bases. It fires for a destiny's own task file AND for a body it reaches through `include:` (`ExpandIncludesInDestiny`); a scenario's included body is untouched, where a keeper-side address is correct. The offline half is what matters: the L0 trial folds a step by its module ADDRESS, so such a case predicts its result exactly as a routed one does and goes green on a plan no run can execute. Group register naming ([ADR-0084](adr/0084-explicit-state-capture.md) amendment 2026-08-26) - the keeper-side per-host accessor lives at a fixed field inside the `register` root, so exactly one register name stopped being the author's to take: - `own_namespace_fence_unchecked` - `soul-lint validate-scenario` was given no `--service-name`, so the own-namespace Vault fence ([ADR-0083](adr/0083-declared-secret-state-fields.md) §7) could not run. A **warning**, not an error: linting a scenario standalone, without knowing which service will own it, is ordinary, and refusing it would teach operators to drop the linter. What is not acceptable is running the check silently against nothing — which is what happened until NIM-726, when the name came from `service.yml` and `ScanOwnNamespaceVault` opened with `if service == "" { return nil }`. The retired `service_name_reserved` lived beside it and went with the same field: it could only judge a name the manifest stated, and a service is now named once, at registration, where `serviceregistry.ErrReservedName` -> 422 is the sole enforcement point. - `secret_field_reserved_state_name` - a `state_schema` field that declares a secret (`type: secret`, scalar or inside `items`) and is named `tls`. Keeper issues an incarnation's TLS material to `///tls/{cert,key}` ([`keeper/internal/certissue`](../keeper/internal/certissue/issue.go)), which a collection secret on `tls` with element key `cert` reproduces exactly - and unlike the service name, that path lives INSIDE the service's own namespace, where no rule about service names can reach it. The element key is state data and cannot be constrained, so the fence is on the field name, the last static point there is. Only a field declaring a secret is checked: a plain `tls:` object of ports and cipher lists derives nothing and is untouched ([ADR-0083](adr/0083-declared-secret-state-fields.md) amendment 2026-08-26, NIM-706). - `register_name_reserved` - a task carries `register: hosts`. Such a register is unreadable from either side: the keeper-side CEL context injects the per-host root at exactly that field and it wins there, so a keeper task reads the SID-keyed map instead of the author's payload, and on a host task `register.hosts` is refused at compile as the keeper-only accessor whether or not the register exists. Rename the register; to read one register across all hosts from an `on: keeper` task use `register.hosts.`. Parse level rather than a soul-lint rule, so it also fires inside a resolved `include:` at the included file's own line ([ADR-0084](adr/0084-explicit-state-capture.md) amendment 2026-08-26, [`docs/scenario/orchestration.md §7`](scenario/orchestration.md)). Group declared secret fields (Category 1, [ADR-0083](adr/0083-declared-secret-state-fields.md) §1, [`shared/config/secret_field.go`](../shared/config/secret_field.go)) - the rest of the family whose reserved-name member is described just above (NIM-752 catalogued them; they were emitted all along). Every one of them is an **error**, and they share one ground: the Vault path is *derived* from `(service, incarnation, state field, key)`, so a declaration that cannot yield a well-formed path, or that writes a constraint nothing will ever enforce, is refused rather than accepted and quietly ignored. ★ They are raised at **two** points, not one, and the difference is visible to the author: what is written inline is judged at load, positionally, and carries a line and a column (`validateSecretFields`); what reaches through a `$type` reference cannot be judged until the caller resolved it, so it is re-checked afterwards ([`ValidateStateSchemaSecrets`](../shared/config/service.go)) and carries a YAML path with no line. An issue the parsed schema already produced is dropped from the second pass — one mistake stays one diagnostic, and the positional copy is the better of the two: - `secret_field_unsupported_location` - a `type: secret` node somewhere other than the two legal positions: a top-level property of `state_schema`, or a property of a top-level array's `items`. The derived path has exactly one field segment and one optional key segment, so a deeper position has nowhere to go in it. It is the fail-closed floor of the group - the check collects every declared node and reports the ones no legal shape claimed. - `secret_field_name_unsafe` - the state field name (scalar shape), or the field name or the property name (collection shape), is not a safe Vault path segment: `ValidVaultPathSegment` admits letters, digits, `_` and `-` only, because the name *becomes* a path segment. Two emission sites, one code - the collection shape checks both of its names against the same rule, and which of the two offended is in the message rather than in a second code. - `secret_field_key_on_scalar` - `key:` written on a scalar secret. `key:` addresses one element of a collection, and a scalar has one value rather than one per element, so the key would name nothing. - `secret_field_key_required` - the mirror: a secret inside a collection's `items` with no `key:`. Without it no element can be addressed, so the whole collection would derive a single colliding path. - `secret_field_key_unknown` - `key:` names no sibling property of the element. - `secret_field_key_not_string` - `key:` names a sibling that is not `type: string`. Its value becomes one segment of the Vault path, and only a string can be one. - `secret_field_required` - `required:` on a `type: secret` node, `true` **or** `false`. The ground is satisfiability, not vocabulary: the value lives in Vault and never enters state, so no state instance can ever satisfy the requirement. ★ The check runs deliberately **ahead** of the closed-grammar one ([ADR-0086](adr/0086-one-schema-dialect.md) §7) - `required` is a key outside `type`/`key`/`label`, so `secret_field_unknown_key` would otherwise claim it first and answer a satisfiability problem with a vocabulary complaint, pointing the author at the wrong fix. - `secret_field_unknown_key` - any other key outside the closed `type`/`key`/`label` grammar (`default`, `enum`, `pattern`, `min_length`/`max_length`, `secret`, `prefill_from_state`, `required_when`, `description`). All offenders are reported in **one** message in the list's fixed order - an error text that varied run to run on the same input would not be diagnosable. - `secret_collection_key_duplicate` - not a `secret_field_*` name, and deliberately so: it is a **scenario** diagnostic, not a manifest one ([`shared/config/secret_collection_keys.go`](../shared/config/secret_collection_keys.go)). Two elements of a literal collection in a `core.state.` task address one declared secret with the same `key:` value, so both derive the same Vault path and the second silently keeps the first one's secret - two accounts holding one credential. Emitted only for literal elements whose key is a string and a valid path segment; a templated key is unknowable statically and is skipped. Group whole-service walk (`soul-lint validate-service-tree`, NIM-753) - diagnostics about the WALK over a service repository rather than about any one document, plus the one that fires when a check crashes on a document: - `service_tree_no_scenarios` - `soul-lint validate-service-tree` walked a tree that declares no scenario in EITHER auto-discovery channel (`scenario//main.yml`, `upgrade//main.yml`). A **warning**: the service parses and registers and can never be run, which is a defect of the repository and not of any file in it, so it does not fail the lint - but a walk that reported one part and looked complete would be the silence the whole-service mode exists to remove (NIM-753, [`docs/soul-lint.md`](soul-lint.md)). - The `migration_*` family - the state-schema ladder, read by the same scan the keeper derives the version from (`config.ScanMigrationLadder`). `migration_layout_retired` (the pre-NIM-735 flat step), `migration_step_name_invalid` (an entry that is not a `_` directory - including a step-named FILE), `migration_step_number_invalid` (`001_...`: version 1 is the empty ladder, no step leads to it), `migration_step_duplicate`, `migration_step_main_missing`, `migration_chain_broken` (a gap, one diagnostic per missing version), `migration_ladder_unreadable` (`migrations/` is not a listable directory) and `migration_version_key` (a step stating its own place). All ERRORS: since NIM-736 the ladder IS the version, so each of these moves or truncates it. They replace `migrations_unchecked`, the hint that named the gap while the ladder was checked by a script in each service repository. - `lint_internal_panic` - a check crashed on this file during a `soul-lint validate-service-tree` walk. An **error**, so the run is red and nothing downstream reads the tree as clean, but it accuses the linter rather than the author. It exists because a panic is the ultimate early exit: uncaught, it would abort the process over a half-written report and leave every part after it unchecked, which is the one route by which the whole-service mode could still let one broken part hide the rest. Each part therefore runs behind a recover, and the panic value travels in the message so the crash is reportable with the file that provoked it. Group stratify/passage (Category 1, [staged-render](#domain-entities) [ADR-056](adr/0056-staged-render-passage.md)) - fail-closed diagnostics of task stratification by register dependency (probe→where; the consumer task gets into Passage strictly AFTER the probe emitter): - `register_dependency_cycle` — register-dependency in a circle (probe A reads `register.B`, probe B reads `register.A`): there is no topological order by Passage, the run is rejected. - `within_block_register_dependency` - a child of `block:` reads a register issued by a neighboring child of the **same** block: not possible on render. `block` is atomic by Passage (the entire fan-out is one Passage), peer-register is available only to Soul-side AFTER probe, and `where`/`params`/`vars`/`apply.input` are resolved by Keeper-side BEFORE dispatch → selecting hosts by outdated/external register silently (silent-wrong-target). Flow-control `when` (Soul-side per-task gating after FC-5 narrow-fix) is **not** included here - within-block `when: register.peer` is valid (peer-probe is executed by the same ApplyRequest BEFORE the consumer, Soul sees the peer-register in the accumulated slice of the block). Probe and consumer must be different top-level tasks (then Stratify will routinely separate them by Passage). Detected offline (`soul-lint validate-scenario`) and runtime insurance. Symmetry with the `_on_block_invalid` family (block-invalid failure at block-level). - `cross_passage_requisite_unsupported` - the task refers through requisite (`onchanges:`/`onfail:`/...) to register, whose source is in another Passage, with an inaccessible audit log (`AuditReader`): cross-passage gating requires reading CHANGED/FAILED facts from the audit, without it the keeper cannot determine the fact silently → fail-closed reject (runtime, [ADR-056 R3](adr/0056-staged-render-passage.md)). - `cross_passage_when_unsupported` - the task gates `when:`/`changed_when:`/`failed_when:` by register issued in **earlier** Passage: flow-control = Soul-side per-task gating ([ADR-012(d)](adr/0012-keeper-soul-grpc.md)), sees only **its register** Passage → cross-passage register not available (other `ApplyRequest`) → `no such key` silent, task FAILED. `where:` (Keeper-side targeting) cross-passage is capable, `when:` is not (legitimate asymmetry). Treated with `where:` for cross-task register targeting or `register.self` for same-task gating. Detected offline (`soul-lint validate-scenario`) and runtime insurance ([ADR-056 amend 2026-06-21](adr/0056-staged-render-passage.md), FC-5). Full catalogs by category live **by place of use**, and not in this file: - Category 1 (parser / validation) - [`docs/keeper/config.md`](keeper/config.md), [`docs/keeper/plugins.md`](keeper/plugins.md), [`docs/keeper/rbac.md`](keeper/rbac.md). - Category 2 (HTTP / RFC 7807) - [`docs/keeper/operator-api.md → Error format`](keeper/operator-api.md). - Category 3 (runtime task/plugin) - `proto/keeper/v1/` (field `TaskError.reason` in `apply.proto`), [`docs/keeper/plugins.md → Host behavior after handshake`](keeper/plugins.md). ## DSL syntax ### Expression markers | Marker | Destination | |---|---| | **`${ expr }`** | CEL expression in YAML string context (`params:`, `vars:`, `apply: input:`, `on:` literals, migration `set:`, etc.). The parser looks for exactly the sequence `${`; a single `$` is not a marker. Top-level expression keys (`where:`, `when:`, `changed_when:`, `failed_when:`, `until:`) - the entire line is treated as a CEL expression **without wrapper** `${ … }`. Specification - [templating.md](templating.md), selection fixation - [ADR-010](adr/0010-templating.md). | | **`$type: `** | input-DSL directive of a link to a reused named type from the `types:` (`service//types.yml`) section. Placed as a separate field or under `items:` for an array; The service-level is resolved at the input stage (NOT render, NOT CEL). NOT similar to `${ … }` (different mechanism). Fixation - [ADR-062](adr/0062-input-types.md), spec - [input.md → "Reused named types"](input.md#reusable-named-types-types--type). | ### Value formats | Name | Format | |---|---| | **`duration`** | Go-`time.ParseDuration` (`1s` / `500ms` / `1h30m`) + extension `d` for days (`30d` = 720h, long-lived retention convenience). The composite form `1d2h` is **not** supported. Used for all duration fields in configs (`keeper.yml`, `soul.yml`). The canonical commit is [`docs/keeper/config.md → Type conventions`](keeper/config.md). | ## Metrics and OTel resource-attributes Normalized [ADR-024](adr/0024-observability.md#adr-024-observability-prometheus-primary--otel-bridge); full convention spec - [`docs/observability.md`](observability.md). ### Namespace-metric prefixes Metrics are distinguished by component **name prefix** (not label `component=`). Names - Prometheus-convention: `snake_case`, unit in suffix (`_seconds` / `_bytes`), `_total` for counters. | Prefix | Who is exhibiting | Example | |---|---|---| | **`keeper_*`** | Keeper-side metrics. | `keeper_http_requests_total`, `keeper_grpc_streams_active` | | **`soul_*`** | Soul-side metrics. | `soul_apply_tasks_total` | The specific catalog of metrics is not standardized in the dictionary - it is filled in after the implementation of subsystems (like [Audit-events](#audit-events)), each metric follows the conventions of [observability.md §2](observability.md#2-namespace-of-metrics-soul_--keeper_). ### Config entity `metrics.auth.basic` (keeper) Opt. HTTP Basic-auth on keeper-`/metrics` ([ADR-024](adr/0024-observability.md#adr-024-observability-prometheus-primary--otel-bridge), [keeper/config.md → metrics](keeper/config.md#metrics)). | Key | Type | Meaning | |---|---|---| | `metrics.auth.basic.enabled` | `bool` | Enable basic-auth. | | `metrics.auth.basic.username` | `string` | Username (required for `enabled`). | | `metrics.auth.basic.password_ref` | `vault-ref` | `vault:/` to a secret with field `password`. Plaintext password is prohibited (vault-ref only). | **There is no `metrics.auth` on Soul** - the Soul agent does not have a vault client ([ADR-012](adr/0012-keeper-soul-grpc.md)) for resolution `password_ref`; Soul-`/metrics` is protected by loopback-bind (`metrics.listen` = `127.0.0.1`). Auth for Soul - deferred. ### Custom OTel resource-attributes Domain identity of the instance in traces/metrics-export. Prefix `soulstack.` — namespace of custom project attributes (does not conflict with OTel semconv). | Attribute | Meaning | Applies to | |---|---|---| | **`soulstack.kid`** | [KID](#identifiers) (Keeper ID). | `service.name="keeper"` | | **`soulstack.sid`** | [SID](#identifiers) (Soul ID = FQDN). | `service.name="soul"` | Standard `service.name` = `"keeper"` \| `"soul"` (binary name according to the dictionary). The high cardinality of KID/SID is the reason to put them in OTel resource-attrs, **not** in Prometheus metric-labels ([observability.md §2.2](observability.md#21-naming-convention), [§3](observability.md#3-otel-resource-attributes)).