# ADR — Soul Stack Architecture Decision Index The ADR (Architecture Decision Records) catalog. The source of truth for high-level architecture is [`docs/architecture.md`](../architecture.md); ADRs are gradually being split out of it into separate files (one ADR = one file, `NNNN-.md`). Not all of them have been split out yet — for the ones that haven't, the link points to an anchor inside `architecture.md`. Statuses: **active** — a decision in effect; **amended** — in effect, but supplemented by Amendment block(s); **superseded** — replaced by a newer ADR. **Numbering — a new ADR is written WITHOUT a number (decided 2026-09-01).** It is authored as `docs/adr/draft-.md` and cited by that path for as long as its ticket is in flight; the number is stamped **in the release worktree, at squash-merge** — the one point where the release tip is known and tickets land one at a time — by renaming the file to `NNNN-.md` and writing its index row with that number. The `NNNN-.md` filename format and the `ADR-062` citation form are **unchanged**; only the moment of allocation moved. What it fixes: two parallel sessions each read the same "highest number" from their own worktree and both take the next one, and the clash surfaces only at rebase — in the filename and in the index row at once, which is the one place a merge cannot silently pick a side. That is not hypothetical: the reservations below were the workaround for it. Renumbering the existing ADRs is **not** decided and is not proposed here. **83 files** `NNNN-.md` in total, the highest number is **0087**. Numbering **has gaps**: numbers 0034, 0036, 0037 are unused, and 0071 is reserved for in-flight design work (see the note at the end of the index). In other words, "0001...0087" is not a contiguous range. A reservation is now the exception rather than the mechanism: under the convention above an in-flight ADR holds no number at all, and any `draft-.md` awaiting one is listed in [Drafts — no number yet](#drafts--no-number-yet) after the table. | ADR | Title | Status | Summary | |---|---|---|---| | [001](0001-language-go.md#adr-001-implementation-language--go) | Implementation Language — Go | active | Go as the language for all binaries of the system. **Split into its own file.** | | [002](0002-transport-grpc-ha.md#adr-002-transport-keeper--souls--grpc-bidirectional-stream-over-mtls-ha-keeper-cluster) | Transport Keeper ↔ Souls — gRPC bidi over mTLS, HA cluster | amended | Bidirectional stream over mTLS, initiated by Soul; Keeper is a stateless cluster (presence/shedding are amendments). **Split into its own file.** | | [003](0003-destiny-format.md#adr-003-destiny-format--yaml-with-a-typed-schema-cuejson-schema) | Destiny Format — YAML with a typed schema | amended | YAML + JSON Schema/CUE, templating as a separate phase. **Split into its own file.** **Amendment 2026-09-01 (NIM-741, [One schema dialect](0086-one-schema-dialect.md)): the "JSON Schema" half no longer describes `state_schema`.** It still describes the destiny format; `state_schema` in `service.yml` moves to the **same dialect as `input:`** — a map `` → schema, no root `type: object`/`properties:` wrapper, `required: true` on the field, snake_case vocabulary. This closes the divergence [ADR-062](0062-input-types.md) rejected `$ref` over ("a second schema DSL alongside our own input DSL") in the last place it was still shipping. Design only, not implemented: NIM-742/743/744. | | [004](0004-binaries.md#adr-004-binary-layout--keeper-soul-soul-lint-push-mode-as-a-module-inside-keeper) | Binary Layout — keeper / soul / soul-lint | active | Three (later five) artifacts; push is a module inside keeper. Amended 2026-07-26: `soul-legion` ships. **Split into its own file.** | | [005](0005-storage-postgres.md#adr-005-keeper-state-storage--postgres) | Keeper State Storage — Postgres | active | Postgres — the sole cold-state storage. **Split into its own file.** | | [006](0006-cache-redis.md#adr-006-cache-and-coordination--redis) | Cache and Coordination — Redis | amended | Redis — heartbeat, lease, pub/sub, the Reaper leader; Souls presence + Conclave; cluster-mode SSE/apply-events routing with a sharded channel `events:shard:` K=256 (amendments). **Split into its own file.** | | [007](0007-versioning-git-ref.md#adr-007-artifact-versioning--via-git-ref-not-a-manifest-field) | Artifact Versioning — via git ref | amended | An artifact's version = git ref, not a field in the manifest. **Split into its own file.** **Amendment 2026-09-01 (NIM-735): the `state_schema_version` exception is RETIRED** — the manifest key leaves `service.yml` and the number is derived instead, as the top of the migration ladder (`migrations/_/`; an empty `migrations/` = version 1). It removes an exception rather than adding one, restoring this ADR's own one-source-of-truth rule to the state schema. ⚠ Only the **manifest key** goes: the `incarnation.state_schema_version` column and the API/MCP field of the same name stay. | | [008](0008-coven-stable-tags.md#adr-008-coven--stable-logical-tags-only) | Coven — stable logical tags only | amended | Coven = stable tags; role is NOT coven; environment = a special case of coven (amendments). **2026-07-17 (NIM-124): `incarnation.name` is NO LONGER a Coven — membership is a first-class relation (`incarnation_membership`); the `on:` resolver / RBAC / bulk-select decouple from `coven==name`.** **2026-07-28 (NIM-209): membership gets an OPERATOR path** — `POST`/`DELETE`/`GET /v1/incarnations/{id}/members` + MCP twins, rights `incarnation.bind-member` / `.unbind-member` (read rides on `incarnation.get`). NIM-124 named exactly one writer — the in-run bind act `core.soul.registered` — so an already-onboarded host could not be put into an incarnation from outside a run, and a create scenario over a ready roster (`create_from_souls`) was unreachable through the API (the e2e harness seeded the row with direct SQL). ★ **Authorization is TWO gates:** the incarnation selector by path-`{name}` AND every target SID inside the caller's soul visibility (`soul.list` purview) — an `incarnation=`-scoped predicate is satisfied without looking at the host, so gate (a) alone would let a holder pull ANY host into their incarnation and reach it with `incarnation.run`; all-or-nothing, never a silent partial bind. Operators may bind only a `connected` host; the keeper-internal act is exempt (it binds `pending` hosts it just created). Both directions idempotent (`bound` vs `already_member`; unbind of a non-member → `removed:false`). **2026-07-30 (NIM-330): the declared role is a Voice, not a spec field** — `incarnation.spec.hosts[]` is REMOVED (the "Decision" line naming `spec.hosts[].role` as its home is superseded); the declared/actual split is untouched, only the storage moves to `incarnation_choir_voices.role`. **Split into its own file.** **2026-08-03 (NIM-410, [ADR-0082](0082-service-vars.md)): the assembly order collapses to one lexical layer** — `default → os → coven → incarnation.spec` is replaced by every `*.yaml` directly inside `/vars/`, sorted lexically from `00-base.yaml`; the `os/`/`coven/` directory layers are deleted (implemented, used by zero shipped examples) and conditionality moves into `vars/_stack.yaml`, while `incarnation.spec.essence` is removed outright (two readers, no writer — a fleet forks the service repo instead). "essence is role-agnostic" stands. The coven axis of service vars is NOT weakened: it selects layers of the incarnation's OWN config by `incarnation.covens` through a `foreach:`, and the NIM-248 guard is retargeted onto that step rather than dropped — it never read a host label, which is why [NIM-281](0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited) leaves it alone. **2026-08-05 ([NIM-280](0008-coven-stable-tags.md#amendment-2026-08-05-nim-280-a-rules-subject-reads-both-levels--targeting-only)): a rule's SUBJECT reads both levels — targeting only.** A Vigil / Decree / Rite subject became one of four dimensions (`sid` / `incarnation` / `coven` / `trait`), and its two label dimensions match over the host's labels ∪ those of every incarnation it belongs to, for the duration of one match. `souls.coven[]` / `souls.traits` are unchanged, no other reader sees the union, and the RBAC scope predicate still reads the row's own column — ★ targeting expands, authorization never does. ⚠ Consequently labelling an incarnation widens existing `coven`-subject rules, and unbinding a host withdraws the rules that reached it that way. | | [009](0009-scenario-dsl.md#adr-009-scenario--the-full-destiny-task-dsl-the-boundary-with-destiny-is-a-recommendation) | Scenario — the full destiny task DSL + orchestration | amended | Scenario = all destiny blocks + the orchestration delta; cross-incarnation at the Voyage layer + an optional task field `id:` + the destiny-pass CEL env sees read-only `soulprint.self.*` (hosts/where are scenario-only); per-task granularity `serial:`/§8 — via the staged-render Passage ([ADR-056](0056-staged-render-passage.md)); the `state_changes` grammar (§7.1 `sets`/`appends`/`modifies` map) extended to an ordered list of CRUD verbs ([ADR-057](0057-state-changes-crud-verbs.md)); top-level `compute:` — computed run vars (host-invariant, visible in `apply: input:` and `state_changes`); var→var within the `vars.*` layer — eager-topological resolve (a cycle/unknown-ref → an error, YAML order doesn't matter, file↔task cross-layer isolation preserved); conditional `when:` on an include-task — conditional-include = a render-phase group-drop (a real exclusion of the group from the plan, only a static `input.`/`essence.`/`incarnation.`/`vars.` predicate; dynamic → `include_when_dynamic_unsupported`; coexists with block-static-skip); a mechanism for several named create scenarios (a per-scenario `create: true` declaration, the hardcoded `create` default removed, choosing `create_scenario` is mandatory when the set is non-empty, a bare incarnation when it's empty — `created_scenario` NULLABLE, migration 090); `extends: ` — inheriting a shared service-level contract of sections (`input`/`compute`/`state_changes`/`validate`) from the `covenant.yml` at the service-repo root: add-only shallow merge, fail-closed `section_key_conflict` (deep merge/last-wins rejected), `form:` is not merged, covenant is appended first, resolution in `LoadScenarioManifestResolved` BEFORE consumers (isomorphic to `$type`/`include`), one extends / no recursion / only 4 sections, forward-compat (optional), removes the create↔create_from_souls drift (amendments); **destiny-input parity (amendment 2026-07-26)** — `validate:` becomes a top-level `destiny.yml` key and the destiny render pass runs the SAME input contract as scenario (defaults → `required`/`required_when` → value validation → `validate:` rules) via one shared composition, enforced strictly from the first release (breaking for callers that were silently out of contract); **within-block `include:` (amendment 2026-07-26)** — the block pilot restriction is lifted: `ExpandIncludes` recurses into `block:`, so an include child splices in place at any nesting depth (cycle/depth detection and the conditional-include conjunction cascade through the block unchanged), in BOTH layers at once since the expander is shared; a conditional include inside a block composes with the block's `when:` as two ordered axes (group-drop first — physical removal, then static-when skip — placeholder keeping index/`register:`), decided once per pass via `keepIncludeGroup`; the render guards stay as defense-in-depth `ErrUnexpandedInclude`; **intra-host concurrency (amendment 2026-07-27)** — the task core's concurrency key is `async:` (fire-and-forget, not a grouping mechanism) and `parallel:` becomes reserved/unrecognized for a future group-with-join, with the barrier model and the two Passage-related boundaries fixed in [ADR-0075](0075-intra-host-async-tasks.md); **a roster-reading assert has no pre-flight point at create (amendment 2026-07-28)** — the gate stands before `incarnation.Create` and NIM-124 made membership FK that row, so `soulprint.*` there is not empty but impossible and every create carrying a topology guard answered 422 unconditionally; such asserts are deferred to the render fail-safe (single-point, not two-point), asserts over `input.`/`essence.`/`incarnation.` keep their 422; **the pre-flight gate moves to where the roster IS real (amendment 2026-07-28, NIM-270)** — it now also runs on the explicit-run path (`POST .../scenarios/{scenario}` + the MCP twin), the one place a topology assert can be answered, with ONE predicate (`planBuildsRoster`, shared with the `no_hosts` bypass) deciding when the roster in front of the gate is not the assert's subject; a contract change on that endpoint (202 → possibly 422), plus pre-flight now resolving the RUN's own essence rather than a synthetic incarnation (NIM-271) and a `soul-lint` WARNING naming the create-path deferral at authoring time (NIM-272, `assert_roster_deferred_on_create`); **a group construct's requisites compose as OR and widen (amendment 2026-07-30, NIM-287)** — `block:` and `apply:` merge their `onchanges:`/`onfail:`/`require:` into every task of the group as a union of names, which the runner reads as OR, so a descendant that already had a requisite runs when EITHER source changed, while `when:` on the same construct merges by AND and narrows; the asymmetry is stated rather than removed, since AND is only definable between inheritance levels (a name resolves to several indices after NIM-246) and would need a grouped wire field, a capability gate and a rule for an all-filtered bracket — deferred to NIM-351, against a corpus with zero uses of the combination. **Split into its own file.** **Amendment 2026-08-03 (NIM-410, [ADR-0082](0082-service-vars.md)): the scenario context loses the `essence` root** — a service's defaults move into `vars/` and read as `vars.*`, giving one flat ladder `vars/*.yaml` → scenario `vars:` → `block:` → task with **no operator rung** (`incarnation.spec.essence` removed, no successor); `essence` leaves the reserved-name lists for `loop:`/`compute:`/task-`vars:` (`vars` was already reserved there, so nothing that validated starts failing), a static `when:` keeps `input.`/`vars.`/`incarnation.`, and the `apply:` boundary asymmetry becomes visible rather than camouflaged — the same one that already forced `apply_when_dynamic_unsupported`. New soul-lint WARNING `vars_shadows_service_var` for a local taking over a service var's name; **NIM-694: `include:`/`extends:` take one subdirectory level** (a shared segment alphabet `[a-z_][a-z0-9_-]*`, `.` deliberately outside it so `..`/absolute/hidden are unrepresentable, securejoin kept as the second line), a `_`- or `.`-prefixed directory under `scenario/`/`upgrade/` is **not a scenario** and is skipped silently even with a `main.yml` (both walkers), and soul-lint resolves the service-level tier offline — an unresolvable include is now an ERROR instead of the `stage_include_unresolved` hint (**amend 2026-09-02 / NIM-716**: outside a service tree the kept hint covers a target that was not FOUND and nothing else — an error out of a body that resolved locally and was read is that body's own, at its own coordinates). **NIM-699 ([ADR-0084](0084-explicit-state-capture.md)): `state_changes:` leaves the orchestration delta** — a state write is a keeper-side task `module: core.state.` + `on: keeper` standing at its own position in `tasks:` and landing at that step; the verb grammar survives, the block does not, the cross-host barrier is unchanged, ordering becomes the author's (soul-lint `state_store_after_use` / `state_stale_same_passage_read` ERRORs, `state_wide_match` WARN), a capture sees the keeper register bucket only, and `foreach` is dropped because a step has `loop:`. **Amendment 2026-09-01 (NIM-741, [One schema dialect](0086-one-schema-dialect.md)): `state_schema` is written in the input DSL, so the 2026-08-19 secret declaration changes spelling.** A map `` → schema — no root `type: object`/`properties:` wrapper, `required: true` on the field, snake_case vocabulary; the secret declaration's substance (two shapes, `key:` sibling, derived path, closed node grammar) is untouched, every corpus site that spells it moves. ★ **The wrapper is refused at the ROOT only** — a nested object field still declares `type: object` with fields under `properties:` — while the list form `required: [names]` leaves the **whole** dialect, `types.yml` included (28 flow-form sites across both dialects; the block form is authored nowhere). Breaking with **no transition window**, and the old envelope is refused **by name** (`state_schema_legacy_json_schema_form`) rather than left to parse: read as the new dialect it is two state fields called `type` and `properties`, which moves every real field a level down and loses every declared secret with no error raised. Design only — NIM-742 (engine), NIM-743 (`soul-lint list-secret-paths`), NIM-744 (`examples/` + WB redis). | | [010](0010-templating.md#adr-010-templating-engine-cel-for-yaml-expressions-go-texttemplate-for-files) | Templating engine: CEL + Go text/template | amended | CEL — YAML expressions, text/template — files; the boundary is by file; the destiny-pass CEL env additionally registers read-only `soulprint.self.*` (amendment 2026-06-18); var→var within the `vars.*` layer via the CEL-AST `Engine.VarRefs` (eager-topological, amendment 2026-06-24). **Split into its own file.** **Amendment 2026-08-03 (NIM-410, [ADR-0082](0082-service-vars.md)): the CEL root `essence.*` merges into `vars.*`** — removed from the env rather than renamed beside it; `core.file.rendered`'s `render_context` root becomes `{vars, self, role}` (+ the conditional `input` of 2026-06-26), which costs nothing at the seam since **no `.tmpl` in `examples/` reads `.essence`**; `default(x, y)`'s anchor roots lose `essence` and keep `input`/`vars`/`incarnation`/`register`/`soulprint`. Masking, seal and `injectInput` untouched. **Amendment 2026-09-01 (NIM-741, [One schema dialect](0086-one-schema-dialect.md)): all three key literals of the §7.4 masking walk are wrong after `state_schema` moves to the input DSL, and the failure is fail-OPEN.** Layer 1 (schema) recurses *"through `properties`/`items` and nested `properties` under `additionalProperties`"* — at the root there is no longer a `properties` key (the root IS the field map), `additionalProperties` is now `additional_properties`, and `items` survives only inside a subtree nothing reaches. ⚠ `keeper/internal/incarnation.CollectStateSchemaSecrets` hardcodes the three strings (`secret_schema.go:82`,`:89`,`:92`) and returns an **empty** `SecretPathSet`, which is byte-identical to the honest *"this service declares no secrets"* — and the read path is built to tolerate exactly that (`keeper/internal/api/handlers/incarnation_secret_schema.go:26-29`: best-effort assembly, degrade to vault+regex, GET deliberately does not fail). So a `secret: true` state field stops being masked on `GET /v1/incarnations/…` with **no error raised anywhere**, and `isSecretNode` (`:116-122`) matches `type: secret` too, so ADR-0083 §1's deliberate defensive entry goes with it. **The walk therefore migrates in the same commit as the parser (NIM-742), not after it** — a declarative masking layer that fails open is worse than an absent one. A third reader fails the other way and is recorded for contrast: `stateop.schemaFieldType` (`keeper/internal/stateop/ops.go:489-504`) also reads the root `properties`, but `collectionKind` consults the schema only for a field ABSENT from state, so the first `add` into an unmaterialized collection **fails closed and loudly** with a message blaming the schema's content rather than its dialect. ⚠ **`append` on the same reader fails OPEN** — `applyAppendOp`'s `:351` guard (`ops.go:343-357`) exists only to reject a map field, never fires once `schemaFieldType` returns `""`, and `:354` materializes a **list** where the schema declares a map with no diagnostic: the one silent state-corruption path in the verb engine, and it moves with the walk (NIM-742). Its declared twin `trial.collectionKind`/`schemaFieldType` (`keeper/internal/trial/diff.go:28-58`) is **dead code — called by nothing**, so L0 neither reproduces nor catches it. The 2026-06-24 `additional_properties`-node limitation carries over unchanged; `input_schema` and the manifest `InputParamDef.Secret` do not move. | | [011](0011-go-layout.md#adr-011-go-code-layout-gowork-with-per-side-modules) | Go code layout: go.work with per-side modules | amended | go.work with seven per-side modules. **Split into its own file.** **Amendment 2026-09-01 (NIM-757): `proto/plugin/` loses a service contract and `sdk/` loses a subpackage.** The separate CloudDriver contract is removed — a cloud driver becomes an ordinary SoulModule plugin declaring `side: keeper` ([ADR-017](0017-keeper-side-core.md) / [ADR-020](0020-plugin-infrastructure.md)) — so `clouddriver.proto` + its committed generated Go go, `sdk/`'s subpackage list drops `clouddriver/` (a whole module directory, a separate breakage for plugin authors from the proto one), and `keeper/internal/` sheds `provider/` / `profile/` / `coremod/cloud/` / `pluginhost/clouddriver.go`. `go.work`, the module count and the Soul-isolation invariant do NOT move. ⚠ Carries a **factual correction** rather than propagating the drift: `proto/plugin/v1/` holds **four** services today, not the three this ADR names — `SoulBeacon` arrived as V5-2 with the [ADR-030 amendment 2026-05-26](0030-vigil-oracle.md#amendment-2026-05-26-s5-closure) and was never absorbed here — so the count after the cut is **three**. ⚠ `make check-gen` does NOT catch an orphaned `.pb.go`: `Makefile:22` enumerates the `.proto` sources with `find` and protoc never deletes stale outputs, so the generated files go by hand in the same commit. Implemented in NIM-761 (2026-09-04). **Amendment 2026-09-05 (NIM-776): the Operator API wire types live in `shared/api/wire`, not in `keeper/internal/`.** `internal/` is a compiler-enforced wall, so every consumer outside the keeper module — `soulctl` and the four test-harness modules — was FORCED to declare its own struct with hand-written json tags, and `encoding/json` makes that second description diverge in silence (NIM-729 renamed `name` → `id` across ten registries and left `soulctl push-provider create` dead against `additionalProperties:false` with nothing failing). The bodies and the contract enums move to `shared/api/wire`, which the Consequences already permit in as many words (*"Allowed in `shared/`: … data types and enums"*); keeper keeps the whole server half and names them through aliases. ⚠ **NOT `sdk/api`**, which this ADR names for a future external client SDK: `sdk/*` is Apache 2.0 under [ADR-016(o)](0016-parity-license.md) while the core is BSL 1.1, so putting the contract there would relicense it — a licensing decision on its own merits, not a side effect of a refactor. The package imports **nothing outside the standard library**, so no dependency reaches `soul` through `shared`. The three enums the UI references by `$ref` keep their `huma.SchemaProvider` on a keeper-local shim, because that interface is a METHOD and Go allows one only in the declaring package; `docs/keeper/openapi.yaml` does not move. Guard: `shared/api/wire/single_source_test.go`. | | [012](0012-keeper-soul-grpc.md#adr-012-keepersoul-grpc-contract-one-eventstream-with-oneof-keeper-side-render-forward-compat-only-add) | Keeper↔Soul gRPC contract | amended | One EventStream with oneof, Keeper-side render, forward-compat only-add; a back-link apply_run; the dispatch model "one ApplyRequest per host" → "N per host by Passage" ([ADR-056](0056-staged-render-passage.md)) (amendments). **Amendment 2026-07-27 (NIM-188, [ADR-0074](0074-interactive-console-pty.md)):** a fourth RPC `ConsoleStream(stream ConsoleFromSoul) returns (stream ConsoleToSoul)` — the console plane moves off EventStream onto its own bidi stream per session (same mTLS listener, same connection, the `FetchModule` precedent), because on EventStream pty output shares one write mutex with `TaskEvent`/`RunResult` and keystrokes share the per-SID outbound queue with apply dispatch; `ConsoleOpen` stays on EventStream (only a client may dial); only-add, both compat directions live (an old Soul is served over EventStream, a new Soul against an old Keeper falls back on `Unimplemented`). **Split into its own file.** **Amendment 2026-08-03 (NIM-410, [ADR-0082](0082-service-vars.md)): `flow_context` loses its `essence` key** — `{input, vars, essence, incarnation, self}` becomes `{input, vars, incarnation, self}`. **Only-add (c) is intact and it is still breaking:** `flow_context` is a `Struct`, so a map key changed where the wire schema cannot see it, and an older Soul reads `flow_context["essence"]` against a new Keeper and silently gets nothing. Recorded here precisely because the compat rule cannot catch this class; a mixed-version fleet across this boundary is unsupported. | | [0013](0013-bootstrap-archon.md) | Bootstrap of the first Archon | amended | `keeper init --archon`, an advisory lock, the first Archon is cluster-admin; the bootstrap invariant is expressed via `created_via='bootstrap'` (amendment 2026-06-23); the registry-emptiness check ignores the system `archon-system` — "empty" = no operators with `created_via != 'system'` (amendment 2026-07-01). **Split into its own file.** | | [0014](0014-operator-identity.md) | Operator identity model (Archon) | amended | Registry `operators`, a JWT credential; AID charset extended, near-instant revocation, a `created_via` field + moving the bootstrap invariant (amendments). **Revocation made route-independent and immediate on the revoking node (amendment 2026-08-07, NIM-421):** one `RejectRevoked` link after `RequireJWT` → `401 operator-revoked-token` everywhere (the existence-gate routes used to say 403, four ungated ones 200), and the invalidator refreshes its own snapshot before publishing (the origin node used to wait out its 10s TTL). Federated login answers 401 for revoked too. **Split into its own file.** | | [0015](0015-core-modules-mvp.md) | Core modules MVP: exact list | amended | 18 Soul-side core (12 original MVP + post-MVP `url`/`line`/`repo`/`firewall`/`http` + `directory` split from `core.file` 2026-07-17). The current registry fact is 18 Soul-side (+`core.augur` per ADR-025) and 4 Keeper-side (`core.soul`/`core.cloud`/`core.vault` per ADR-017 + `core.choir` per ADR-044); the factual summary is [module/README.md](../module/README.md#catalog-status). Amendment 2026-06-18: `core.service` (systemd) does a gated `daemon-reload` before mutating actions (param `daemon_reload` auto/always/never, default auto); a new state `core.file.directory` (params path/owner/group/mode/parents; idempotent, fixes owner/mode drift, supports Scry). Amendment 2026-07-17: `core.file.directory` split out into a new top-level `core.directory` (`present`/`absent`; hard rename, no back-compat; `absent` removes a non-empty dir only with `recursive: true`, else errors — a deliberate divergence from silent `rm -rf`); `core.service` gains `disabled` and `masked` (systemd-only, disable-before-mask). Soul-side core count 20 → 21. **Amendment 2026-08-14 (NIM-687): a non-zero exit now FAILS the verb-shell modules by default** — `core.exec.run` / `core.cmd.shell` take an optional `exit_codes` (exact integers and/or inclusive `"lo-hi"` ranges, default `[0]`); a contract change, not an addition, since both used to report `changed=true, failed=false` for ANY code the process returned. The rejected code travels as `failed=true` **with** `{stdout, stderr, exit_code}` (`util.SendFailedWithOutput`), so a `failed_when:` predicate over `register.self.exit_code` stays evaluable; the guards (`creates`/`unless`/`onlyif`) and `res.Err != nil` are unchanged, and `failed_when: false` is the documented way back — at the price of the task ending OK, not CHANGED. Reaches the Errand surface with the same modules (`POST /v1/souls/{sid}/exec`, `keeper.soul.run-command`). **Split into its own file.** | | [0016](0016-parity-license.md) | Parity strategy + license | amended | **BSL 1.1** (core+web, fair-code; Additional Use Grant = **Internal Use only**, each version turns Apache 2.0 two years after its own release) / Apache 2.0 (`sdk/`, `proto/plugin/`, `examples/`, plugins); a hybrid of a core rewrite + community plugins; SDK Phase 2. ★ The original decision (a) "Apache 2.0 for everything" is **superseded** — the amendment of 2026-07-09 is the licensing decision in effect. ★ Part **(g) reserved namespace `official` is retired** (amendment 2026-09-02 / NIM-765): the artifact carries no name since NIM-377, `official` is in neither `shared/plugin/reserved.go` nor the reserved list, and the origin-grouping address level is removed — origin is answered by the catalog `source` and the Sigil allow-list. The trademark protection of "official"/"certified" under (q) is unaffected. **Split into its own file.** | | [0017](0017-keeper-side-core.md) | Keeper-side core expanded | amended | `core.cloud` (the address `core.cloud.created`/`core.cloud.destroyed`, base+state — the form `core.cloud.provisioned` is historical, as an address it does NOT exist, amendment 2026-06-26) / `core.vault.kv-read`; a cloud credentials-flow + 6 providers (amendments). **Amendment 2026-07-01 (self-onboard "Variant T"):** a third bootstrap-delivery mode — per-VM tokens are baked into userdata BEFORE create (keeper predicts the FQDN: opt-param `name` + `providers.fqdn_suffix`, migration 094; `self_onboard: true` on `core.cloud.created`; orphan-cleanup of souls/tokens on a failed create); a deliberate departure from the B-flat floor "userdata without tokens" (single-use tokens, opt-in). **Amendment 2026-07-01 (cert-rotation via Warrant, Keeper-centric):** the **Warrant** registry of service TLS certs (migration 092, one active per `(incarnation, kind)`) + the Reaper rule `rotate_due_certs` (a scan of `not_after`+jitter → CAS → keeper-side genkey+CSR (★an R2 exception, the service cert's private key goes through Keeper→Vault — the SoulSeed identity invariant is not weakened) → Vault PKI `SignCSR` → `WriteKV` → spawn a Voyage(`rotate_tls`); default OFF+dry_run) + the keeper-side core `core.cert.registered` (E1) + `purge_old_certs` (093). **Amendment 2026-07-09 (cert-rotation config-driven):** a new top-level manifest section `certificate_rotation` (`enable`/`scenario`/`threshold`/`pki_role`) — the Reaper reads the rotation scenario name and Vault PKI role from the manifest (Path B, version = pinned `service_version`, cache ~60s), **no section = rotation off** with no fallback to the hardcoded `rotate_tls`; a second state `core.cert.issued` (mint+enroll: keeper-side genkey+CSR → Vault PKI role from the manifest → Vault → Warrant, the R2 private key never leaves); three gates (`enable` × per-cert `auto_rotate` × keeper.yml `rotate_due_certs.enabled`); `threshold` is still global (keeper.yml), the `warrant` schema unchanged. **Split into its own file.** **Amendment 2026-07-26 (re-create over hosts that are already up converges; NIM-189):** completes the (mm) boundary of the 2026-07-24 amendment — the driver already reuses live VMs, so `EnsureProvisionable` gains a third outcome **`existing`** (a `connected`/`disconnected` row of THIS incarnation is read and left untouched, NO bootstrap token issued) instead of refusing; `core.cloud.created` flags such hosts `onboarded: true` in `hosts[]` and counts them in a new `existing` output, `core.bootstrap.delivered` skips them; `vm_ids`/`hosts[].sid` still cover the WHOLE roster (day-2 `provisioned_vm_ids`/`provisioned_sids`); `revoked`/`expired` and foreign incarnations are still refused. **Amendment 2026-07-26 (a teardown is not done until the provider confirms; NIM-191):** provider delete calls are async — an accepted RPC is not a deletion, and a VM deleted mid-create stays alive (WB parks it in `DELETE_FAILED`) while the driver reported success, so the cascade ran `souls→destroyed`/seeds-orphaned/tokens-burned over live, billed machines. SDK skeleton (f) gains **`ConfirmDestroy`** (delete → poll until gone → **re-issue the delete** whenever the probe reports the deletion itself failed; same `SOUL_CLOUD_WAIT_BUDGET`; per-provider part = `GoneProbe`/`GoneResult`, rendering = `ReportDestroy`); an unconfirmed teardown is `failed=true` with the `vm_id`, never a success; Keeper's `PluginAdapter.Destroy` stops counting failed events as deletions and fails the whole call, leaving the registry untouched (delete is idempotent, so a re-run is safe). **Amendment 2026-08-17 (a second source for the driver tuple — the step itself; NIM-668):** additive first step of "a cloud is just a plugin" — `core.cloud.created`/`destroyed`/`resized` can take the whole tuple from step params instead of a registry row: `driver` (plugin alias, was `providers.type`), `credentials` (a **literal** `vault:` ref — resolved by the render's vault phase, so A-flow holds and the key name is caught by `sensitiveKeyRe`; a ref built by `${ … }` is never resolved and is refused), `region` (merged under the same `regionKey`), `fqdn_suffix` (was the `providers` column, for self-onboard FQDN prediction) and **`profile` as an OBJECT** — reversing the 2026-06-29 "NOT an inline object" on new grounds (a topology × size matrix has no expressible registry name, and a service repo cannot ship rows). `provider` XOR the inline set, enforced in `Validate`; audit `cloud.provisioned` carries the driver alias where the row name would go, never the credentials. Registry path unchanged and still supported (removal — NIM-669); `CloudDriver.Schema()` validation and alias defaults — NIM-670. **Amendment 2026-09-01 (NIM-757): the CloudDriver contract is removed — a cloud driver is an ordinary plugin.** Every CloudDriver already *is* a plugin, so the separate service contract was a duplicated abstraction; it becomes an ordinary SoulModule plugin declaring `side: keeper`. Credentials stay Variant A (the plugin never talks to Vault) but arrive as **ordinary step params**, which is what makes the excision complete: **no cloud-specific credentials channel remains in keeper at all** — `coremod/cloud/credentials.go`'s `ResolvedProvider{Driver, Credentials, FQDNSuffix}` with `region` folded under `regionKey` is exactly the thing that goes. Named losses, as an accepted price: `core.cloud` and all **three** states (`created`/`destroyed`/**`resized`** — `resized` is missing from this ADR's own framing), the Provider + Profile registries (migrations 019/020/094 + the FK `ON DELETE RESTRICT`), **8** RBAC permissions, **7** audit events, **10** MCP tools, **10** Operator-API operations over 6 path items, `shared/coremanifest/mod_cloud.go`, `keeper/internal/{provider,profile,coremod/cloud,pluginhost/clouddriver.go}`, `sdk/clouddriver/`, the six `examples/module/soul-cloud-*`, and the web Provider/Profile screens (NIM-762). ★ **Removing a permission is breaking, and worse than "a lost grant":** the catalog is a closed enum and the enforcer fail-closed, so ONE surviving row takes the whole cluster's authorization down at the next keeper start with no in-band remedy (and `holder.go` hides it on a running cluster until then) — catalog entries and the data migration ship in the SAME change, matching **both** the bare and the ` on `-scoped form, per the `109_drop_permission_update_hosts` template. ⚠ `catalog.go`'s "names are never removed" prose states the opposite policy; its reason is dead (grants are in Postgres, not `keeper.yml` — [ADR-028(g)](0028-rbac-storage.md)) and NIM-330 already broke it once — flagged, not a blocker. `proto/plugin` — **Option A, no backcompat**: `clouddriver.proto` + generated Go + `sdk/clouddriver/` deleted, `KIND_CLOUD_DRIVER = 2` becomes `reserved` (never-reuse per ADR-020(c), **not** backward compatibility); `proto/plugin/v2` rejected — nothing is changing shape, it is going away. **The 2026-08-17 NIM-668 amendment is ANNULLED** (no `core.cloud` step left to parametrise). Order is forced: **NIM-758** (keeper learns to execute a keeper-side plugin — today `applyKeeperTask` answers `unknown keeper-side module`) → **NIM-760** (`soul-cloud-wb` moves, VM creation verified live) → **NIM-761** (removal); removal first would leave the platform unable to create a machine at all. All three shipped; removal landed 2026-09-04. **Amendment 2026-09-03 (NIM-745): the manifest section `certificate_rotation` becomes `certificate:` with the rotation policy nested under `rotate:`, and `pki_role` lifted out of it** — the role is what a cert is ISSUED with (`core.cert.issued` mints the first one with it), so tying it to a rotation switch made "signed by role X, do not auto-rotate" inexpressible; `certificate: { pki_role: X }` with no `rotate:` block is now a complete section. Gates, Warrant schema and the `rotate_tls` contract key unchanged; `Present`/`Enabled` now read the `rotate:` block, so a role-only section is excluded from the scan exactly as no section was. The old key is refused with a hint — no transition window, because the three manifests carrying it were all ours. `core.cert.issued` still requires rotation enabled (follow-up). | | [0018](0018-soulprint-typed.md) | Soulprint typed-schema MVP | amended | `SoulprintFacts` instead of a Struct stub; `choirs` as a fact, typed_facts byte-passthrough (amendments). **Split into its own file.** | | [0019](0019-state-migration-dsl.md) | State_schema migration DSL | amended | A flat DSL + CEL + foreach, forward-only, an atomic PG tx; upgrade → drift (amendment). **Split into its own file.** **Amendment 2026-09-01 (NIM-735): a step states its place once.** A step becomes a directory `migrations/_/` holding `main.yml` and its `tests/`; the number is the version the step leads to and the "from" is derived, so `from_version:`/`to_version:` leave the file and `state_schema_version:` leaves `service.yml` ([ADR-007](0007-versioning-git-ref.md) amendment) — the version is the top of the ladder, an empty `migrations/` = 1. A generated `schema.lock` (`version` + a fingerprint of the **parsed and canonicalized** `state_schema`, never the file text) catches the one failure nothing else does — a schema edit with no step behind it — because the structure is described twice, declaratively and imperatively, and nothing reconciles the two, not even the upgrade tx (which never loads the target schema). ★ The fingerprint proves a step was ADDED, not that it is correct; the stamp bypass is closed **procedurally** (a re-stamp with no new directory is visible in the diff, `atlas migrate hash`-style) and the mechanical ban was explicitly rejected — it would force empty steps for harmless schema edits. The engine — the layout, the derived version, dropping the manifest key and the `soul-lint` ladder check — landed with NIM-736 together with the `examples/` relocation; `schema.lock`, the `soul-lint schema-stamp` command and the lock check landed with NIM-737. | | [0020](0020-plugin-infrastructure.md) | Plugin infrastructure | amended | manifest / handshake / lifecycle for three plugin types; SDK Phase 2, the SshProvider set; the catalog `plugins.soul_modules[]` ([ADR-065](0065-core-module-installed.md)); separating Teleport paths — the bootstrap-delivery `transport: teleport` goes through a keeper-side Dialer over an identity file BYPASSING the `soul-ssh-teleport` plugin, the `proxy_jump` limitation (m) remains only for push runs of Destiny (amendments). **Split into its own file.** **Amendment 2026-08-06 (NIM-377): the schema is generated from Go and stamped into the artifact; the artifact carries NO self-name.** (a)/(e) superseded — no hand-written `manifest.yaml`, the source of truth is a `module.Def` in Go, the generated canonical-JSON schema is stamped as a trailer (`soul-mod stamp`) and written to `dist/schema.json`, with `soul-mod verify` as the CI gate; `namespace:`/`name:` are gone, `Manifest.BinaryName()` and the binary-name convention go with them (NIM-423 closed as moot), and address level 1 comes entirely from the registration alias. (d) amended: dispatch is a subcommand (`soul-mod-redis acl`), no proto change. ★ **(f)/(g) CORRECTED — `required_capabilities` and `side_effects` are disclosure to the operator before approval, NOT controls.** There is no sandbox at spawn (no `SysProcAttr`/seccomp/`Setuid`/rlimit anywhere in `shared/pluginhost`); `SideEffects` is read only by the grammar validator; no production code emits `policy_violation`; `conflict_policy` is parsed and read by nothing; `soul-lint` does not check capabilities at all. The one real control is unchanged: the operator approves a specific sha256 and the host refuses to exec a differing digest. (s) adds the reserved-namespace list (absorbs NIM-375). **Amendment 2026-09-01 (NIM-757): `cloud_driver` is removed, and `side: keeper` is what replaces it.** Continues the NIM-748 `side:` block rather than opening a second story: that block added the field and warned it was accepted-and-inert on a plugin; this decision makes it load-bearing. ⚠ The Context's "three categories with different service contracts" was **already wrong** before this ticket — `soul_beacon` (V5-2, 2026-05-26) is a fourth and was never absorbed — so the cut leaves **three**, not two; the amendment records the stale count rather than silently correcting it. (e)'s `spec.profile_schema` / `CloudDriverSpec` arm / `soul-cloud-*` convention go with the cloud arm — while the infrastructure this ADR actually fixes (one handshake, one socket, one one-shot lifecycle, one stamped schema document, one Sigil gate) is untouched, which is precisely why the separate contract was redundant. ⚠ **The closed `kind:` enum is in `sdk/schema/schema.go`, NOT in proto** — `pluginv1.Manifest` and its `*Spec` sub-messages have zero non-test Go references, so `manifest.proto` is a hand-synced dead document and (f)/(g)'s "extend via PR to `proto/plugin/vN/manifest.proto`" is stale; removing a kind is an `sdk/` change with `reserved 2` as a footnote, and an old artifact then fails at **schema-document validation** — `kind_invalid` from `sdk/schema/validate.go`, refused by `keeper/internal/pluginhost/slot.go:100-102` before the plugin is spawned, so the handshake kind-drift check is never reached. The 2026-08-06 open item "the authoring form for `cloud_driver` / `ssh_provider` / `soul_beacon`" is **half-closed** — the cloud half by removal, the other two untouched. Implemented: NIM-758, NIM-760, NIM-761 (2026-09-04). **Amendment 2026-09-02 (NIM-764 / NIM-765): a plugin address is `..`, and the origin-grouping level is removed.** Level 2 is the **object** the module manages, level 3 the **action**; the grouping level is not written. The argument is a corpus fact, not a preference — core already speaks this grammar (`core.user.present` / `core.file.rendered` / `core.state.set`) and the plugin was the outlier: `community.redis.acl` put the plugin's own subject at level 2, leaving level 3 to mix nouns with adjectives (`acl`, `pinged`, `role`, `detached`, `offset-synced`). **Both** grouping levels go — `official.*` is origin too, by its own definition — and origin is answered instead by the catalog entry's `source` plus the source-keyed Sigil allow-list ([ADR-026(a)](0026-sigil.md)); no follow-up ticket is filed for the `official.*` artifacts, which live in the companion repo. ★ **Nothing in the engine changes:** `splitModuleAddress` is a positional three-way split, `reModuleAddress` already matches `redis.user.present`, neither `redis` nor `community` is reserved (so level 1 is a config edit), (p) already put level 1 in the registration alias, and `Modules []Module` already admits several objects per artifact. Level 3 stays a *state* in SoulModule terms — the discipline changes, the mechanism does not. The imperative carve-out (`run`/`shell`/`probe`) is bounded: **an object taking the verb form takes exactly one — two operations are two objects**, which is what stops a level 2 spelled `command` re-admitting the old level-3 zoo. ⚠ **No gate follows from a word in an address** — a plugin object named `command` gets neither a gate nor an admission from its name (the mechanism, and why the conclusion is stronger than it sounds, is in the amendment itself). ★ **Status 2026-09-03: the redis artifact serves it (NIM-766, NIM-767)** — `soul-mod-redis` under the alias `redis`, seven objects (`acl`/`cluster`/`command`/`instance`/`replica`/`sentinel`/`user`), `side: soul` per object, the schema document generated from `module.Def` (closing NIM-525 in the same change), and the seven cluster operations moved out of `params.action` onto level 3 so each declares only the params it reads. **NIM-767 added `user`** — `ACL SETUSER`/`ACL DELUSER` on ONE user, which is the address this amendment used as its worked example; it stands alongside `acl.reloaded` rather than replacing it, because their subjects differ (the user vs. the aclfile a destiny renders), and the credential goes on the wire as `#` — the same digest `users.acl.tmpl` writes — never as plaintext. ★ **Status 2026-09-05: the mongo artifact serves it (NIM-769)** — `soul-mod-mongo` under the alias `mongo`, three objects (`command`/`instance`/`user`), `side: soul` per object, `module.ServeBundle` and the schema document generated from `module.Def`. `params.state` went the way `params.action` went: `user.present` and `user.absent` are two actions, which is what lets `roles`/`user_password` be declared on the half that reads them and REFUSED on the half that does not — and, because an address is static where a param was not, a definition choosing between them per item filters its loop instead. The service-level declaration (`input.users[].state`) is untouched. With this, `community.*` appears in no effective address in this repository and `docs/module/community/` is gone. Still open: NIM-768 (WB redis off `redis-cli`). **Amendment 2026-09-04 (NIM-794): the catalog entry gains a source kind and an explicit artifact list.** A `soul_modules[]` entry gains `source_kind` (closed enum `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[]` of `{os, arch, path, sha256}`, one row per platform, **each naming its own `path` in full**: no template, 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 (argued once in [ADR-026](0026-sigil.md), not copied here). The key is `source_kind` and **not** `kind`, which the epic first proposed: `kind` is already the closed plugin-type enum in `sdk/schema/schema.go`, an entry sitting inside `soul_modules:` that carried `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 — while `_kind` beside `kind` is the settled `provider_kind` precedent. The resolver **interface** is laid down now with exactly one implementation (`artifact` over https), because a second resolver arriving is the moment to choose the seam rather than retrofit it around two. ⚠ **(o) is left an OPEN fork:** N platform binaries carry N stamped schema trailers while the signed block holds ONE `schema_sha256`, cross-compiled builds are not guaranteed byte-identical there, and the user did NOT rule — NIM-795 comes back for a decision instead of picking "first row wins" or a de-facto byte-identity requirement silently. The 2026-08-06 "one executable in `dist/` … forced on every kind" becomes **kind-scoped**: it still holds on the Soul slot, but an artifact-kind entry names N binaries. Status: **design only — NIM-795 (keeper)**, epic NIM-793; no `source_kind` / `base_url` / `artifacts[]` key is parsed by any config yet. The **Soul-side consumer of this entry HAS shipped** (NIM-796, `2daf8545`, see [ADR-065](0065-core-module-installed.md)), fed from a read-side DTO nothing populates from the wire. | | [0021](0021-hot-reload-config.md) | Hot-reload of config with write-back YAML | amended | Hot-reload of config, writing the changed value back to disk. **Split into its own file.** **Amendment 2026-07-24 (NIM-139):** (f) "cross-host coordination deferred post-MVP" is **closed for the reload-able class** by [ADR-0073](0073-keeper-runtime-config-pg.md) — those keys live in `keeper_settings` and sync cluster-wide over `service:invalidate`; three layers `default < Postgres < keeper.yml` (amended 2026-07-27, NIM-141: the local file wins) with no seeding of file values; write-back for an overlay key targets Postgres, never `keeper.yml`; the validation pipeline (c) and the closed audit `source` enum (g) are unchanged. Per-host behavior still stands for the require-restart class, the bootstrap floor and `soul.yml`. | | [0022](0022-audit-pipeline.md) | Audit pipeline | active | Storage / schema / retention of the audit log. **Split into its own file.** | | [0023](0023-trial-test-runner.md) | Test runner Trial + DSL coverage | active | `soul-trial`, levels L0–L3, the trial-coverage metric. **Split into its own file.** Amendment 2026-08-25: L0 `assert.state_after` is a subset check (ADR-0084 F-C/C4) and now threads the plan's `core.state.` capture steps through the production merge engine, building each op with the same code the module runs; new section `assert.state_absent` (strict key absence — the only way a subset check can assert a removal); L1 migration stays full. | | [0024](0024-observability.md) | Observability: Prometheus-primary + OTel-bridge | amended | Prometheus-primary `/metrics` + OTel-bridge; prefixes `keeper_*`/`soul_*`. **Amendment ([ADR-067](0067-vector-log-shipping.md)):** the observability model is extended with a third independent layer — **logs (push)**: a Vector agent on every data service, alongside metrics (Prometheus pull) and traces/opt. OTLP (OTel-bridge). **Split into its own file.** | | [0025](0025-augur.md) | Augur — a broker for Soul's external access | amended | A keeper-side broker for Soul's live access to external systems (Omen / Rite). **MVP-1 (the broker, `delegate=false`) implemented (2026-05), in baseline; MVP-2 (delegation) deferred** ([amendment 2026-06-16](0025-augur.md)). **Amendment 2026-07-28 (NIM-249) — REVOKED 2026-08-05 by [NIM-281](0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited):** it read the "SID→covens" step of the authorization resolve as a host's EFFECTIVE labels (own ∪ inherited, the name included), so `coven: ` granted its members; now it is `souls.coven[]` alone — an operator-attached tag and nothing more. **Known narrowing (NIM-280):** a Rite's subject is `coven` XOR `sid` with no incarnation dimension, so "the members of incarnation X" cannot be expressed at all — tag those hosts and scope the Rite to that tag. NIM-124 removed the injected name and the resolve was left on the bare column, so such Rites stopped authorizing anyone; unlike the reactor's silent version of the same defect, this one denied hosts mid-apply. A Rite has **no** membership dimension to gate on (subject is `coven` XOR `sid`), so it cannot distinguish a member from a host tagged with the incarnation's name — NIM-280. **Amendment 2026-08-05 (NIM-280) — the narrowing above is CLOSED: a Rite's subject is EXACTLY ONE of four dimensions** (`sid` / `incarnation: {service, name}` / `coven` / `trait: {key, value}`; migration 113, `rites_subject_xor` → `rites_subject_one_of`). The two label dimensions read BOTH levels — the host's own labels and those of every incarnation it is a member of — resolved at match time, with nothing written to `souls`, so this is not the inheritance NIM-281 removed. ★ Targeting expands, operator authorization never does: `rbac.CovenScopeSQL` still reads the row's own column. ⚠ The label namespace is shared, so tagging an incarnation widens every `coven`-subject Rite to its members with no Rite edited — on the Augur path that widens **access to a secret**, making incarnation labelling a grant-affecting act. | | [0026](0026-sigil.md) | Sigil — plugin integrity | amended | A Keeper-signed digest index of plugin integrity. **Split into its own file.** **Amendment 2026-08-06 (NIM-377):** the registry key moves off `(namespace, name, ref)` onto the **artifact source** — the artifact carries no self-name ([ADR-020](0020-plugin-infrastructure.md)), so there is no alternative; the key change itself is NIM-438, and changing which fields the signed block covers means a new DST `soul-stack/sigil/v2`. ★ **(c) corrected:** the signature does make the declarations non-forgeable, but that is **not** "a strengthening of ADR-020(f)/(g)" — nothing enforces those declarations, so what the signature buys is a **trustworthy disclosure** for the operator's approval decision, not a control. The security invariant is unchanged and never rested on (c): the gate is the digest. (g) mechanics: the cache slot is `///` (registration alias, not `-`) and the artifact convention is **the single executable in `dist/`**; the schema document replaces `manifest.yaml` in the slot and is read from the artifact's trailer WITHOUT executing it. **Amendment 2026-09-04 (NIM-794): the grant carries a list of artifacts, and the bytes stop travelling through the keeper.** A plugin arrives on the host **from its source** — a Nexus repository, a GitHub release; the Keeper still resolves `ref` into concrete bytes and still signs them, what stops is the bytes travelling through it. Keeper-proxying was rejected: a shared package proxy already exists in any infrastructure this deploys into, and today those bytes ride the **same mTLS connection that carries run dispatch** (the fetcher is taken from the live EventStream session), so stream isolation keeps the artifact from blocking the queue but not from competing for that connection. The signed block's scalar `binary_sha256` becomes a **list** `{os, arch, path, sha256}`, one row per platform, and the DST therefore goes to **`soul-stack/sigil/v3`** by the rule this ADR already wrote down — every existing grant is deleted, the same correct consequence as v1→v2 rather than a hazard to migrate around. ★ **A grant per platform is not merely dispreferred — this ADR's own trust key forbids it:** the partial unique index `plugin_sigils_active_idx` on `(source, ref)` refuses the N live rows it would need, so the artifact-list is the only shape the key admits. **Source-pull weakens nothing:** (g) already calls `source` operator-asserted but the repository **untrusted**, and the gate was always the digest, checked before materialization — a compromised source earns a verification refusal, not code execution; revocation is unchanged, running through the grant set (`SigilSnapshot` ReplaceAll) and not through the bytes. ⚠ **The trap to refuse:** keeping field 4 populated as "one of the N" for compatibility makes an old Soul silently install whichever row the Keeper happened to put there — `binary_sha256 = 4` becomes `reserved` the moment it stops being authoritative. Authentication to the source is deliberately **deferred** (v1 assumes an anonymously readable repository; a credential on every managed host is its own decision), and for an artifact-kind entry `ref` is a **label, not a checkout** — no `commit_sha`, which costs a provenance marker, not a control. ⚠ Breaking past this repository: `PluginSigilView` / `PluginSigilAllowReply` carry `sha256` as a **required scalar**, and `soul-stack-web` regenerates from that document — caught by neither core `make check` nor core CI. **Rider correction:** the NIM-438 re-key is migration **115** (`115_plugin_sigils_source_identity`), not 113 — `113_subject_four_dimensions` is unrelated — fixed in the banner, the NIM-438 amendment and the Consequences bullet. Status: **design only — NIM-795 (keeper)**, epic NIM-793; the signed block is still `(source, ref, binary_sha256, schema_sha256)` and the DST is still v2. ⚠ **The Soul half HAS shipped** (NIM-796, `2daf8545`) and stopped at this ADR's boundary: `SigilRecord` gained the read-side `BaseURL`/`Artifacts` that **nothing fills from the proto**, because sign and verify are one helper and both ends move together or neither does — so a multi-platform grant today installs only on the platform whose row digest equals `BinarySHA256hex` and **every other row fails closed at verify**. | | [0027](0027-apply-work-queue.md) | Apply execution model — work-queue + claim | amended | An Acolyte pool, Ward-claim, Summons; a refuse-guard, recovery, GATE-1 deliver-once; reclaim/Ward-claim granularity → per-passage ([ADR-056](0056-staged-render-passage.md)) (amendments). **Split into its own file.** | | [0028](0028-rbac-storage.md) | RBAC storage → Postgres | amended | RBAC (roles/permissions/membership) in Postgres, a fix for BUG-1; the Synod amendment (ADR-049). **Split into its own file.** | | [0029](0029-service-registry.md) | Service registry → Postgres | amended | The Service registry from `keeper.yml` moved to Postgres; closes the remainder of ADR-028(h); well-known `keeper_settings` keys (`default_destiny_source`, `provisioning_allowed_methods`) — amendment (g). **Split into its own file.** | | [0030](0030-vigil-oracle.md) | Vigil + Oracle — event-driven monitoring | amended | beacons + a reactor; S5 typed-payload / soul_beacon / inotify (amendments). **Amendment 2026-07-28 (NIM-224) — REVOKED 2026-08-05 by [NIM-281](0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited):** it resolved the `coven` half of a Vigil/Decree subject over a host's EFFECTIVE labels, so `coven: []` bound a rule to that incarnation's members; now it matches `souls.coven[]` alone, and an untagged host is silently unwatched (a Vigil that never ships raises no Portent). "Members of incarnation X" as a subject is NIM-280. (NIM-249's own occasion — NIM-124 removing the injected name and leaving every incarnation-scoped rule matching nothing, silently — is the record of what the union was for.) The **membership-check keeps reading `incarnation_membership`** and must never be answered from a host's tags — wrong in both directions now: a tag spelled like an incarnation would pass the cross-incarnation escalation guard, and a genuine member nobody tagged would fail it. **Amendment 2026-08-05 (NIM-280) — the subject binding of a Vigil and a Decree is EXACTLY ONE of four dimensions** (`sid` / `incarnation: {service, name}` / `coven` / `trait: {key, value}`; migration 113, `*_subject_xor` → `*_subject_one_of`). `coven` and `trait` read BOTH levels at match time (the host's own labels ∪ those of its incarnations) with nothing written to `souls` — not the inheritance NIM-281 removed. A Decree now carries two incarnation-shaped fields that are NOT the same: `subject.incarnation` (may this host trigger the rule) and the top-level `incarnation_name` (what the action runs against, membership-checked as before) — a Decree may legally name two different ones and then fire for nobody. ⚠ Labelling an incarnation widens every `coven`-subject rule to its members, and **unbinding a host withdraws every Vigil that reached it through that incarnation** — membership changes are monitoring-affecting. **Split into its own file.** | | [0031](0031-scry-drift.md) | Scry — drift detection | superseded | Circuit REMOVED 2026-08-05 (NIM-446): no check-drift endpoint, no background scan, no `drift_checked` audit. What survives: pure-read `Plan` + `PlanReadSafe`, the only-add proto fields, and the `drift` status (now written only by a legacy upgrade). **Split into its own file.** | | [0032](0032-push-orchestrator.md) | Push orchestrator (Variant C) | amended | Multi-host destiny push without incarnation/scenario; the S6/S7 series + P2 routing (amendments). **Amendment 2026-07-28 (NIM-251) — REVERTED 2026-08-05 by [NIM-281](0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited):** it matched Level 2 of the provider resolve (`push.coven_default_providers`) against a host's EFFECTIVE coven labels, so labelling an incarnation put all of its hosts behind one bastion; Level 2 now reads `souls.coven[]` as originally specified. ⚠ **Upgrade check:** a fleet relying on the inherited reading falls through to the cluster default — a silent change of SSH perimeter; tag the hosts each configured coven is meant to route. (NIM-251's own occasion was NIM-124 removing the injected name and leaving that per-coven default silently falling through — same symptom, now the specified behaviour.) The own-then-inherited tiebreak is gone with the union it ordered — one group left, alphabetical, first match wins. **Split into its own file.** | | [0033](0033-errand.md) | Errand — pull-ad-hoc exec | amended | Pull-ad-hoc exec outside a scenario; E5 cancel, the Voyage command-kind (amendments). **Split into its own file.** | | [0035](0035-distribution-split.md) | Distribution split — core vs web | amended | A distribution split: core (API+CLI) vs web (UI). **Amended by [ADR-055](0055-embed-ui-bundle.md)** — the deferred embed-compat-shim is activated as an optional default-ON embed UI at `/ui` (beta single-binary onboarding). **Split into its own file.** | | [0038](0038-toll.md) | Toll — a detector of mass Souls attrition | amended | A cluster-wide detector of Souls attrition; a webhook + per-coven thresholds (amendment). **Split into its own file.** | | [0039](0039-e2e-testing.md) | E2E testing — three levels | amended | Three e2e levels with no new dictionary entity; L3a-impl (amendment). **Split into its own file.** | | [0040](0040-tide.md) | Tide — invocation-time scope chunking | superseded | Absorbed by [ADR-043 (Voyage)](0043-voyage.md#adr-043-voyage--unified-batch-run) via the `kind=scenario` mode. **Split into its own file.** | | [0041](0041-errandrun.md) | ErrandRun — a multi-target wrapper over Errand | superseded | Absorbed by [ADR-043 (Voyage)](0043-voyage.md#adr-043-voyage--unified-batch-run) via the `kind=command` mode. **Split into its own file.** | | [0042](0042-backend-driven-ui.md) | Backend-driven dynamic data in the UI | amended | The UI does not hardcode dynamic catalogs — it fetches them from the backend. **Split into its own file.** Amend: the `GET /v1/event-types` instance (the Tiding catalog, ADR-052). | | [0043](0043-voyage.md) | Voyage — unified batch run | active | A unified batch run; absorbs Tide (ADR-040) and ErrandRun (ADR-041). **Split into its own file.** | | [0044](0044-choir.md) | Choir — named host topology | amended | A named host position within an incarnation; a stable declared fact. **Split into its own file.** Amend: precedence-role + a multi-Choir conflict (S-T6 part(1a)); scenario-driven layout + NULL-vs-default semantics (unset → NULL, not a default group; per-shard layout deferred to mongo). **2026-07-30 (NIM-330, S-T6 part(1b)): `spec.hosts[]` REMOVED, not deprecated** — Voice is the SOLE source of a declared role, so the precedence of amendment 2026-05-29(a) is moot and "unset → empty role" is the only rule; the surface goes too (`PATCH .../hosts`, rights `incarnation.update-hosts` + the alias `incarnation.update`, audit `incarnation.hosts_updated`), with migrations 108/109 stripping the `spec` key and the dead grants — the RBAC catalog is a closed enum and its enforcer is fail-closed, so a leftover grant would abort the snapshot load. The bootstrap-`create` justification lapsed when S-T5 landed: `core.choir.present` (`on: keeper`) writes a Voice from inside the run. Still deferred: the per-shard declarative sugar, NOT the ability to assign a role in a scenario. | | [0045](0045-param-dsl.md) | Module Param-DSL | active | Typed module input fields for the Run Command UI form. **Split into its own file.** | | [0046](0046-cadence.md) | Cadence — recurring runs | active | A scheduled/recurring Voyage (interval + cron). **Split into its own file.** | | [0047](0047-purview.md) | Purview — scoped RBAC visibility | amended | A role's default_scope + an extended node-visibility selector. **Amendment (2026-07-18, NIM-128):** scope becomes a **boolean expression** (`AND`/`OR`/groups) over a reduced selector enum `{coven, service, incarnation, host, trait}` — `regex`/`soulprint`/`state` **removed** (pattern-match → `host matches `, glob-only); wire = RAW string (no schema migration), fail-closed data-migration `100_rbac_drop_pattern_selectors`; subset = DNF single-conjunct subsumption + exact-fast-path; single scope resolver + full AST→SQL pushdown. **Amendment (2026-07-19, NIM-128):** scoped full-wildcard `* on ` (a "scoped super-admin" — enforced on the write path, subset-contained, self-lockout counts **bare** `*` only) + `incarnation matches ` (glob extended to the incarnation identity dimension). **Amendment (2026-08-08, NIM-522):** a `trait.=` scope value names a **WHOLE value** — jsonb's `?|` arm is removed (it matched an object's KEYS and an array's STRING elements only), so an object reaches nothing, a container's own text reaches nothing, and each **scalar** element of an array is addressable whatever its JSON type; rendered once by `rbac.TraitScopeSQL` and restated in Go by `rbac.TraitValues`, which reads Postgres' RAW jsonb rather than a decoded map (NIM-521 — `float64` printed `1000000` as `1e+06` and the single-row read hid a host the list had shown). Narrows and widens; release note. **Split into its own file.** | | [0048](0048-conductor.md) | Conductor — the Cadence-schedule executor | active | A leader-elected executor of Cadence schedules. **Split into its own file.** | | [0049](0049-synod.md) | Synod — a group of Archons | amended | A group of Archons bundling roles (the level Archon → Synod → Roles); amends ADR-028. **Split into its own file.** | | [0050](0050-tempo.md) | Tempo — per-AID rate-limiting write-API | active | Per-AID rate-limiting on the write API. **Split into its own file.** | | [0051](0051-operator-api-codegen.md) | Operator API codegen: OpenAPI → Go types | superseded | oapi-codegen: types-only → a strict server; the abolition of `proto/operator/v1`; the S6 bridge pattern. **Replaced by [ADR-054](0054-openapi-code-first.md)** (a reversal spec-first → code-first); **the implementation was DEMOLISHED on 2026-06-13 (HEAD `fde65bf`)** — the `oapi` package/`oapi_strict.go`/hand-written source/`gen-api`/`check-gen-api` removed. **Split into its own file.** | | [0052](0052-herald-notifications.md) | Herald + Tiding — run-event notifications | amended | Herald (the channel) + Tiding (the subscription rule); a tap on top of the audit-writer, at-least-once webhook delivery, scope = run events; ephemeral-Tiding + a flexible annotations/projection body (amendment). **Amendment 2026-07-01 (channel-types):** the `heralds.type` enum is extended with six types on top of `webhook` — `telegram`/`slack`/`mattermost`/`discord`/`custom` (the HTTP class: drivers `channelDriver`→`httpDelivery`, a single SSRF perimeter by construction) + `email` (the SMTP axis); the top-level `secret_ref` — webhook only (HMAC), for the rest the credential = a vault-ref inside `config`; the type catalog `GET /v1/herald-types` (a single source `channelDrivers`, migration 091). **Split into its own file.** | | [0053](0053-dependency-tiers.md) | Infrastructure-dependency tiers | active | The mandatory perimeter is PG + Redis + Vault (all fail-fast); Vault hard-required (start/auth/PKI); an OPTIONAL-with-degradation tier; a Vault-less mode is rejected. **Split into its own file.** | | [0055](0055-embed-ui-bundle.md) | Embed UI bundle — an optional single-binary keeper with UI at `/ui` | active | An optional (default-ON) go:embed of a vendored soul-stack-web build snapshot into keeper; the `/ui` route + SPA fallback, public static assets (parity with `/docs`), the `web_ui_enabled` toggle, shares the `:8080` listener; the `sync-webui.sh` sync + the `check-webui` drift guard. **Amends [ADR-035](0035-distribution-split.md).** **Split into its own file.** | | [0054](0054-openapi-code-first.md) | Operator API — a pivot to code-first (Go types → OpenAPI) via huma v2 | amended | Replaces ADR-051 (an inversion spec-first→code-first); Go types are the source, the spec is derived (huma v2 + humachi); **FULL-TYPED** (Amendment 2026-06-12: typed input/output + an extracted `XTyped`, the `RawBody` bridge rejected). **ROLLOUT COMPLETE 2026-06-13 (HEAD `fde65bf`)**: all ~19 domains are handler-native, served = a runtime huma-dump (`HumaFullSpecYAML`), the committed `openapi.yaml` is derived (`gen-openapi`/`check-openapi`), the oapi scaffolding is torn down, a native enum catalog `huma_enums.go`. **Amendment 2026-06-15**: a visual viewer `GET /docs` (RapiDoc, go:embed) + a served spec behind JWT (`GET /openapi.yaml`/`/openapi.json`) — mechanism A. **Split into its own file.** | | [0056](0056-staged-render-passage.md) | Staged-render — a scenario run as N ordered Passages | amended | A run = N ordered **Passages** (render→dispatch→barrier→gather register); actually implements probe→where (`register` in `where:`/`apply:input` of subsequent tasks), closes the doc-drift in orchestration.md §4/§5. A topological N-stage by register-dependency; a single state-commit after the last Passage (§7 is not split). proto only-add `passage`; a PG-PK per-passage; explicit-reject of an old Soul. **Amendment 2026-06-29 (keeper-target per-Passage):** the pilot assumption "keeper-side tasks in a single pass before host-fan-out, sid=keeper passage 0" is lifted — keeper tasks are now stratified on par with host tasks, executed per-Passage (N rows `apply_runs(apply_id,'keeper',passage)`); this implements the keeper→keeper register-chaining `core.cloud.created → core.bootstrap.delivered` that [ADR-061](0061-onboarding-await-and-midrun-reresolve.md)/[ADR-063](0063-bootstrap-token-delivery.md) already require. **Amendment 2026-07-02 (keeper-register in state_changes):** the keeper-tasks' register is available in the state_changes render-scope as a run-level underlay, the per-host host register takes precedence (host-wins); additive — the isolation of host tasks from keeper-register in `params:`/`when:` is preserved; closes the live bug `provisioned_vm_ids` no-such-key. **Amends [009](0009-scenario-dsl.md) / [012](0012-keeper-soul-grpc.md) / [0027](0027-apply-work-queue.md).** **Split into its own file.** | | [0057](0057-state-changes-crud-verbs.md) | `state_changes` — an ordered list of CRUD verbs | amended | `state_changes` → an ordered **list** of operations (not a map); the verbs `set`/`add`/`modify`/`remove` + `foreach`/`as`/`do` (reusing the migration DSL). Multiplicity via a `match` predicate (not handles/flags); bindings `elem`/`key`/`value`; opt. `expect` (one/at_most_one/any); `add` with `on_conflict` (skip/replace/error, default skip = idempotent); soul-lint WARNs on a wide match, an empty match → no-op. Fixes a latent bug (`appends`/`modifies` were no-ops — state didn't grow). `remove`≠the migration `delete`. Transit: dual-parsing the map form for one release (deprecated). **Amends [009](0009-scenario-dsl.md).** Amendment 2026-06-24: day-2 scenarios read the expanded fact from `incarnation.state`, NOT from `essence`/`input` (the read convention is symmetric to the write semantics; the spec is [production-conventions §7a](../destiny/production-conventions.md)). **Split into its own file.** **Amended by [ADR-0084](0084-explicit-state-capture.md) (2026-08-25, NIM-699):** the verb grammar survives — extended with `present`/`append`/`unset`, same `match:`/`expect:`/`key:`/`on_conflict:`/`patch:` — but the `state_changes:` container is retired; a write is a keeper task `core.state.` landing at its own step instead of after the cross-host barrier, ordering is linted rather than intra-block, and `foreach` is dropped because a step has `loop:`. | | [0058](0058-operator-auth-ldap-oidc.md) | Federated operator authentication — LDAP + OAuth2/OIDC | accepted + end-to-end (LDAP + OIDC) | External auth is validated at the Keeper → mapped onto `operators(aid)`+roles → an INTERNAL JWT is issued (RBAC/MCP/middleware unchanged). **LDAP implemented end-to-end (stage 1):** search-bind (`go-ldap/v3`), auto-provision by groups, roles from `group_role_map`→`rbac_role_operators`, aid from `aid_attr` (default `uid`), JWT in an HttpOnly+Secure+SameSite=Strict cookie `soul_session` (no JSON token), no-refresh, the revoked invariant; `POST /auth/ldap/login` outside `/v1`. **OIDC implemented end-to-end (stage 2):** an authorization-code flow with **mandatory PKCE (S256)**, discovery (`go-oidc/v3`)+JWKS validation of the id_token (signature/iss/aud/exp/nonce), a Redis flow-state store (state→nonce/verifier, single-use GETDEL, TTL 5m), `GET /auth/oidc/{login,callback}` outside `/v1`, JWT in the cookie `soul_session` (`SameSite=Lax`), a shared `DBMapper` (Method=oidc). The `auth_method` enum extended (`ldap`/`oidc`, only-add, migration 083); config blocks `auth.ldap`/`auth.oidc` (secrets — Vault `*_ref`); TLS-required. **The `archon-system` seeding blocker CLOSED** (amendment 2026-06-23): the field `operators.created_via` + relaxing the bootstrap index to `WHERE created_via='bootstrap'` (migrations 084/085), `archon-system` seeded by migration 086, federated writes `created_by_aid=NULL`+`created_via='ldap'`\|`'oidc'` (the `FederatedSourceAID` workaround removed). **Provisioning policy (i):** `provisioning_allowed_methods` + `/v1/provisioning-policy` (perm `provisioning.read`/`provisioning.update`, audit `provisioning.policy_changed`, problem `provisioning-method-disabled`). **Amends [ADR-014](0014-operator-identity.md) / [ADR-013](0013-bootstrap-archon.md) / [ADR-029](0029-service-registry.md).** **Web login cookie→Bearer (amendment 2026-07-09, NIM-77):** `POST /auth/token` exchanges the HttpOnly cookie `soul_session` for a short Bearer in JSON (`exchange_ttl` 10m/floor 1m, cap on cookie.exp, revoked check against the in-memory RBAC snapshot, anti-oracle 401, cross-site 403), the session cookie scope narrowed to `Path=/auth`, `GET /auth/methods` implemented; `/v1`/RequireJWT/MCP untouched. **Split into its own file.** | | [0059](0059-audit-sink-pluggable.md) | Pluggable audit sink — PG / Kafka / off | proposed / deferred | **Design-only, not implemented (a post-beta plan).** The audit-export backend — choosing an implementation of `shared/audit.Writer` (the abstraction already exists, the `MultiWriter` tap is preserved), wired into `setupAudit`. `keeper.yml → audit.sink: pg \| kafka \| off` (default `pg` = the current PG `audit_log`, the mandatory ADR-053 perimeter is intact). `kafka` — a new sink in `keeper/internal` (`shared` stays pgx-free/Kafka-free): at-least-once `acks=all`, **fail-closed** degradation (audit is compliance-critical), downstream dedup by `audit_id` (a ULID PK). Tier: Kafka is strictly **OPTIONAL-with-degradation**, NOT a 4th required component. Switching the sink is **restart-required** (the `web_ui_enabled` pattern). Supersedes the Redis-Stream variant of the audit-scaling backlog, batched-INSERT remains (a cheaper alternative on the write-throughput axis). **A hard dependency BEFORE implementation (open question (n)):** `changed_tasks`/`incarnation.run_completed` ([ADR-052 §k](0052-herald-notifications.md)) and `GET /v1/audit` today derive data via an SQL query over `audit_log` in PG — with `sink: kafka` and no PG they'd silently break; an alternative event source is needed (3 candidates). The working name is "audit sink"; the thematic **Chronicle** is a candidate alternative, NOT fixed in naming-rules. **Amends [ADR-022](0022-audit-pipeline.md) (a pointer) / [ADR-053](0053-dependency-tiers.md) (the OPTIONAL row).** **Split into its own file.** | | [0060](0060-traits.md) | Trait — operator-set key-value labels (host and incarnation) | amended | Trait = operator key-value (scalar\|list) labels, a SEPARATE axis alongside the flat Coven (Variant B; extending `souls.coven` to key-value is rejected — breaks ADR-008 + the scope-pushdown `$1=ANY(coven)` + `'x' in covens`). **R1 (2026-06-25): Trait RELOCATED per-soul → per-incarnation** — the source of truth is `incarnation.traits jsonb` (operator-set in `incarnation.spec.traits`, migration `088`, mirroring `incarnation.covens`/`046`), projected in a MATERIALIZED way into member hosts' `souls.traits` via a **sync-hook** (`SyncTraitsToHosts`, reuses `soul.BulkReplaceTraits`; wired into create + bind `core.soul.registered`). `souls.traits` (`087`) = the projection target, the read layer is reused (`soulprint.self.traits` / `where:traits` / soul-lint / topology — 0 changes). Targeting via `where: soulprint.self.traits.` (CEL `DynType`, no AST rewrite). **R1 slice a (2026-06-25): the API relocation per-soul→per-incarnation is IMPLEMENTED** — `incarnation.spec.traits` on create + the day-2 `PUT /v1/incarnations/{id}/traits` (permission `incarnation.traits-set`, audit `incarnation.traits_changed` — KEYS only, MCP `keeper.incarnation.traits-set`); the per-soul write path `POST /v1/souls/traits` / `soul.traits-assign` / `keeper.soul.traits-assign` is **DEPRECATED, NOT removed** (forward-compat, overwritten by the projection). **NIM-128 (2026-07-18): RBAC scope by traits IMPLEMENTED** as the boolean-scope dimension `trait.=v` ([ADR-047 amendment](0047-purview.md)). **NIM-121 (2026-07-27) SUPERSEDES the R1 relocation:** the materialized projection is removed, Trait is an axis on BOTH levels at once, and the per-soul write path is first-class again with a trait-scope gate. **Revised 2026-08-05 by [NIM-281](0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited):** the read-time union that replaced the projection is removed too — a host's traits are `souls.traits` alone, an incarnation's are `incarnation.traits` alone, the two levels never meet, and `POST /v1/souls/traits` is the only way a host gets a trait. **One exception, 2026-08-05 ([NIM-280](0008-coven-stable-tags.md#amendment-2026-08-05-nim-280-a-rules-subject-reads-both-levels--targeting-only)):** a rule's SUBJECT (`trait: {key, value}` on a Vigil / Decree / Rite) matches over the host's traits ∪ its incarnations', inside one selector match — a targeting read; `souls.traits` and every other reader, the RBAC trait scope included, still see the bare column. **Amends [ADR-008](0008-coven-stable-tags.md) / [ADR-018](0018-soulprint-typed.md).** **Split into its own file.** | | [0061](0061-onboarding-await-and-midrun-reresolve.md) | Single-run provision→onboarding→role: onboarding-await + mid-run re-resolve | amended | One create scenario: provision (`core.cloud.provisioned`) → wait for onboarding of the created Souls → mid-run roster growth → applying the role. Two capabilities on top of the existing `core.soul.registered` (NOT a new module — the user's decision). **(1) onboarding-await:** new flags `await_online`/`await_timeout`(required-when)/`await_min_count`/`await_poll_interval` — after the register+coven step, blockingly polls the **Redis SID lease** (the source of truth for online, NOT PG status) until `await_min_count`/timeout; **B1-strict** (online/types.yml` (the same InputSchema DSL) + a `$type: ` reference as a field or `items: {$type}` for an array. **Replaces the unimplemented `$ref`/`schemas/`** (removed from architecture.md/manifest.md). Service-level resolution (NOT local-per-scenario, NOT cross-service); MVP = object + array-of-type + type→type nesting with **mandatory cycle-detection**; WITHOUT scalar-alias/generics/cross-service. Errors `input_type_unknown`/`input_type_cycle`/`input_type_duplicate`/`input_type_ref_conflict`. The DTO `/v1/scenarios` resolves `$type` backend-side BEFORE projection + the annotation `x-type: ` (forward-compat for a UI widget). Wave 1 = the mechanism on the current `AclUser`. **Amends the `$ref` groundwork in [ADR-003](0003-destiny-format.md) / [ADR-009](0009-scenario-dsl.md).** **Split into its own file.** **Amendment 2026-09-01 (NIM-741, [One schema dialect](0086-one-schema-dialect.md)): three deltas, once `state_schema` shares this dialect.** (1) The object-level list form `required: [names]` leaves `types.yml` too — requiredness is `required: true` on the property, everywhere in the dialect (`input_required_list_removed`); a declared type is a schema NODE, so it keeps its own `type: object` + `properties:` — what the new dialect refuses at the root is a `state_schema` root, not a catalog entry. 28 flow-form sites to migrate, across both dialects. (2) **`type: secret` becomes legal in a shared type** — *not asked for on input, the platform mints it; in `state_schema` it means a declared secret* — so one `AclUser` can carry `password` for both contracts; the **input** half is **resolved and built (NIM-751, 2026-09-02)**: such a property is stripped from the projected operator form, is never required and takes no default, and a value supplied anyway is refused as `input_secret_type_not_writable`; the seal deliberately keeps keying on `secret: true` provenance, because a value refused at the gate reaches no cell to mask. (3) **`$type` accepts the referring node's own `properties:` — in `state_schema` only** — written as a **widening of an existing closed overlay set**, not a carve-out: `applyRefOverlay` already overlays `description`/`required`/`required_when` (`shared/config/input_types.go:430`), `input_type_ref_conflict` refuses the closed `{type, properties, items}` (`:100`), and `properties` moves from the second list to the first while **`type`/`items` stay refused everywhere** (they replace a shape; a reference that replaces the shape is not a reference). The merge is [ADR-009](0009-scenario-dsl.md)'s `extends:` covenant **by reference** — add-only shallow, fail-closed `input_type_ref_overlay_conflict`. The secret rules are deliberately **not** among `soul-lint`'s standalone `types.yml` checks (`soul-lint/internal/validate/type_refs.go:6-77`): a type carrying `type: secret` is neither legal nor illegal in isolation, only at the point of use. Design only: NIM-742/743/744. | | [0063](0063-bootstrap-token-delivery.md) | `core.bootstrap.delivered` — keeper-side bootstrap-token delivery over SSH | active | A new keeper-side core module (`on: keeper`) for thin delivery of a per-VM bootstrap token to freshly created cloud-init VMs. **Closes BUG#2 cloud-provision**: the keeper-side stub address `keeper.push.applied` did not exist → a created VM did not get a token → the `await_online` barrier never gained presence → `error_locked`. **Design A1 "thin delivery":** cloud-init (B-flat) has already installed the soul binary+CA+unit; the module places ONLY the token (`/etc/soul/token`, umask 077, 0400, **★the token goes via STDIN not argv**) + opt. `systemctl start soul`. Per-host flow (sequential): `Authorize` (deny→fail-closed) → an ephemeral keypair+`Sign` → `push.Dial` (a CA-signed host-cert verify) → write the token → opt. start. **B1-strict** (any host's error → `failed`). Params: `hosts` (`${ register.provision.hosts }`), `ssh_provider`, `token_path`/`ssh_user`/`ssh_port`/`start_soul` (defaults). Output `hosts[]={sid,delivered,started}`+`count` — **WITHOUT the token**; audit `bootstrap.delivered` — `{count, sids}` with no tokens. Reuses `keeper/internal/push` (ephemeral/Sign/Dial/Session). Registration is **conditional** (like `core.choir`): with the full set of SSH deps (provider+host-CA+dialer). **MVP boundaries:** one key-based provider, token only, hosts sequential. **★ C1 — a cloud-init CA-signed host-key (required-for-live, NEXT slice):** until then, `push.Dial` rejects a fresh VM's host cert (a bare host-key) → live-e2e won't pass (the module is valid on render+unit). **Amendment (Teleport by-name transport):** a second mode `transport: teleport` (vs the default `direct`) — by-name via the Teleport Proxy (target=SID, NOT primary_ip), transport+auth+host-verify via a Teleport identity file (Authorize/Sign/Vault-host-CA are NOT needed, C1 doesn't apply), retry-until-join (`join_wait_timeout`); creds — `keeper.yml::push.teleport`, the plugin does not participate in the flow. **Amendment 2026-06-30 (full-install mode):** two delivery modes — **token-only** (cloud-init installed the setup, the current one) and **full-install** (delivered installs the ENTIRE setup over Teleport SSH — for platforms without cloud-init userdata, e.g. a namespace with `ci_user_data` disabled); the install blueprint is factored out into the shared [keeper/internal/soulinstall](../../keeper/internal/soulinstall) (a canonical `Blueprint` + `RenderCloudInitYAML` userdata + `RenderInstallScript` SSH steps, DRY); the blueprint source = `keeper.yml::cloud_init` (config reuse); the secret write is via SSH stdin (not argv) in both modes; slices 1=blueprint extraction (done)/2=install mode/3=scenario+live; cross-ref [ADR-017(h)](0017-keeper-side-core.md) (`generate_userdata` is NOT the only onboarding path). **Amends [ADR-017](0017-keeper-side-core.md) / [ADR-061](0061-onboarding-await-and-midrun-reresolve.md) / [ADR-015](0015-core-modules-mvp.md).** **Split into its own file.** **Amendment 2026-07-26 (NIM-189):** a host flagged `onboarded: true` by `core.cloud.created` (already up on a re-run, no token issued) is **skipped** rather than failed — reported as `{sid, delivered: false, started: false, onboarded: true}` and counted in a new `skipped` output; the flag is the ONLY exemption from the required `bootstrap_token`. **Amendment 2026-09-04 (NIM-780):** the same exemption moves to the side that mints, because [NIM-757](0017-keeper-side-core.md) turns the cloud driver into a plugin that knows nothing about Souls and cannot produce the flag. `core.bootstrap.issued` passes a `connected`/`disconnected` Soul **of this run** through as `{sid, onboarded: true}` — no token, no write — instead of refusing it; ownership is `EnsureProvisionable`'s own predicate (member of this incarnation, or of none), shared as `keepersoul.OwnedByRun`, so a host of ANOTHER incarnation is still an identity takeover and still rolls the batch back. Found live: a `create` that failed AFTER onboarding could not be repeated at all, and the service had no roster to narrow `sids` with. Output/audit gain `skipped`; the SID keeps its slot in `hosts[]` because delivery refuses an empty list. | | [0064](0064-secret-write-path.md) | Secret write-path — accepting a plaintext secret from the operator, writing it to Vault keeper-side | accepted (implementation pending); amended | Dual-mode secret acceptance in Herald/Provider CRUD: the operator passes either `secret` (plaintext) **XOR** `secret_ref` (a vault path, the current mode). On plaintext, Keeper ITSELF writes to Vault at a deterministic `secret///` (`WriteKV`, the same one sigil/cert uses) → only the internal ref `vault:#` goes into PG; plaintext is never persisted anywhere. **A generalization of the existing keeper-side write path** (`sigil.Introduce`/cert `issueMaterial`/`core.vault.kv-present`) to accepting plaintext FROM the operator. **★A deliberate security trade-off** (relaxing "a secret never leaves Vault" for UX, confirmed by the user 2026-07-01) UNDER mandatory mitigations: a TLS transport / strict masking at every sink + guard tests against leaks (`secret` under `sensitiveKeyRe`; a huma body is NOT auto-masked → an explicit audit) / plaintext is never persisted / vault-policy write prefixes. `update` overwrites at the stable path. RBAC — reuses `herald.create`/`provider.create` (no new permission). Scope MVP = Herald+Provider; the operator's TLS PEM is deferred (drags essence→PG). Rejected: the name pattern `Consign`/`Entrust`; a `oneof` form; a `secret.write` permission; a ULID-immutable path. **Amendment 2026-07-08 (NIM-73):** the path of a not-found Vault secret in resolution error texts is given in flat form, bypassing the observability masker (a location, not a value — no leak); masking of the secret's VALUE is preserved. **Amends [ADR-052](0052-herald-notifications.md) / [ADR-017](0017-keeper-side-core.md).** **Split into its own file.** **Amendment 2026-08-26 (NIM-706, [ADR-0083](0083-declared-secret-state-fields.md)):** the two domains fix the first segment of a path family in the same KV mount into which ADR-0083 §1 derives `///`, so a service named `herald`/`provider` lands on this path's own slots — one entry, two writers, and `WriteString` REPLACES (KV v2 has no merge) where the mint reads-then-merges, so the second write deletes the first one's fields silently. Both words are now **reserved service names**; the write path is unchanged on purpose — the collision can no longer be constructed, and `WriteMap` beside it MUST replace (rotating `{access_key, secret_key}` → `{token}` has to leave the old pair dead). A coupling guard asserts `config.IsReservedVaultNamespace` over the `DomainHerald`/`DomainProvider` **constants**, so renaming a domain without moving the reserved list fails the build. | | [0065](0065-core-module-installed.md) | core.module.installed — delivering SoulModule plugins to the Soul host | amended | The canonical delivery channel for custom modules (`soul-mod-*`) to the Soul host + closing open Q No. 5 (a module registry in Keeper). **Transport** — a third RPC on `service Keeper`: server-streaming `FetchModule(PluginFetchRequest) returns (stream PluginChunk)` (the same mTLS listener as EventStream; a separate HTTP/2 stream doesn't choke the control plane; content-addressed — only the sha256 of an active Sigil grant `kind: soul_module`; auth = the mTLS peer cert; guard rails `max_artifact_size_mb` + a per-SID rate limit). **Registry** — the config catalog `plugins.soul_modules[]` (symmetric to `cloud_drivers`/`ssh_providers`) + the existing plugingit resolve into an FS cache + a Sigil grant (reused); **no new storage** (PG=grants, FS=bytes, git=provenance); an S3 artifact store — post-GA, behind the fetch abstraction. **Params:** `name` (required, `.`) + `ref` (opt, a pin check against the grant, NOT a version choice); idempotency by sha256 == the active Sigil. **hot-register is MANDATORY** (a thread-safe Rescan with no daemon restart — otherwise `community.redis.*` won't work within the same run); the beacon registry is NOT rebuilt on rescan (post-MVP). **Scenario** — an explicit install step (the canon "the operator writes it explicitly"); `service.yml::modules[]` — a post-MVP validation hint, NOT auto-inject. **Sigil-verify:** an allow-check BEFORE fetch (`module_not_allowed` before a single byte) + a full verify before the atomic rename; the manifest comes from `PluginSigil.manifest_raw`. The Soul-side cache is catalog-style `/-/{manifest.yaml, soul-mod-}`. Reasons: `module_not_allowed`/`module_fetch_failed`/`module_verify_failed`. MVP boundaries: no `absent` (TTL cleanup), ~~no auto-inject~~ (lifted by the 2026-07-03 amendment), no beacon-hot-reload. **Amendment 2026-07-03 (auto-synthesis from modules[]):** the canon "only an explicit step, no auto-inject" is lifted — Keeper synthesizes Soul-side install steps from the explicit manifest declaration `service.yml::modules[]` (`{name, ref}`): the point is the scenario runner after include expansion, BEFORE Stratify (symmetric in check-drift, Acolyte claim-render, and the L0 harness), the synthesis step = a regular plan task (render→dispatch→TaskEvent, name-marker `install (service manifest)`); position — before the first consumer (a `module:` with the `..` prefix, a consumer inside a block → before the whole block), with no consumers it is NOT synthesized; Passage — by the general Stratify axes (a roster consumer moves after the roster-refresh boundary, provision-from-zero with no special logic); dedup — an explicit step with the same literal `params.name` disables synthesis; idempotency is module-only (sha256, no plan-level skip); no keeper-side fail-fast (caught by the Soul-side allow-check `module_not_allowed`); push/oneshot are unaffected; consumers = top-level/block `module:` tasks (destiny-only via `apply:` — a follow-up); the post-MVP validation hint is preserved in the reverse direction (used, but not declared). **Amends [ADR-012](0012-keeper-soul-grpc.md) / [ADR-020](0020-plugin-infrastructure.md) / [ADR-015](0015-core-modules-mvp.md); [ADR-026](0026-sigil.md) is NOT changed.** **Split into its own file.** **Amendment 2026-08-06 (NIM-377):** the Soul-side slot is named by the **registration alias** (`//`), holds one executable whose filename means nothing, and carries the canonical-JSON schema document instead of `manifest.yaml`; the schema is stamped into the artifact as a trailer, so (f) step 4's "the manifest does NOT travel through `FetchModule`" is no longer true. The delivery mechanism itself — `FetchModule`, allow-check-before-fetch, fail-closed verify, hot-register — is unchanged. **Amendment 2026-08-07 (NIM-524):** that amendment said `modules[].name` "becomes the registration alias" while leaving the field's two-level regex in place — and the producer kept passing the whole `.` into a `params.name` that forbids the dot, so **every service declaring `modules:` failed at apply on every host**, both ends green in isolation. The declaration stays an address (`.` — what the scenarios write); the synthesizer takes **level 1**, because the step installs a slot and level 2 addresses a module inside it. Several entries of one artifact collapse into ONE install; a `ref` disagreement under one alias is the new diagnostic `conflicting_module_ref`; `destiny.yml::required_modules[]` is unaffected (it synthesizes nothing). **Amendment 2026-08-26 (NIM-543):** that fix reduced the synthesizer's OUTPUT and left its takeover comparison keyed on raw spellings — the manifest half reduced to the alias, the explicit-step half the literal the author typed — so the documented escape hatch worked only for an author who happened to write a bare alias, and one writing `name: community.redis` (the form (e) itself showed) got a SECOND install of the same artifact beside their own, both failing on the host. **Both halves now reduce to address level 1** through one function; a dotted explicit step IS a takeover and is separately reported offline as the new diagnostic **`module_install_name_not_an_alias`** (predicate `plugin.ValidAlias` — the Soul's own rule, not a local "has no dot" test), keyed on the base address `core.module.installed` so a dotted `core.pkg.installed` name is untouched; it reads the value RAW (`reAlias` runs untrimmed) and through block scalars, catches a null `name:` that nothing else offline spoke for, and shares ONE `${…}`-cell predicate with the takeover half so partial interpolation cannot split them. Riders: the synthesizer's reserved skip now reads the shared reserved list instead of a `core.` prefix (`keeper.*`/`soul.*` were being synthesized), and the schema description that still promised `"."` now states the alias. **Amendment 2026-09-04 (NIM-794): the fetch step goes to the source, and `FetchModule` stays as the egress-free path.** Of `applyInstalled`'s six numbered steps — allow-check, idempotency, fetch, verify, atomic install, hot-register — **exactly one changes**: step 3's endpoint. The other five are transport-independent, none of them reads where the bytes came from, and the order is preserved verbatim (rights before network, signature before disk) — which is not incidental but exactly what makes fetching from an untrusted source safe. ★ **`FetchModule` is NOT deprecated, NOT transitional and NOT scheduled for removal:** it is the delivery path for hosts with no egress, a permanent class of host rather than a migration state, and a later session deleting it as dead code breaks every air-gapped fleet. ★ **The platform axis was recorded wrong and is CORRECTED against the shipped code:** the amendment decided `runtime.GOOS`/`runtime.GOARCH` "explicitly not `OsFacts.family`", and what shipped reads the host's **own Soulprint facts with the running binary as the fallback** — the primary→fallback shape `util.ResolvePkgMgr` already uses. The concern behind the original decision was real and the code answers it rather than routing around it: `os.family` is a *distribution* family on Linux where a row names an operating system, so a `{os: linux}` row matched against `family` alone would refuse **every** row on **every** Linux host — therefore the four Linux families **collapse to `linux`**, and any other family is already a GOOS, because Soulprint fills `family` from `runtime.GOOS` wherever there is no `/etc/os-release`. The two supporting reasons are stale with it: `coremod/module` **does** implement `util.SoulprintAware` now (`Deps` still carries no facts — the facts arrive by the same route `core.pkg` uses), and the push-mode gap is handled explicitly (`case "":` → the running binary's platform, guarded). No matching row → a closed refusal, not a fallback. ⚠ **(b)'s "NO new storage" now holds only on a condition:** for `FetchModule` to serve a grant covering N platforms the **Keeper must hold N artifacts per slot**, which it does not today — `LookupModuleBinary` skips any row whose digest is not the slot's one executable, so N−1 fetches can never succeed and fail as `module is not allowed`, pointing at the grant, the wrong place to look; the per-artifact `plugins.max_artifact_size_mb` then applies N times. The **Soul-side slot needs NO change** and still holds exactly one executable (the host installs only its own row) — carrying the N across would make discovery ambiguous and the slot refused. Two safety consequences, one now closed: verification is still **in memory** (fetch → verify → install on a `[]byte`, so unverified bytes never touch disk) and an implementation spooling a large artifact to a temp file would give that up as a deliberate trade; the missing Soul-side size ceiling **shipped** as `maxArtifactBytes` — a **constant, not a `soul.yml` field**, reusing Keeper's *default* value as its own, 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` itself is still unbounded and still correct, because its peer is the Keeper. **Newly recorded from the code:** the transport rule is **six-way**, not "no row → refuse" — no rows → Keeper; a row matches → the source; no row for this platform → a closed refusal naming what the release covers; the source did not answer and a session is live → Keeper **with a warning**; the source did not answer and there is no session → a refusal saying so; the source served the **wrong bytes** or the address is unusable → a refusal. ★ **Keeper can stand in for a source that is down, not for one serving something else, and not for a catalog field it cannot fix** — which is why a 4xx is a refusal while `408`/`429` and 5xx fall back. The final event carries **`fetch_via`** (`source`/`keeper`) and **`fetch_url`**, deliberately absent on the idempotent no-op and `fetch_url` absent on the Keeper fallback. Three security decisions recorded: the HTTP client and timeout are `core.url`'s (`util.NewHTTPClient` / `util.DefaultFetchTimeout`) so two core modules cannot diverge on redirect, TLS or timeout; the **SSRF dial guard is lifted deliberately** — a `base_url` is catalog configuration an Archon wrote, not run input, and an in-perimeter repository answers on a private address — while **every other guard stays, https-only included**, and the verify still gates the disk; and the path is **joined, never interpolated** (absolute path, own URL, `.`/`..`, backslash, query or fragment refused, as written and percent-decoded; a `base_url` with no host, credentials or a query refused too). **Push mode:** the transport half of the refusal is gone and guarded — a session-less run installs from the source — but the step still fails closed at the **allow-check**, because push mode has nil sigils/anchors; where a grant set comes from with no `SigilSnapshot` remains **explicitly not decided**. ⚠ **Half-wired, and NIM-795 inherits it:** `SigilRecord` gained `BaseURL`/`Artifacts` and **nothing fills them from the proto**, so until the signed block covers the row list a grant installs only on the platform whose row digest equals `BinarySHA256hex` and **every other row fetches and then fails closed at verify**. **Rider corrections:** (f) step 1's allow-check keys on the **registration alias**, not `(namespace, name)` (stale since NIM-438), and the PG-schema bullet's migration is **115**, not 113. Status: the **Soul half is implemented** (NIM-796, `2daf8545`); the keeper half is **not** (NIM-795), epic NIM-793. | | [0066](0066-teleport-onboarding-profile.md) | Onboarding via Teleport on platforms without cloud-init userdata — environment constraints and working profiles | active | A deployment-profile decision on top of [ADR-063](0063-bootstrap-token-delivery.md)/[ADR-061](0061-onboarding-await-and-midrun-reresolve.md)/[ADR-017(h)](0017-keeper-side-core.md) (contracts unchanged). **Environment constraints (live-verified):** `ci_user_data` is disabled at the namespace level (all keys/clouds/SA → both userdata onboarding paths are unavailable); direct keeper→VM SSH is closed; VM access is only Teleport behind an L7 TLS balancer. **Production profile:** full-install via `core.bootstrap.delivered` `transport: teleport`; environment requirements — bot identity (Machine ID) without `pin_source_ip` (identity from an interactive `tsh login` carries the PinnedIP OID `1.3.9999.1.9` = the issuing src-IP → keeper from a different src-IP gets access-denied; plus MFA/TTL of the interactive session), `alpn_upgrade: true` + system trust for the private LB root, a working external IP on the VM for enroll (`set_external_ip: true` + a driver probe waits for activation). The 403 history: PinnedIP was attributed first, the actual code wall was ALPN NextProtos h2-first (fix `NextProtos=nil`). **Live-proven by an E2E create run.** **Demo/dev profile:** a local keeper + an enrolled VM as a bastion (`tsh ssh -R` reverse tunnel) + Teleport shell-exec onboarding from the operator's machine; boundaries — demo/dev, not production. Rejected: `core.teleport.shell` (plan B — bypasses Sigil, an epic, doesn't solve PinnedIP; a fallback), waiting for `ci_user_data` to be enabled as a blocker. **Split into its own file.** | | [0067](0067-vector-log-shipping.md) | Mandatory log-shipping (Vector) — the log plane of data services | active | Vector (`vectordotdev/vector`) — a mandatory **PUSH log plane** on every data service ("like node-exporter"); **complements** node-exporter/redis_exporter (metrics, pull), the three independent observability layers don't overlap. **Design — a clone of the node-exporter reference** (the stateful branch of [production-conventions](../destiny/production-conventions.md)): a new standalone destiny [`vector`](../../examples/destiny/vector/destiny.yml) — `core.url.fetched` (a GitHub tarball + a **mandatory sha256** fail-closed) → `core.archive.extracted` → a stable system account (NOT DynamicUser — the persistent disk buffer `data_dir` survives a restart) + `data_dir` 0700/`config_dir` 0750 → `core.file.present` the binary + `core.file.rendered` `vector.yaml`+a hardened unit + `core.service.running`/`restarted` onchanges; arch = a Rust triple (`soulprint.self.os.arch` amd64/arm64 → x86_64/aarch64). **Introduces no new core module** (assembled from existing ones per [ADR-015](0015-core-modules-mvp.md)). **Wiring — an unconditional `apply: destiny vector` at the end of `create`** (after deploy+exporters, no `when:` gate) — a data-service invariant, the entire contract lives in **essence** (contract A author-context, `covenant`/`form` untouched); the only per-service difference is `vector_log_sources`. **sink — Variant A** (per-incarnation essence: `vector_sink_type`/`sink_endpoint`/`sink_auth_ref`; default `console` — no external infra); `sink_auth_ref` — a Vault ref/value, resolved Soul-side, **does NOT land in state** (symmetric to `tls.*_ref`, the unit env var `Environment=VECTOR_SINK_TOKEN`). **B** (globally in keeper.yml) / **C** (a hybrid) — a follow-up. The **state read-model** `logging.vector_*` (without `auth_ref`) + a state_schema bump + a per-service migration (redis `014_logging_read_model` v13→v14 forward-only with a `has()` guard). The name `vector` is upstream (the node-exporter precedent), in [naming-rules](../naming-rules.md). **Amends [ADR-024](0024-observability.md)** (adds a push log plane alongside pull metrics + OTel traces). Related: the node-exporter reference (a pattern clone, it has no ADR of its own), [ADR-015](0015-core-modules-mvp.md)/[ADR-010](0010-templating.md)/[ADR-009](0009-scenario-dsl.md). **Split into its own file.** | | [0068](0068-service-upgrade-v2.md) | Upgrading incarnations to a new service version — an `upgrade/` folder + the upgrade-paths API | active | Extending `POST /v1/incarnations/{id}/upgrade` (today — a pin change + state migrations [ADR-019](0019-state-migration-dsl.md) → `drift`) to **two-phase**: a structural migration + **optional host orchestration** via an upgrade scenario. **The `upgrade//` catalog** (separate from `scenario/`, hidden from day-2 — doesn't scare the operator, convenient for retries) with **self-describing** `from: [tags]` (symmetric to `create: true` [ADR-065](0065-core-module-installed.md); auto-discovery per [ADR-009](0009-scenario-dsl.md), no block in `service.yml`). **The `from` direction in the new version** (immutable tags [ADR-007](0007-versioning-git-ref.md) + forward-only [ADR-019](0019-state-migration-dsl.md)). **2 branches:** found → pin+state-migrations+auto-launch the scenario (`Runner.Start`; a failure → `error_locked`/`rerun-last`); not-found → **legacy** (today's behavior + a WARN). Fail-closed 422 is **dropped** (`upgrade/` is inherited across patches — it would break `v2.0.0→v2.0.1`). **Discoverability** — `GET /v1/incarnations/{id}/upgrade-paths` (keeper enumerates tags cheaply; per-target found/legacy + state migrations via `?to=`; incarnation-scoped, since `from` = the incarnation's pin). `spec.input` is **NOT migrated** on upgrade (the truth is `state`, [ADR-057](0057-state-changes-crud-verbs.md)). Naming: the folder is named `upgrade/`, not `migrate/` — that word is already triply overloaded (data migration `scenario/migrate_cluster/`, the schema `migrations/`). Non-goals: auto-chaining `v1→v3`, semver, a bulk upgrade (NIM-35/[ADR-043](0043-voyage.md)). Impl — NIM-34. **Amendment 2026-07-04 (the NIM-34 impl):** the cheap mode (without `?to=`) = only `is_current` (direction isn't computed — a ban on semver-parsing tags, ADR-007); `?to=` gives `direction` with 4 values (+`same-schema`), and a structurally broken migration chain = `200 reachable=false`, NOT 422. **Split into its own file.** | | [0070](0070-secret-reveal-path.md) | Secret reveal-path — revealing an incarnation's plaintext secret to the operator under an RBAC right | amended | ⚠️ **Amended 2026-08-19 ([ADR-0083](0083-declared-secret-state-fields.md), NIM-698): the `revealable_secrets` registry is DELETED — the endpoints, the right and the audit event stay, the path is DERIVED from a `state_schema` field carrying `type: secret`.** A READ counterpart to [ADR-064](0064-secret-write-path.md) (write-path): ADR-064 accepts plaintext FROM the operator into Vault, this one gives plaintext BACK to the operator under an explicit right. **A declarative `revealable_secrets[]` registry in the service manifest** (generic, NOT a redis hardcode): `{id, label, enumerate: state., vault_ref: "…/{incarnation}/…/{key}#field"}`. **Restricted placeholders `{incarnation}`/`{key}`** (a literal substitution, NOT CEL — a smaller attack surface): `key` must be ∈ the enumerate array of the CURRENT state (anti-arbitrary), the manifest version = `ServiceVersion` (anti version-craft), `vault.ParseRef` cuts off traversal. Endpoints `POST .../secrets/reveal {secret_id,key}→{value}` (self-audit `incarnation.secret_revealed` WITHOUT the value) + discovery `GET .../secrets/revealable→{items}` (READ, no audit). Authorized reveal → a DTO **bypassing `MaskSecrets`**. **★A security trade-off** (a mirror of ADR-064: plaintext Keeper→operator over TLS) UNDER mitigations: the RBAC gate `incarnation.view-secrets` (scope `coven=`/`service=`/`incarnation=`, fail-closed 404 outside scope) / auditing the fact WITHOUT the value + leak-guard tests / no body logging / the key lives in state / a traversal guard / a vault-policy read prefix. The right `incarnation.view-secrets` is **strictly more privileged than `incarnation.get`**; no MCP (REST-only, like form-prefill). Rejected: a redis-hardcoded endpoint (in favor of the generic registry); CEL in `vault_ref` (in favor of placeholders); reusing `incarnation.get`. Deferred: a singleton with no `enumerate` (an admin password). Implemented as NIM-74. **Amends [ADR-064](0064-secret-write-path.md) / [ADR-047](0047-purview.md).** **Split into its own file.** | | [0072](0072-host-utilization.md) | Host-Utilization — lightweight host utilization telemetry over the presence channel | amended | Live host utilization (CPU%/load/mem/disk/uptime) for the operator **without Prometheus** — a third cheap **push layer** on top of the Soul→Keeper presence stream, independent of the static Soulprint ([ADR-018](0018-soulprint-typed.md)) and pull-node-exporter. **Transport B:** a new `FromSoul.host_utilization = 10` (message `HostUtilization`, `proto/keeper/v1/utilization.proto`); alt-A (reserved fields 8-14 in `SoulprintFacts`) rejected (static→live, 5m cadence, would pollute the `soulprint` CEL namespace); only-add [ADR-012(c)](0012-keeper-soul-grpc.md). **Pulse** 30s default / floor 10s (clamp+warn), single-writer `handleSession`. **Storage in Redis only** ([ADR-006](0006-cache-redis.md)): latest Hash `soul::util` + TTL 3×interval + list-ring `soul::util:win` (`LPUSH`/`LTRIM`, N=60) — not RedisTimeSeries (portable to `redis:7-alpine`/DragonFly). **Invariants:** liveness does not depend on utilization (authority is the lease, graceful degrade), freshness `stale` (stale data is never served as fresh), SID only from the mTLS peer cert (not from the payload). **API** `GET /v1/souls/{sid}/telemetry` + `GET /v1/incarnations/{id}/telemetry` (aggregate `coven && ARRAY[name]`). Deferred: config delivery to collectors (**NIM-87**), web HostsTab (**NIM-88**). **Amends [ADR-024](0024-observability.md).** **Split into its own file.** **Amendment 2026-07-18 (NIM-127):** +network (`net_rx_bps`/`net_tx_bps`/`net_err_ps`, aggregate physical-NIC rate) + inode (`DiskUtilization.inodes_used`/`inodes_total`, from the same statvfs) + collector `net`; server-side worst-case `IncarnationRollup`; two-tier UX (soul-page Overview strip + `Utilization` tab, incarnation curated columns + rollup); disk-IO deferred. **Amendment 2026-07-28 (NIM-248, revised 2026-08-05 by [NIM-281](0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited)):** config delivery resolves the host's incarnation through **`incarnation_membership`**, and the coven overlays of its essence through **`incarnation.covens`** — the incarnation's own labels selecting layers of its own config ([ADR-0082](0082-service-vars.md)), never a host label. Two questions, two sources; neither is `souls.coven[]`. It used to ask both at once (`FROM incarnation WHERE name = ANY()`), which migration 099 emptied, so **the effective telemetry config reached no host of any incarnation** through the legal, unlogged "no incarnation → soul-local" branch; the asymmetry that hid it is that the READING half (the two `/telemetry` endpoints) had been converted by NIM-124 and kept showing a normal member aggregate. Membership is never answered from a host's tags (a tag spelling an incarnation's name is not a binding, and since NIM-281 a binding leaves no tag either). M:N: the first membership by name wins, now logged at WARN with the full list — an explicit selection is NIM-279. | | [0073](0073-keeper-runtime-config-pg.md) | Keeper runtime-config → Postgres — the `SettingsStore` overlay | active | Reload-able Keeper parameters move out of per-VM `keeper.yml` into `keeper_settings` (Postgres) and hot-sync across the stateless HA cluster without a restart — the "first real request" that [ADR-021(f)](0021-hot-reload-config.md) waited for. **Three layers, per key:** built-in default < Postgres < `keeper.yml` (**amended 2026-07-27, NIM-141** — a value set in a host's own file outranks the cluster one; the catalog reports the shadowed `cluster_value` so the override is visibly, not silently, ignored). **★ Target end-state — `keeper.yml` shrinks to the bootstrap floor:** everything that can live in Postgres does, and the file keeps only what physically cannot (`postgres.dsn_ref` / `vault.*` / `redis.*` — chicken-and-egg; `kid` and `listen.*` — per-instance; `logging.*` — must work before Postgres; `hot_reload.*` — governs the mechanism; `auth.jwt.signing_key_ref` / `metrics.auth.*` — security-critical). **Operator surface is part of the contract:** API/MCP + an editable web-UI form + a dedicated RBAC permission family, with the field-registry **published as a backend catalog** ([ADR-042](0042-backend-driven-ui.md), the `GET /v1/herald-types` shape) carrying type, bounds, default, effective value and `source` — so a new key appears in the UI with no front-end change, and a key with no live apply path is flagged `requires_restart`. **The overlay sits in `shared/config.Store`** (late-binding, the `SetAuditWriter` pattern) — not in the consumers: Toll (`OnReload`) and Tempo (per-request `Get()`) read the file snapshot, so switching the source below them means **zero consumer edits**; merge policy lives in `keeper` ([ADR-011](0011-go-layout.md)). **Merge = patch the in-memory Document + re-run the full parse/schema/semantic pipeline** ([ADR-021(c)](0021-hot-reload-config.md)), never a second validation dialect; the patched Document **never reaches disk** (write-back for an overlay key targets Postgres). ★ **Prerequisite:** create-on-write in `shared/config` — `PatchKeeper` returns `ErrPathNotFound` today, and optional blocks (`toll:`) are legal, so without it a never-touched block cannot be overridden. **Namespace** — a reserved `cfg_*` prefix in `keeper_settings` (disjoint from the [ADR-029(g)](0029-service-registry.md) well-known keys), key↔YAML-path mapping explicit in a Go field-registry, not a mechanical transliteration. **No seeding** from the file (supersedes the NIM-126 sketch): absent row = the layer below shows through, `DELETE` = clean revert, no per-host drift; discoverability via a read endpoint returning the effective value + `source ∈ {default, file, pg}`. **Invalidation** reuses `service:invalidate` (envelope `{origin_kid, at}`, self-filter) + a **mandatory idempotence guard** — without it the 10s TTL-poll would fire a swap every 10s. **Fail-soft:** broken Postgres at startup → file base + WARN (**not** fatal, unlike `serviceregistry.NewHolder`), at runtime → last-good; a bad row rejects the **whole** overlay; break-glass `KEEPER_CONFIG_SOURCE=file`. **Write-gate** validates + range-bounds before publish (422, row unwritten). **Admission is closed** — reload-able ∧ not a security gate ∧ scalar ∧ outside the bootstrap/require-restart classes. Audit reuses `source: keeper_internal` (**the enum is NOT extended**). Deferred: `config_history`/rollback, the ‡ hot-apply group, typed columns, structural values, `soul.yml`. **Amendment 2026-07-30 (NIM-292):** the `console:` block joins the overlay, `console.enabled` included — a **security gate admitted against the letter of (j.2)**, by explicit decision and recorded so it is not read as drift. What separates it from the `audit.enabled` refusal: the fail-soft failure mode cannot occur, since the console plane needs the same Postgres that mandatory recording needs ([ADR-0074(g)](0074-interactive-console-pty.md)), so the more permissive value is unreachable during an outage. Cost stated plainly: `setting.update` becomes the right to switch the plane back ON cluster-wide; the mitigation is (b) itself — a pin in the host's own `keeper.yml` outranks the cluster row, and that is where the hard guarantee lives. Admission required giving the block a live apply path (j.5): the Hub and the recorder now resolve their envelopes per open/sweep/session instead of once at construction. `console.recording.retention` (no live apply path) and `console.errand_shell_gate` (NIM-197's key) stay out. **Amends [ADR-021](0021-hot-reload-config.md)**, extends [ADR-029(g)](0029-service-registry.md). | | [0074](0074-interactive-console-pty.md) | Interactive console — live PTY sessions on a managed host | amended | A **third execution mode** beside [Errand](0033-errand.md) and [Voyage](0043-voyage.md): a living tty on the host, so `top`/`vim`/`cd`/`^C` behave — programs test `isatty` and switch to full-screen, line editing and job control. **A console is NOT an Errand and is deliberately not a variant of one:** an Errand is request/response (one named module, one final blob ≤64 KiB, a known end) and can be checked against a module allow-list **before** it runs; a console is an open-ended byte stream whose commands are not knowable in advance, running as the Soul daemon's user (typically **root**). Own runner (`consolerunner`, never `errandrunner`), own session manager, own right, own audit events. **Transport — only-add, no new RPC:** `console_*` members on the existing bidi `EventStream` oneof (`FromKeeper` 13-16 / `FromSoul` 11-13, `proto/keeper/v1/console.proto`, frozen numbers + descriptor guard) [ADR-012(c)](0012-keeper-soul-grpc.md); the ONE new transport is the operator's **WebSocket `GET /v1/console`** — the only WebSocket in Keeper, justified because SSE cannot carry keystrokes back — auth in the `bearer.` subprotocol (a browser cannot set headers; keeps the token out of logs/`Referer`/history), deliberately **absent from OpenAPI** (an upgrade has no response body to model). Session ids are per-plane and never cross. **The right is `soul.console`, strictly stronger than `errand.run` and independent in BOTH directions**; selectors are the existing `host=`/`coven=`, **no new selector keys** (intersection via Purview dimensions, [ADR-047 §S4](0047-purview.md)). **Checked twice**, because the target host is not in the URL: an **existence-gate** (`HoldsAction`) before the upgrade → 403 with no socket, and a scope-aware `host=` check per `open` frame → a session-scoped error that leaves the socket's other panes live. ★ The upgrade gate **must not** be a scope-aware `Check(nil)` — an absent dimension fails closed, so it would deny exactly the `host=`-scoped roles the feature serves ([ADR-047 §g G1](0047-purview.md)); pinned by a guard test. ★ Recorded consequence: `soul.*` covers `soul.console` (the widening `incarnation.*` took with [ADR-0070](0070-secret-reveal-path.md)) — enumerate actions instead of the wildcard to withhold shells. **Two ceilings, two different questions:** HOST policy in `soul.yml` (`enabled`, `max_sessions` 8 — may a shell run here at all) vs OPERATOR policy in `keeper.yml` (`max_sessions_per_archon` 30, `max_sessions_global` 256, `idle_timeout` 30m — a wall over 30 hosts is one session on each, which no host can judge). **Two non-negotiable invariants:** kill-on-disconnect on both halves (a pty never outlives its EventStream, a session never outlives its socket; teardown escalates SIGHUP→hang-up→sweep the terminal **session**, since an interactive shell puts each job in its own process group), and **lost output is always visible** (bounded queues drop chunks but never lifecycle frames, `dropped_bytes` + metrics on each side — a silently spliced ANSI stream would show a screen that never existed). **Audit records the fact independently of the content** (`console.opened`/`console.closed`), because who opened a shell where must survive every degradation of the recording path. **Recording is mandatory before the console is on by default** — enforceably: the plane is opt-in and off unless configured, and when recording lands (NIM-145, amendment below) it is on for every session and not disableable per-session. **★ An approval-gate on opening a prod console is REJECTED for R5, bounded by a re-open condition**: there is nothing to attach "production" to (a [Coven](0008-coven-stable-tags.md) is a label on `souls.coven[]` — no coven entity, no environment classification), the engine it would reuse (NIM-113) is an unbuilt draft, and a coarser preventive control already exists — the scope of the right itself, since obtaining `soul.console on coven=prod` is a role grant = a second human under the least-privilege subset check + audit. Conceded: that is a standing key, so the posture is **detective-leaning**, stated rather than implied; revisited when Coven classification gains a home OR NIM-113 ships. Cheaper candidates noted, not built: a mandatory `reason` on open and a [Herald](0052-herald-notifications.md) notification on `console.opened`. Rejected: reusing `errand.run`; ~~a new RPC~~ (reversed, below); a `console` selector key; a separate `console.*` resource to dodge the `soul.*` wildcard; documenting the upgrade in OpenAPI; buffering under flood; resuming a session across a reconnect. **★ Amendment 2026-07-27 (NIM-188) — the Keeper↔Soul console gets its own RPC.** `ConsoleStream(stream ConsoleFromSoul) returns (stream ConsoleToSoul)`, one bidi stream per console session, dialed by Soul on the SAME mTLS listener and connection ([ADR-012](0012-keeper-soul-grpc.md) amendment; the `FetchModule` precedent). The original "no new RPC" weighed it as a **contract** question and found no benefit; the cost that mattered is **contention** — every `FromSoul` shares one write mutex, so pty output and `TaskEvent`/`RunResult` take turns (the drop in (e) was a *symptom* of the shared carrier, not a property of consoles), and downstream a paste could fill the 10-slot per-SID queue and fail a run's `ApplyRequest`. The stated cost was also wrong: same TCP connection, one more HTTP/2 stream, only while a console is open. **`ConsoleOpen` still rides EventStream** (only a client may dial; it also keeps a close ordered behind its open). **Attach + ack** (`ConsoleAttach`→`ConsoleAttached`): a positive signal is what lets a Soul tell "no such RPC" from "not answered yet". ★ **The session id is a route, not a credential** — a stream receives a session's frames only if the SID that dialed owns it (peer cert, [ADR-012(i)](0012-keeper-soul-grpc.md)); the same check now also guards the EventStream carrier, closing a gap where a Soul could name another host's session. Attaching creates nothing — `soul.console` is still checked at the operator's socket. **Both carriers live** (no capability gate needed for correctness): an old Soul is served over EventStream, a new Soul falls back on `Unimplemented`; the carrier is settled once per session BEFORE the first frame, and Keeper holds pre-`opened` input and releases it in order — mixing carriers mid-session would reorder keystrokes across two independent streams. **Backpressure revised:** on its own stream the queue blocks instead of dropping (a slow reader throttles the pty at the source), so `dropped_bytes` reads 0 there; dropping stays correct and accounted on the EventStream carrier and browser↔Keeper. Kill-on-disconnect is preserved per session: the console streams of a SID die with its EventStream, and a console stream dying alone is terminal (Keeper synthesizes the exit, Soul kills the pty). NOT claimed: fairness between two consoles on one host. **★ Amendment (2026-07-27, NIM-147 — the non-interactive console):** the MCP surface for command execution leaves Deferred as **`keeper.soul.run-command`** (`{sid, command, cwd?, env?, timeout_seconds?}` → `{errand_id, status, exit_code, stdout, stderr, …}`) — an agent needs what an operator needs, but a pty merges the channels, echoes the input and reports only the shell's exit, so the MCP form is request/response. **The right stays `soul.console` + `host=`, no lighter one is minted**: dropping the tty removes the echo, not the privilege. The TRANSPORT is the Errand stack with the module **pinned** to `core.cmd.shell` (a caller who could name the module would be holding `errand.run`'s tool under the wrong right) — a deliberate split between what AUTHORIZES and what CARRIES, since only request/response gives split channels, an integer exit code, the 64 KiB cap and async escalation. Audit **`console.command`** beside `errand.invoked`, because the two answer different questions (a module ran vs an arbitrary command ran under `soul.console`); the command line is not in the payload, as `console.opened` holds no keystrokes. Masking is re-applied on this boundary, with its bound stated: on a free-form stream only vault provenance can fire. ★ **Known gap, not closed:** `core.cmd.shell`/`core.exec.run` sit on the Errand runner's hardcoded allow-list, so `errand.run` alone still reaches a shell via `keeper.soul.errand.run` / `POST /v1/souls/{sid}/exec` / a `kind=command` Voyage — the allow-list constrains the module, not the command line it carries; aligning those choke-points is RBAC-breaking and is tracked as NIM-197. **★ Amendment 2026-07-27 (NIM-196) — the ownership check crosses instances.** That check only bound where a SID exists, and it does not on the cluster bridge: the socket lands by load balancer and the EventStream by SoulLease, so in a cluster of N they are on different instances about (N−1)/N of the time, and the receiving instance has nothing to check with (the pub/sub message is a bare `FromSoul`). A Soul authenticated as one host could name any session id it learned and paint forged output into that operator's pane, or end the session with a terminal frame — defence-in-depth (a valid seed cert plus a leaked 26-char session ULID are needed, and the reverse direction was never open), but a console is the most privileged operator action and forging its output attacks exactly what the feature is for. **Fix: the claim `console:owner:` carries `|`, and the PUBLISHER refuses a frame whose authenticated SID is not the claimed one.** ★ Putting the SID in the pub/sub envelope is the obvious fix and the wrong one — a new instance would publish what an old one cannot parse, breaking every cross-instance console for the length of a rolling upgrade; the claim is written and read one version at a time. The boundary closes completely: it is Soul↔Keeper, the publisher sits on it, instances trust each other by construction. **Compatibility is self-closing** — a claim with no SID reads "host unknown" and routes as before; no flag, the 90 s TTL shuts the window a minute and a half after the last old instance restarts. ★ A refused frame is dropped and **not** reported as an orphan (that would send the `ConsoleClose` the forgery was reaching for) and its route is not cached (it must not seed the orphan sweep). **★ Amendment 2026-07-27 (NIM-145) — what "mandatory" turned into.** Recording lands, closing (g) and the retention line of Deferred. **Enforced by construction:** `console.NewHub` refuses to build without a `Recorder` and `keeper.yml` has **no** `console.recording.enabled` key (its absence pinned by a guard test) — "on by default" is one refactor from "off in this deployment", a type that cannot be constructed is not; the MCP `run-command` uses the SAME recorder, so the two things holding `soul.console` cannot record differently. **One interception point — the Hub**, above both carriers (NIM-188) and the cross-instance bridge, on the instance holding the socket, the only place that sees both directions of one session. Two consequences by construction: the recording holds MORE than the operator saw (a chunk is recorded before backpressure can drop it; output the *Soul* dropped becomes a gap marker), and the order is **record → deliver** both ways — no byte reaches an operator or a shell unrecorded. **Format asciicast v2** (`o`/`i`/`r`/`m`), because the artifact exists to be watched and this one already replays; stdout/stderr are NOT separated (a tty merges them, so splitting would show a screen that never existed). **Store is Postgres** (migration 104, `console_recordings` + `console_recording_parts`): Keeper is stateless, so a local file is a recording exactly one machine can read. `recording_id` rides the `console.opened`/`closed`/`command` payloads — audit stays the index, (f) unchanged. **Retention enforced, not just recorded:** `ttl_at` baked on INSERT from `console.recording.retention` (90d), swept by the Reaper rule `purge_old_console_recordings` — a mandatory recorder with no purge is a disk-growth bug with an audit story attached; baking it in also keeps retention non-retroactive. ★ **Masking must survive chunking:** a pty echoes keystrokes ONE BYTE AT A TIME, so `vault:secret/db` typed by hand matches in no single chunk and per-chunk masking would mask nothing exactly where an operator types a credential path — the recorder carries a possible-reference tail across boundaries (bounded 4 KiB) and masks **the reference only**, never the whole chunk (blanking the screen destroys the record the masking exists to make keepable). Bound restated from NIM-147: on a free-form stream only the content layer can fire. **Fail-closed at every point it can act:** recording cannot start → session refused and **no `ConsoleOpen` dispatched**, wire code `recording_unavailable`; store breaks mid-session → session closed via callback (so an IDLE shell at a prompt is closed too); per-session cap (256 MiB) → closed + `truncated`; `run-command` → not dispatched, and if its OUTPUT cannot be recorded the output is not returned — before the dispatch is the only moment fail-closed has for a command that cannot be un-run. NOT claimed: persistence is async behind a bounded queue, so a dying instance loses up to one flush window (NULL `finished_at` says so); what cannot happen is a session that keeps running unrecorded. Impl — NIM-142..147, 188, 196. **★ Amendment 2026-07-28 (NIM-148) — reading a recording back.** Three read routes (`GET /v1/console/recordings`, `…/{id}`, `…/{id}/cast` serving asciicast v2 as `application/x-asciicast`, streamed, in the OpenAPI spec — unlike the WebSocket an ordinary GET has a body a generated client can call). **★★★ The right is `soul.console` with the SAME selectors, and no auditor-grade right is minted:** a recording is the session's content moved in time, so the boundary deciding who may WATCH must be the one deciding who may OPEN — otherwise an operator refused `soul.console on host=db-01` reads every session ever held there, which is most of what the refusal was for (the NIM-147 reasoning once more: dropping the tty removed the interactivity, not the privilege; dropping the live-ness does the same). ★ Conceded and left open deliberately: a **pure auditor** cannot be given playback without shells — a read-only `soul.console-recording` is a new entry in a closed catalog and belongs in its own decision; until then narrow the SCOPE, not the right. **★★ The check splits exactly as (c) splits it** — a listing names no host, so the route gate is the existence gate (`RequireAction`) and scope is answered where a SID exists; a scope-aware `Check` there would fail closed on the absent host dimension and deny the `host=`-scoped roles this serves (the NIM-144 bug), pinned by a guard on all three routes. ★★ Past the gate it is `ResolvePurview` and **never** `Check`: `Check` accepts a context map and looks like it honours it, but a role's `default_scope` sits outside the permission it matches, so a bare `soul.console` passes for any host. The list narrows **in SQL** (souls-list pattern — offset pagination and `total` stay exact and never advertise an unopenable row); **out of scope answers the SAME 404 an unknown id gets** (a 403 would confirm existence, turning the route into an oracle for which hosts were consoled into); the host is joined **LEFT** with the **host** dimension reading the recording's own `sid`, so an INNER join cannot make deleting a host delete its evidence — after deletion a `host=`-scoped operator still matches, a `coven=`-scoped one cannot (nothing left to prove membership), unrestricted still sees it. **★ Read-only by construction:** the store splits into a write-only `Recorder` (Hub) and a read-only `Reader` (API), so an operator-facing route has no write path behind it. **★★ Nothing is masked or un-masked on read** — masking ran once at record time across chunk boundaries; the cast is served byte-for-byte, and a second masking pass would be the implementation that drifts (pinned end-to-end by typing a vault ref one byte at a time through the real recorder and reading it back through the real handler). Audit **`console.recording-read`** on the cast route ONLY (list/metadata say a session happened, which `console.opened` already said), payload carrying `recorded_archon_aid` because reading your own session is routine and another's is what an investigation asks; written BEFORE the first byte, mirroring record-before-deliver, best-effort like `console.opened`. **★ No MCP twin, deliberately** — it would hand an agent bulk raw content of other operators' shells, and an agent cannot watch a replay (asciicast's value is timing); `keeper.soul.run-command` already covers the agent case. The browser player over this API is web-side work, tracked separately. **★ Amendment 2026-07-30 (NIM-292) — Keeper can switch the console plane off.** `console.enabled` in `keeper.yml` (default `true`, so an existing file is unchanged) removes **both halves** of the plane — the interactive WebSocket and the MCP `keeper.soul.run-command` — because `soul.console` is one privilege reached two ways through different objects (Hub vs Errand transport), and a switch that nil'd the Hub alone would leave an agent running arbitrary root command lines on a cluster that had just declared it carries no consoles. **Off answers 404, not 403**, with the gate ahead of the RBAC one: a 403 confirms the plane exists, which is the question a console-free cluster should not be answering; the MCP tool is absent from the catalogue rather than present-and-refusing, for the same reason. Sessions already open are drained (`console_plane_disabled`) — without that, an operator sees 404 while root shells keep running behind it. **Recording playback stays outside the switch** (it never consulted the Hub; now that is a stated property): a recording is evidence, and turning consoles off must not take last week's sessions from an auditor. The key is also served from the SettingsStore overlay — the admission, and its cost, are recorded in [ADR-0073](0073-keeper-runtime-config-pg.md). **NOT closed by it:** the Errand path (`errand_shell_gate`, NIM-197) is untouched, so "no console plane here" is not "no root shells here"; and the host-side `soul.yml` flag still gates interactive opens only, so a host carrying `enabled: false` continues to execute `keeper.soul.run-command`. **Amends [ADR-033](0033-errand.md)**, extends [ADR-012(c)](0012-keeper-soul-grpc.md). | | [0075](0075-intra-host-async-tasks.md) | Intra-host task concurrency — `async:` tasks and named barriers | active | Two independent tasks on one host still run one after the other: the apply cycle is a strict loop over `ApplyRequest.tasks[]`, and requisite gating, fail-stop and register accumulation are all written against that order. `parallel:` **looked** like the answer — a declared key, fully specified in [destiny/tasks.md §6](../destiny/tasks.md), parsed into `Task.Parallel` — but nothing ever executed it: both render guards reject it (`ErrUnsupportedDSL`), the validator rejects it on a `block:`, and `RenderedTask` has no field for it, so it could not reach a Soul at all. That made this the last cheap moment to choose between two incompatible models, since the key has **no consumer anywhere in `examples/`** and no run can carry it. **Chosen: fire-and-forget, spelled `async: true`.** The task starts, the flow moves on; it is a flag on an ordinary task and **not** a grouping mechanism (adjacent `async:` neighbours do not "execute together"). **The name states the semantics** — what is introduced is asynchrony, and `parallel:` is **reserved, as an unrecognized key**, for a genuinely different future construct (a concurrent group with a join, a group-scoped outcome and error boundary); spending the better word on the weaker meaning would force a rename later with consumers in the field. **Three barriers, and the explicit one is primary:** `require: []` / `require: all` (statically readable — the plan says what waits for what), an implicit reference to `register.`, and the final barrier at the end of the run, which is mandatory. **A failure is observed where it is awaited, and siblings are never cancelled**: a failed async task writes `failed` to its register without breaking the flow, the flow learns at the barrier it reaches for, and ordinary fail-stop engages there; in-flight tasks run to completion, because a cancel policy makes the executed set depend on scheduling timing and leaves half-applied state. **★ Two architectural boundaries are stated in the spec rather than discovered at run time, and both correct §6 as written:** a register reference is a **local** wait only from `when`/`changed_when`/`failed_when`/`until`/`onchanges`/`onfail`/`require` (Soul-side, one `ApplyRequest`) and a **full** plan-wide barrier from `where`/`vars`/`params`/`apply.input`/`output`/`loop.items`/`loop.when` (Keeper-side, a Passage boundary per [ADR-056](0056-staged-render-passage.md)) — §6's own worked example sat in the second class; and **an async task never outlives its Passage**, so "background until the end of the destiny" is true only on an unstratified plan. **No concurrency limit in the DSL** — real fan-out is small and a per-task field invites tuning a number the author cannot evaluate; the ceiling lives where the knowledge is, host-side `async.max_concurrent` in `soul.yml` (unset = unlimited, over the ceiling a task waits for a slot and is never dropped), mirroring the console's `max_sessions` ([ADR-0074](0074-interactive-console-pty.md)). **★ Resource safety is the author's, stated concretely rather than generically**, because the most obvious use is the one that does not pay off: `core.pkg.installed` takes a single `name`, and apt is invoked with `-o DPkg::Lock::Timeout=300`, so concurrent installs **queue on the dpkg lock** (original duration plus overhead, then failures past the timeout) while `dnf`/`yum`/`apk` surface contention as errors — the win is on independent paths (`core.file.rendered`, `core.url.fetched`, read-only probes). A declared per-module concurrency class is deferred: the closed `side_effects` enum exists only in the **plugin** manifest and the 18 static core modules have none. **Wire — only-add fields 18-20 on `RenderedTask`** (`async`, `require_idx`, `require_all`), with `require:` names resolved into indices Keeper-side (Variant A, as `onchanges_idx`/`onfail_idx`/`aggregate_of`), closing the gap where `require:` was parsed and reference-checked but reached no runtime. **Determinism is preserved where observable:** an async task's own gating is evaluated in the main flow at its plan position, so *whether* a task runs never depends on timing — only the apply log gives up plan order (events carry `plan_index`; the UI orders by it). Rejected: the join block as the R5 construct (reserved, not dropped); keeping the `parallel:` spelling for asynchrony; implicit grouping of adjacent neighbours; cancel-on-first-failure by default; a per-task DSL limit. Impl — NIM-149..152. **Amends [ADR-009](0009-scenario-dsl.md).** | | [0076](0076-engine-compat-window.md) | Engine compatibility — a declared keeper version window per entity | active | An explicit **service↔engine compatibility contract**, replacing symptom-driven discovery of a version mismatch (an opaque `render: DSL construct outside the pilot scope`, and — the dangerous mode — an old Soul **silently ignoring** a new parameter of a known module). **Primary layer — an author-declared window `compat: keeper: {min, max}` on `service.yml` AND on every `destiny.yml`** (per-entity because a destiny is a separate git artifact at its own ref, [ADR-007](0007-versioning-git-ref.md)); the effective requirement is the **intersection — narrowest wins** (max-of-mins / min-of-maxes), an empty intersection is an authoring error, a missing block is unbounded (backcompat). **Grammar** half-open `[min, max)`, plain `MAJOR.MINOR.PATCH`, no range operators — a free-form range string was rejected because arbitrary constraints are **not closed under intersection** and the effective window must be displayable. **Comparison rules:** a keeper version is normalized (leading `v`, the git-describe distance `--g`, and `-dirty` are build metadata) and compared by its **release core**, so a pre-release is enforced as its release (otherwise the whole `v0.1.0-beta.1` public beta would fail every window) and a `make build` past a tag is enforced as the release it stands on. The check is skipped **only when there is no version to compare** — the un-injected `0.0.0-dev` sentinel or a bare commit hash — and then **loudly**: warn + `not enforced` in the API, never a silent pass (`0.0.0-dev` is valid semver sorting below every `min`, so treating it as real would break plain `go build`). **Enforcement belongs to the rendering keeper instance** (an HA cluster runs mixed versions mid-upgrade, [ADR-002](0002-transport-grpc-ha.md)) — fail-closed `keeper_version_unsupported`, symmetric to the existing `soul_passage_unsupported`; registration is only an early check. **Secondary layer — a cross-check, not a replacement:** soul capabilities (generalizing [ADR-056](0056-staged-render-passage.md) §S5, closes the silent-ignore per-host) + `introduced_in` (keeper computes the floor a plan actually needs). **Inference can produce a floor, never a ceiling** — which is exactly why the declaration is primary; a declared floor below the inferred one is a lint/registration error, **not** a run-time block. **The rendered fact is stamped into state** (`apply_runs` += keeper/soul version, the effective window on incarnation/`state_history`), never into the definition. The bootstrap paradox is accepted and bounded: a pre-`compat` keeper rejects the key as a generic `unknown_key` — fail-closed but unversioned, and the strict manifest walker is deliberately **not** relaxed to buy a better message. Impl — NIM-159..163. **Amended 2026-07-26 (NIM-163) — param-level strictness + the deprecation policy:** the Soul checks a task's params against the manifest **compiled into that binary** before Apply (and before `Plan`, so a dry_run cannot report a false clean) — an undeclared key fails with `module.unknown_param` and the module never runs, closing the last silent-ignore mode one level below the capability gate. Transport keys are exempt on the state that owns them (`core.file.rendered` gets `template_content`/`render_context` in place of the author's `template:`/`vars:`, so a manifest-only check would reject every rendered-file task); a module with no embedded manifest is unchecked. Core was enforced while custom modules stayed advisory — measured, not cautious: shipped plugin manifests under-declare (the reference `community.redis` omits its TLS params on 4 of 11 states while real scenarios pass them — 10 keys, 16 files), and an opt-in flag would itself break older agents because plugin manifests are `yaml.Strict()`-parsed. **Deprecation policy** (the "how many generations back" answer): a param is never removed outright but marked `deprecated: {since, removed_in, use?}` with `removed_in` **exclusive**, honored for **at least 2 minor releases** (`X.Y.0` → `X.(Y+2).0` — the same interval as the declarable window), surfaced to the author as a `deprecated_param` **warning** and to the operator as a Soul log line; at `removed_in` the identical task text becomes `unknown_param`. Adding a param is a Soul-side compat event under the existing **souls-first** rollout. **Amended 2026-07-28 (NIM-204) — the manifest is the contract in both homes:** custom modules are enforced on the same terms as core, so `ParamStrictness` collapses to "declared" vs "no manifest" and there is no third mode where a declaration exists and means nothing. The opt-in "enforce my input" key is rejected outright — a decode error makes `DiscoverSlot` skip the **whole slot**, so such a manifest would **delete the module** on an older Soul (`module.not_found`) rather than merely fail to gate; and even with a lenient parser the key never converges (set by the authors who already declare completely), legitimizes a lying manifest, and is per-plugin while the risk is per-estate. A version-scheduled flip ("enforced from X.Y.0") is rejected too: the transition happens **once** in the product's life — afterwards a contract shrinks only via `deprecated:` (r) — and a self-scheduling mechanism for a one-shot event is a permanent cost for a single use. The break is affordable exactly now because the only plugin manifests in existence are the ones this project ships (blast radius = **NIM-206**, the under-declared `community.redis`); that licence is spent at the first public SDK release. Rollout stays **manifests → souls → keeper**, and the rejection names the artifact to upgrade (soul binary for `core.*`, the plugin itself otherwise). Still deferred: a static, author-facing check of plugin params, which needs the linter to resolve a service's plugin manifests. **Amended 2026-07-28 (NIM-205, NIM-237) — the deprecation has to reach the two people who can act on it:** the window is only worth its surfaces, and both were thinner than claimed. The operator's was "a soul-side log line" — the `slog` of one agent, reachable only by tailing a specific host as the task runs, carried into no event stream, no stored run, no audit, no UI; so `removed_in` arrived exactly as abruptly as with no window at all, and for a **plugin** module that invisible line was the ONLY notice, since the static check still returns early on every namespace but `core`. The notice now rides the run: **`TaskNotice`** (`{code, module, param, message}`) on `TaskEvent.notices`, collected before Apply **and** before Plan and sent whatever the outcome — a deprecation is equally true on a task that failed for another reason. It **must** originate on the Soul (the contract is the manifest compiled into that binary), and keeper lands it in three surfaces it already serves: the `task.executed` audit payload, the SSE frame, and `apply_runs.notices` (migration 107), read back per host by the run-detail endpoint and deduplicated by `(code, module, param)` — one param used by twenty tasks is one thing to migrate. Two deliberate asymmetries with `error`: notices are **not** suppressed by the then-existing `no_log` (rendered from the manifest, never from a value — suppressing them would blind the operator exactly on secret-handling tasks; `no_log` itself was removed by [ADR-0083](0083-declared-secret-state-fields.md) §8) and the SSE frame **does** carry their sentence (no stderr in it, and `deprecated_param` alone sends the operator hunting for which param and by when). The **author's** surface gained the other half: `GET /v1/modules` now publishes `deprecated` beside `introduced_in`, as an object `{since, removed_in, use}` rather than prose — that is what an author reads *before* writing the task, where the lint warning only arrives after. A fleet-wide "who still passes this param" rollup is deliberately **not** built, and must not be sourced from run history if it ever is: an aggregate over stored runs answers "who passed it in the runs we observed", missing an incarnation nobody ran this month and still accusing one fixed yesterday — the honest source is the definitions. Module- and state-level `deprecated` remain undeclarable (NIM-244). **Amends [ADR-056](0056-staged-render-passage.md).** | | [0077](0077-docs-sourcing-drift-policy.md) | Docs sourcing and drift policy | active | One source of truth per fact — machine-checkable facts are generated from code, not hand-written; design prose lives next to the code and changes in the same PR; drift is a build failure (idempotent regen + `check-doc-links` + linted `examples/` + a release gate); repo `docs/` = design/ADRs (English, for contributors), the docs site = user docs (single home, generated, i18n); translations only via the site's overlay, with the top-level README as the one in-repo exception (`docs/i18n/`). | | [0078](0078-rbac-derived-roles.md) | Derived roles — `parent_role`, attenuation and cascade | active | A role may name another in **`rbac_roles.parent_role`** (self-FK, migration 102); the named role is its ceiling. **Storage = reference + delta**, and the delta is the role's existing `default_scope` ([ADR-047](0047-purview.md)) reinterpreted: absolute on a plain role, an **attenuating delta** on a derived one, so one formula covers both — `effective_scope(r) = effective_scope(parent) AND default_scope(r)` (a plain role's parent side is the unrestricted top). Since the scope grammar has no `NOT`, conjunction only narrows → **attenuation of scope is structural**, not a rule to remember. **Variant B** — a child may also hold **fewer** permissions: `effective_perms(r) = own_perms(r) ∩ effective_perms(parent)` via the existing `subset.go` containment. The intersection is the security: a child's rows are NOT an implicit copy of the parent's (that would be a **widening** cascade on every parent addition); removal from the parent cascades **down** at the next snapshot build, so "a child never exceeds its parent" holds at the **decision** layer, not just at write time. **Flattened once at snapshot build** (`NewEnforcerFromSnapshot`), not walked per request — cascade rides the existing `rbac:invalidate` rebuild ([ADR-028(d)](0028-rbac-storage.md)). **The parent is ONE named role** (not the union of the caller's roles — wider than any single one; not multi-parent). **Graph guards in the DB** (migration 102: CHECK + self-FK + the `rbac_roles_parent_chain_guard` trigger, so every write path is covered): no self-parent, no cycles, chain ≤ **4 roles** (mirrors the ADR-047 scope-nesting cap), re-parenting checked from both ends (ancestors above + subtree below); re-checked in Go at snapshot build → a broken graph builds **no** enforcer (the existing `Holder` degradation, same code path as an unparseable permission: TTL refresh keeps the previous enforcer + warns, startup refuses to come up). **Deleting a parent is REFUSED** — `ON DELETE RESTRICT` / `ErrRoleHasChildren`: clearing the parent would turn the child's delta into an absolute scope and drop the parent's narrowing (a **widening** = escalation, the reasoning of [migration 100](../../keeper/migrations/100_rbac_drop_pattern_selectors.up.sql)), re-rooting to the grandparent widens by definition, cascading strips membership behind the self-lockout check. **The write-time floor stays**: `child ⊆ parent` **AND** caller-holds-parent (`subset.go` unchanged). **J1 (NIM-179) is inert at the decision layer** — `parent_role` is stored and carried into the snapshot/catalog, but nothing reads it on a permission check yet (an unresolved parent is narrower, never wider); NIM-180 adds flattening + the extended subset check + excluding derived roles from the self-lockout count, NIM-181 the API, NIM-182 the web. Rejected: a materialized `effective_scope` column (a stale scope is a wrong authorization decision; kept in reserve), multiple parents, implicit permission copying, a separate `derived_role` entity, deriving from a [Synod](0049-synod.md) (no scope of its own to attenuate against). **Amends [ADR-028](0028-rbac-storage.md) / [ADR-047](0047-purview.md) / [ADR-049](0049-synod.md).** | | [0079](0079-incarnation-name-template.md) | Composed incarnation id — `id_template` in the create scenario | amended | A create scenario may declare a top-level **`name_template`** — a `${ … }` template over its own `input:` — and the keeper composes the incarnation name from the RESOLVED input **before the insert**, on the shared [`ResolveCreatePlan`](../../keeper/internal/scenario/create_scenarios.go) path both REST and MCP already take (no new run phase, no DB migration: the name stays a `TEXT PRIMARY KEY`). Cheap because the name is no longer a targeting label — [ADR-008](0008-coven-stable-tags.md) made it the root Coven tag and NIM-124 superseded that, so composition ripples nowhere near `on:`/covens. **Sandbox is input-only** — the same narrow cel-go env as `required_when`/`validate:`, so `essence`/`soulprint`/`register`/`vault()` are undeclared-reference compile errors and a name is a pure function of the request. Unlike [ADR-010 §5(a)](0010-templating.md) a single block does NOT yield a native type: a name is a string, every block is stringified, a list/map is an error. **`name` becomes optional AND mutually exclusive with a template** — sending it against a composing scenario is 422 `name_not_composable`, never silently ignored (ignoring it would let the RBAC `incarnation=` dimension be checked on one name while another is inserted) and never an override (that would defeat the convention). **Overflow refuses, never truncates:** four components plus literals pass the 63-char ceiling easily → 422 `composed_name_invalid` quoting the string and its length, because a truncated name is a DIFFERENT immutable identity. **Name components are write-once identity** — composition runs only on create, a later run with a different `project` renames nothing. **soul-lint** catches the class statically: `name_template_input_unknown` (ERROR, modelled on `form_field_unknown`), `name_template_invalid` (outside the sandbox / index form `input['x']`), `name_template_too_long` (literal skeleton alone over 63), plus WARNINGs `name_template_constant` and `name_template_ignored`; under `extends:` the reference check is gated post-merge exactly like `form:`. **Known limitation:** a *scoped* operator cannot create a templated incarnation on either surface — a nameless request yields an empty RBAC context set, which admits only bare/`*`; fail-closed, and scoping a create whose name is unknown until the snapshot resolves is left to its own decision (RESOLVED by the 2026-07-30 amendment, NIM-333). **Live preview — Amendment 2026-07-30 (NIM-331):** `POST /v1/incarnations/resolve-name` answers "what name would this input compose, is it legal, is it free" WITHOUT creating anything, through the SAME `scenario.ComposeName` the create runs — client-side CEL is forbidden, since a second evaluator would compose a different string from the same input and hand the operator a different identity than the one they approved. It merges defaults without the required phase (a preview runs on half-typed input) and so rejects *less*, never *differently*. Occupancy is answered here rather than probed via `GET /v1/incarnations/{id}` (which would be an existence oracle), scope-aware in two grains — "taken" always, "taken by service X" only to a caller who may see it — and the same rule now phrases the create's 409. Scenario listing exposes a BOOLEAN `composes_name` next to `input_schema` so the form knows which mode to open in — a flag, not the template text (NIM-340: the operator is shown the resulting name, not the formula, and a client holding the expression is one step from evaluating it). Fully opt-in — a service without the key behaves bit-for-bit as before. Impl — NIM-177 (RBAC: NIM-333/NIM-338; preview + web form: NIM-331). **Renamed by [ADR-0085](0085-entity-id-and-label.md) — Amendment 2026-09-04 (NIM-730):** the key is `id_template:`, the CEL root `incarnation.id`, the flag `composes_id`, the codes `id_not_composable` / `composed_id_invalid`, the endpoint `POST /v1/incarnations/resolve-id` with `composed_id`, and the five soul-lint rules `id_template_*`. Spelling only — sandbox, ordering, anti-truncation and write-once identity are untouched. The two spellings a SERVICE REPOSITORY writes (the key and the CEL root) are both read for a compatibility window and warned on with a line and a replacement (`id_template_legacy_spelling`, `incarnation_name_legacy_root`); declaring both spellings of the key is `id_template_conflict`. The API rename has no window, as with the other ten registries. **Amends [ADR-009](0009-scenario-dsl.md) / [ADR-010](0010-templating.md).** | | [0080](0080-label-inheritance-union.md) | Coven and Trait — one label world: inheritance by membership, union at read | superseded | ⛔ **REVERTED IN FULL 2026-08-05 ([NIM-281](0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited)) — inheritance does not exist.** A label lives ONLY where an operator attached it, and it stays there: a host's covens are `souls.coven[]` and its traits are `souls.traits`, an incarnation's are `incarnation.covens` / `incarnation.traits`, and **neither level reaches the other** — not by copy (ADR-060 R1, already removed) and not by read-time union (this ADR). Belonging to an incarnation attaches nothing; reaching its members is a MEMBERSHIP question, spelled `incarnation=` and answered from `incarnation_membership` alone. `soul.EffectiveCovens` / `LoadInheritedLabels` and the correlated `EXISTS` in the RBAC predicate are gone; the incarnation-side predicate also loses its `name = ANY($x)` arm, so a coven scope matches an incarnation by `covens &&` only — a name is an identity, not a label. Every reader reverted together (RBAC pushdown, souls list filter, bulk selector + gate (a), `soulprint.self.covens`/`.traits`, topology roster, push routing Level 2, Oracle/Vigil subject, Augur Rite subject, trial fixtures). **NOT restored:** the ADR-060 R1 materialized projection — `SyncTraitsToHosts` stays deleted and migration 106 stays applied. **What survives untouched:** the service-vars coven overlay, whose axis is `incarnation.covens` selecting layers of the incarnation's OWN config ([ADR-0082](0082-service-vars.md)) and which never read a host label. **Known narrowing — CLOSED the same day by [NIM-280](0008-coven-stable-tags.md#amendment-2026-08-05-nim-280-a-rules-subject-reads-both-levels--targeting-only), and closed WITHOUT reviving this ADR:** a rule's subject grew an `incarnation` dimension, and its label dimensions read both levels **inside one selector match only** — nothing written to `souls`, no other reader affected, the RBAC scope predicate untouched. A union in one consumer that asked for it is not a label the host carries. Kept below as the record of what was tried and why it was undone; do not implement from it. **Reverts its amendments to [ADR-008](0008-coven-stable-tags.md) / [ADR-060](0060-traits.md) / [ADR-047](0047-purview.md) / [ADR-030](0030-vigil-oracle.md) / [ADR-025](0025-augur.md) / [ADR-072](0072-host-utilization.md) / [ADR-032](0032-push-orchestrator.md).** | | [0081](0081-roster-at-create.md) | Roster at create — a scenario declares which input carries its hosts | active | A create scenario that rolls onto ready hosts (`create_from_souls`) had no way to be GIVEN them: `POST /v1/incarnations` took no hosts, membership was bound afterwards ([ADR-008](0008-coven-stable-tags.md) amendment / NIM-209), and the bootstrap run resolves its roster from `incarnation_membership` at start — so it aborted `no_hosts` before its first task, and a scenario cannot bind its own roster. **Decision:** a third variant of the [ADR-044](0044-choir.md) S-T1 source discriminator, `source: { roster: true }`, declared on an `input:` field. It does double duty: for the UI it is the SID catalog (free onboarded souls the caller may see — the incarnation-scoped variants have no incarnation to resolve against on a create form), and for Keeper it is the statement that this input IS the composition, bound into `incarnation_membership` after the insert and **BEFORE** `runner.Start`. Which field carries it is the author's choice (`config.RosterInputField`); at most one per block (`input_roster_source_duplicate`). **The create contract does not change** — the roster travels inside `input`, so `required`/`min_items`/`format: sid` and a `validate:` size rule against the declared topology all come from the ordinary input gate, turning a render-time `error_locked` into a request-path 422. **Order is the decision:** screen BEFORE the insert (a refusal leaves no half-made incarnation), bind after it (FK), run last; a bind that fails infrastructurally answers 500 with NO run, leaving an empty ready incarnation the operator repairs with `POST .../members`. **Two gates, the bind route's own:** `incarnation.bind-member` ANDed over declared covens (else create is the way around NIM-209's gate (a)) + every SID inside the caller's `soul.list` purview, all-or-nothing, buckets ordered unknown→422 / out-of-scope→**403** / not-connected→422; one screening function shared by REST and MCP. **`input.hosts` is a JOURNAL, membership is the truth** — write-once record of what the incarnation was created on, diverging legitimately once the Hosts tab edits the roster; later runs read the relation. NOT written to `spec.hosts` (that declares ROLES, ADR-008 — it would read plausible and bind nothing). **Catalog** — `GET /v1/souls` (reused rather than extending form-prep: that endpoint is addressed per module, and the souls list is ALREADY `soul.list`-scoped, so "the picker cannot show a SID the caller could not otherwise see" holds by construction — the failure class of NIM-148 / NIM-202/203 with no second copy of the rule to keep in step), gaining `sid_prefix` (literal, LIKE metacharacters escaped) and a repeatable `coven` (ANY-of, which its own docs already promised). ★ **The picker narrows by `status=connected` and NOTHING else** — that alone is an invariant (the keeper binds no other status). Two tighter filters were built first and both removed after the first live review: **by the declared covens** (binding a host attaches no label at all per [NIM-281](0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited), so the filter would hide every candidate nobody had hand-tagged — on a fresh fleet, all of them) and **by "unassigned"** (membership is M:N per NIM-124, so serving one incarnation is no reason to hide a host from another; on six hosts with three in use the picker showed three, with nothing on screen to explain it). What keeps other people's hosts out is the RBAC scope, not a heuristic about occupancy; `unassigned` stays on the endpoint as a registry question nobody's form asks. Rejected: a `hosts[]` body field (core contract for every service, when the scenario is what knows), writing `spec.hosts`, a scenario binding itself (`no_hosts`), a separate `requires_roster` flag (the field's presence IS the flag — and it retired the web form's guess by scenario NAME), operator-assigned master/replica roles (`cluster_topology` covers it; otherwise the plugin lays roles out by sorted SID). Impl — NIM-371. **Amends [ADR-008](0008-coven-stable-tags.md) / [ADR-044](0044-choir.md) / [ADR-045](0045-param-dsl.md).** | | [0082](0082-service-vars.md) | Service vars replace Essence — one `vars` namespace, `incarnation.spec.essence` removed | active | A service repo's default parameters move from `essence/` into **`vars/`**, the CEL root `essence.*` merges into **`vars.*`**, the package `keeper/internal/essence` becomes `servicevars`, and **`incarnation.spec.essence` is removed with no replacement**. The two namespaces were separated by exactly one property — essence was overridable from outside, a `vars` local by definition is not ([`destiny/vars.md`](../destiny/vars.md)) — and the override is what goes: `spec.essence` had two live readers (`scenario/state.go`, `grpc/events_telemetry.go`, both via `specEssence()`) and **no writer at all** (no field in `IncarnationCreateRequest`; the only write of the key anywhere is the fixture of the unit test that reads it back), while being the last key blocking the `incarnation.spec` drop (NIM-408). **A fleet overrides a service's defaults by FORKING the service repo** and re-pinning its `ServiceRef` ([ADR-007](0007-versioning-git-ref.md)) — the ansible-role model, zero new machinery; the redis keys commented "the operator overrides this in spec.essence" (eighteen comments over a couple of dozen keys: fleet-wide facts pinned to one instance's row, plus values deliberately kept out of the Run form) do not move at all, only the way to override them does. Rejected: a replacement column `incarnation.essence_override`, `keeper_settings` ([ADR-0073](0073-keeper-runtime-config-pg.md)) for the fleet group (needs a `settings.*` CEL root — adding an entity is the opposite of the point; still available later, on top, unchanged), and relocating the per-incarnation keys into `input:` behind a collapsed `form:` — that one would park **desired** constants in `state`, which is a projection of **actual**. **The ladder becomes one flat stack:** `/vars/*.yaml` → destiny `vars.yml` → `block:` → task, with no operator rung (and no scenario file rung — `scenario//vars.yml` is documented but read by nothing); a layer may reference the layers BELOW it, since a task var used to reach the service layer as `${ essence.X }` and would otherwise fail on the same expression spelled `${ vars.X }`, while sideways stays refused; the merge's one cost is silent shadowing (a task var over a service var), to be covered by a soul-lint WARNING `vars_shadows_service_var` (NIM-416, not built yet) on the model of the existing `vars_collision`. It also pays for itself — the transit hops `essence.conf_dir → compute.conf_dir → task vars.conf_dir` that existed only to cross the namespace boundary collapse (redis, dragonfly, mongo). **`essence/os/` and `essence/coven/` are deleted** (implemented, used by zero shipped examples and zero external destiny repos) and **`vars/_stack.yaml` becomes real** — documented as working in [`service/manifest.md`](../service/manifest.md) / [`architecture.md`](../architecture.md) while the code said `// Convention-based ordering (no _stack.yaml)`; conditionality gets ONE mechanism, not two, and the guard that proves an incarnation's own tag selects an overlay of its own parameters (NIM-248) is **retargeted onto a `foreach:` step, not deleted** — that axis is `incarnation.covens`, never a host label, so [NIM-281](0008-coven-stable-tags.md#amendment-2026-08-05-nim-281-a-label-is-never-inherited) leaves it standing. Step context is `incarnation.*` (covens included) / accumulated `vars.*` / the `foreach:` binding, and **nothing else** — the draft's `host` root is dropped (it duplicated the incarnation's labels on one axis and misreported the keeper context, which has no host, on the other), and `soulprint.self` is refused outright: a service's vars resolve ONCE per run and are handed to every host, so a step keyed on one host's facts would apply that host's answer to the whole roster (a mixed debian/rhel roster is the case). The layer is host-invariant BY CONSTRUCTION. Host-dependent behaviour belongs where the render already is per-host — `where:` on a task, a task's own `vars:`/`params:` over `soulprint.self.*`, a `.tmpl` — and NOT in `apply: input:`, which resolves on `targeted[0]` and hands one set of values to a destiny's whole roster. Per-host service vars were weighed and deferred: doing them honestly means refusing `vars.*` in `compute:` and `apply: input:` and rewriting the 141 sites that read it there, which is a train of its own; `os/.yaml` accordingly has no replacement — it was used by zero services and already answered with `hosts[0]`'s family for everyone. Corollary: the incarnation's covens must be read on EVERY resolving path (the runner's `FOR UPDATE` read and the telemetry query did not select the column and now do), or one `_stack.yaml` would resolve differently for a run than for the gate. New per-step **`strategy: deep\|replace`** — redis documents `install_package` as "the whole map that an override replaces" while `mergeInto` recurses, so overriding `repo_uri` alone kept the base's `gpg_key_url`: an intent the mechanism could not express. Without a `_stack.yaml`, order is every `*.yaml`/`*.yml` directly in `vars/` sorted lexically (subdirectories NOT walked → `vars_dir_nested`), and the base file is **`00-base.yaml`, not `_default.yaml`** — the rename is forced BY that scheme rather than fixing a defect in the old one (the old resolver read `_default.yaml` through a hard-coded constant and never sorted anything), because once the directory listing is the order the name carries the precedence, and `_` is `0x5F`, between `Z` and `a`: `00-base.yaml` < `Base.yaml` < `_default.yaml` < `base.yaml`. **Two Struct payloads crossing the wire lose the `essence` key**: `RenderedTask.flow_context` ([ADR-012](0012-keeper-soul-grpc.md)) becomes `{input, vars, incarnation, self}` and `core.file.rendered`'s `render_context` ([ADR-010](0010-templating.md), [`templating.md` §3.2](../templating.md)) becomes `{vars, self, role}` (plus its conditional `input`) — no proto field moves (only-add intact), but an older Soul finds nothing under `essence`; stated rather than left to a field report, and cheap in practice since **no `.tmpl` in `examples/` reads `.essence`**. The name asymmetry across `apply:` is documented head-on instead of removed (in a scenario `vars` = service vars + locals, in a destiny only its own `vars.yml`; isolation untouched) — the same asymmetry already cost `apply_when_dynamic_unsupported`, where different names merely camouflaged it. **Essence retires from the dictionary** ([naming-rules.md](../naming-rules.md) entries rewritten to forward here, not deleted). Impl — NIM-412…NIM-416, column drop in NIM-408. **Amends [ADR-008](0008-coven-stable-tags.md) / [ADR-009](0009-scenario-dsl.md) / [ADR-010](0010-templating.md) / [ADR-012](0012-keeper-soul-grpc.md).** | | [0083](0083-declared-secret-state-fields.md) | A secret is a declared state field — the author never writes a Vault path | amended | A service author declares a secret **where the data already lives** — as a field in `state_schema` with `type: secret` — and the platform derives its Vault path from `(service, incarnation, field, key)`. Every authoring channel into the service's own namespace is closed. The problem was not that ADR-070's `revealable_secrets` was wrong but that it was the **fourth** hand-written copy of one path: `wb-service-redis` spells `secret/{service}/{incarnation}/users/{key}#password` in `service.yml`, in prose in `types.yml`, and in 17 `${ vault(...) }` call sites across three scenarios, with a comment — not a mechanism — holding the four in agreement. **§1 The declaration.** A `state_schema` field carries `type: secret` (scalar) or, for a collection, `type: secret` on the property inside `items` next to the `key:` naming which sibling property is the collection's identity — and nothing about how a value is minted, which travels inside the `SecretRequest` at the call site (§3) so one policy is written in one place. Derivation is `secret////#` for a collection and `secret///#value` for a scalar. Every segment is validated against the [ADR-064](0064-secret-write-path.md) `secretwrite` grammar `^[a-zA-Z0-9_-]+$` and **fails closed** — `` is operator-influenced data, so a `/`, a `.` or a `..` inside a user's name must not become a path segment. **§2 `revealable_secrets` is deleted.** The reveal endpoints, the `incarnation.view-secrets` right and the `incarnation.secret_revealed` audit event are untouched — what goes is the author-written `vault_ref` that made them work, replaced by the same derivation. The `vault_ref_not_service_scoped` load-time diagnostic goes with it: the escalation class it fenced cannot be expressed any more. ADR-070's deferred singleton case (a secret that is not a collection member) is solved for free by the scalar form. **§3 `generate_secret()` returns a request, not a value.** A new CEL function taking a map argument (`generate_secret({"length": 32, "charset": "alphanumeric"})`) and returning an opaque **`SecretRequest`** marker — no plaintext exists at render time, so none can leak through a register, a log line or a diff. CEL has no keyword arguments and no `=` token at all, so `generate_secret(length=32)` would not lex; a map argument keeps the names at the call site. 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 seal detector deliberately does **not** key on it: a request holds no secret material, and sealing is whole-cell, so keying on it would mask a failed task's entire inventory out of its own diagnostics. **§4 `core.state.present` is the single write.** One keeper-side module that reads the state field, mints only the properties that are missing, writes them to their derived paths and returns the **effective** state in its register — *a generator's output is a candidate, a writer's output is the truth*. Present-semantics on secret properties is what makes a second run keep the first run's password instead of minting one the writer would discard while a consumer configured the target with it. A register also crosses the `include:` boundary **downwards and transitively** — expansion threads each level's declared `register:` names into the bodies it splices in — so a `_common/` partial can quote the writer; sibling branches stay blind to each other, and the reverse direction (a main file naming a register declared inside an included body) stays a per-file error, because a conditional include is dropped as a whole group at render. **§5 The keeper register is unioned into every per-host bucket** rather than serving as the empty-bucket fallback [ADR-056](0056-staged-render-passage.md) deliberately withheld — a Soul-side task must be able to read what the keeper-side writer just returned. Duplicate register names are already a load error ([`shared/config/task_refs.go`](../../shared/config/task_refs.go)), so the union is unambiguous by construction; `incarnation.state` stays the frozen pre-run snapshot it is. **§6 A secret in a register rides as a reference**, not as plaintext — which closes the plaintext window through `apply_task_register` for free rather than by adding a purge. **§7 The fence is on the namespace, not the spelling:** an author-written path under `//` is rejected in all four spellings — `${ vault(...) }`, a `vault:` ref in `params:`, and the `path:` of `core.vault.kv-read` / `core.vault.kv-present`. All four survive **outside** that prefix, where the cross-namespace read class (a shared TLS CA, another service's credential) is real and has no replacement yet; `external_secrets` would make the fence a deletion and is explicitly not on this ticket. **§8 Per-field `secret: true` on module output** retires **`no_log`**, which is all-or-nothing and set by the task author rather than by the module that knows its own output shape — simultaneously too coarse (suppressing a whole task's diagnostics to hide one field) and unreliable. Removed, not deprecated. The four channels it covered are answered separately, and two of them honestly narrower: `params:` by the per-cell seal, `output:`/`register_data` by per-field masking of the observable copy (the live register keeps the value), `state_changes` by §1 + §6 instead of the source-side register drop that also broke the register chain, and **`error.message` not at all per task** — what stands there is the write-path vault-ref masking plus the SSE floor that withholds it for every failed task. **§9 The policy grammar is the existing one** — `length` (8..1024, default 32) / `charset` (`alphanumeric`|`hex`|`base64url`|`ascii-printable-safe`) / `allowed_chars`, lifted out of `keeper/internal/coremod/vault/policy.go` into shared code so both callers parse one definition. Rejected: a generator module owning the store (`core.secret.generated` with a `field:` parameter — a second state manager beside `incarnation.state`, with the storage path invisible to the author); `generate_secret()` returning plaintext; per-passage state refresh; a new `secret.*` CEL root; keyword arguments; character-class composition rules (NIST has recommended against them for years); entropy-based sizing (rejected once already, reason recorded in `policy.go`); and deleting `vault()` outright. **Breaking, deliberately** — derived paths do not match what existing incarnations use, `revealable_secrets` stops parsing and `no_log` is removed, with no migration: this lands before the release. Impl — NIM-698; rotation and explicit state capture (NIM-699) are separate tickets. **Amends [ADR-070](0070-secret-reveal-path.md) / [ADR-064](0064-secret-write-path.md) / [ADR-009](0009-scenario-dsl.md) / [ADR-010](0010-templating.md) / [ADR-012](0012-keeper-soul-grpc.md) / [ADR-017](0017-keeper-side-core.md) / [ADR-056](0056-staged-render-passage.md).** **Amendment 2026-08-25 (NIM-699, [ADR-0084](0084-explicit-state-capture.md)): the address becomes `core.state.set`, the secret rule stops being the verb's, and the write lands at the step.** The two params are renamed `key:` → `field:` and `set:` → `value:` (with the register echo key), because both collide with the [ADR-057](0057-state-changes-crud-verbs.md) verb grammar ADR-0084 ports onto this module — there `key:` addresses an element **inside** a collection while 698 spelled the containing field the same way. `present` is re-pointed to what it means everywhere else (write only if the field has no value yet) and the declared-secret rule moves **off** the verb onto the property: every verb keeps an existing Vault value and mints only what is missing, `set` included, so `core.state.set` overwrites ordinary content and still does not rotate a credential. §4's end-of-run commit through `state_changes:` is replaced by a capture at the step, which also **reverses two lines of this ADR**: §5's *"`incarnation.state` stays the frozen pre-run snapshot it is"* and the rejection of per-passage state refresh. Both were right for a single end-of-run commit and one verb — nothing could observe a write, so freezing cost nothing — and wrong once the verb grammar is carried: `add` with `on_conflict: skip` inserts twice against a frozen snapshot, `expect` asserts against a state that no longer exists, and a `modify` after an `add` patches nothing. State now accumulates within a run. Breaking and deliberately unversioned; the `examples/` adopters migrate in the same commit. **Amendment 2026-08-26 (NIM-706): §7's fence has a second axis — the NAME that opens the namespace.** §7 assumed `//` belongs to the service that names it; nothing enforced it. Three other path families fix their own first segment in the same mount — `/keeper/` ([ADR-014](0014-operator-identity.md)) and `/herald//` / `/provider//credentials` ([ADR-064](0064-secret-write-path.md)) — so a service named `herald` derives onto that family's own slots: one KV entry, two writers, and `secretwrite.WriteString` REPLACES where the mint merges, so the second write deletes the first one's fields with no error anywhere. A closed list of **reserved Vault namespaces** (`keeper`, `herald`, `provider`, `internal`) is refused as a service name at REST and MCP registration (**422, not 409** — nothing holds the name), and last by the derivation itself (`SecretField.VaultPath` errors rather than emitting a colliding path); reveal denies before reading Vault (`reason=floor_denied`) and keeps a second, path-shaped half for a MOUNT that spells a reserved word (`vault.kv_mount: keeper`), which a name comparison structurally cannot see. Deliberately **narrower** than the [ADR-020](0020-plugin-infrastructure.md) alias list — an alias that collides shadows an address, a service name that collides destroys a secret. **A second axis, inside the namespace:** `certissue` issues `///tls/{cert,key}`, which a collection secret on state field `tls` with element key `cert` reproduces exactly; the element key is state DATA, so the fence goes on the state field name (`secret_field_reserved_state_name`, list = `tls`), and only on a field that declares a secret. `certissue.VaultPath` also spelled its mount as the literal `"secret"` and now takes it from `keeper.yml`; the same defect class removed `config.VaultInputFloor` (literal prefixes spelling the default mount, naming neither `herald` nor `provider`) in favour of the mount-agnostic `config.PathUnderReservedNamespace`. Breaking: a service already registered under one of the four names stops loading and must be renamed; `examples/` names none of them. Impl — NIM-706. **Amendment 2026-09-01 (NIM-741, [One schema dialect](0086-one-schema-dialect.md)): the declaration is spelled in the input dialect, and one refusal has to be re-hung.** §1's substance is untouched; four things change. **(a)** The `required:` refusal survives on its own ground (a secret lives in Vault, so no state instance can satisfy it) but its enforcement reads the LIST — `shared/config/secret_field.go:365-373` iterates `stringSeq(items["required"])` and after the change iterates nothing, so `secret_field_required` silently stops firing; ⚠ the naive repair fires the **wrong code** — `checkSecretNodeGrammar` (`:383-399`) rejects any key outside `{type,key,label}` (`:93`) → `secret_field_unknown_key`, true about the grammar and useless about the mistake — so `required` must be **special-cased ahead of** the grammar check. **(b)** The node grammar stays `type`/`key`/`label`: the newly-available shared input keys (`default`, `enum`, `pattern`, `min_length`/`max_length`, `secret`, `prefill_from_state`, `required_when`) are refused as unenforceable, and ★ **`description` is refused for a different reason** — `label` already carries the caption, and two keys for one caption is the drift this ADR exists to remove. **(c)** "Exactly two legal positions" stays decidable **only at the point of use** — with `type: secret` legal in `types.yml` ([ADR-062](0062-input-types.md)), one catalog entry is legal under an array's `items` and refused one object deeper (`secret_field_unsupported_location`, `:265-274`), so a standalone verdict would have to ban a legal use or bless an illegal one; hence the secret rules are deliberately **absent** from `soul-lint`'s `types.yml` checks (`soul-lint/internal/validate/type_refs.go:6-77`, scoped to what needs the catalog). The NIM-706 reserved-name fence **survives** because `IsReservedStateField` is evaluated on the state FIELD name (`:283-292`), which is a `state_schema` key and does not move when `$type` expands. **(d)** The `` guard is untouched by design — `` is still state data, `ValidVaultPathSegment` (`:59-66`) and the fail-closed `SecretField.VaultPath` (`:153-189`) stay exactly where they are; a dialect is an authoring surface, this is a data-path check. Two engine hazards fail in opposite directions: `CollectSecretFields` reads the root `properties` (`:231`) so every declared secret returns as `secret_field_unsupported_location` (loud), while the masking walk returns an empty set (silent — see [ADR-010](0010-templating.md)); and `scanSecretNodes` must be called with `inProps: true` at the new root, or a state field named `default`/`const`/`enum`/`examples` drops out of the scan entirely (`:417-431`, `:453`). Design only — NIM-742/743/744. **Amendment 2026-09-02 (NIM-746): `present` over a populated field FAILS CLOSED — the verb is not what decides a mint.** The 2026-08-25 sentence *"every verb resolves a `type: secret` property the same way: an existing Vault value is kept, a missing one is minted"* is right in its first half and wrong in its second: nothing mints on the strength of a value being absent. A mint needs a `generate_secret({…})` marker in the value the step proposes, and the deciding pair is **the state of the field + the presence of the marker**, not the verb. `present` alone pre-reads state before any Vault work (`keeper/internal/coremod/state/state.go:207-216`) and, over a non-nil field, discards the proposal, resolves the STORED value and carries `noMint`. So an empty derived path has three outcomes and only one mints: field absent + marker → **mints** (`:684-689`); field absent, no marker → **fails closed** *"has no value yet and none was requested"* (`:682`); field populated, stored value re-resolved → **fails closed** *"is stored but was never minted in Vault"* (`:679-681`). The third is FORCED — `config.StripDeclaredSecrets` (`shared/config/secret_field.go:533-547`) deletes declared properties on the way into state, so a stored element structurally cannot carry a marker (`state.go:597-600`), and under `noMint` a stray one is refused (`:590-594`). Consequence: a schema change that re-points a derived secret path does **not** self-heal — day-2 breaks LOUDLY, create mints SILENTLY at the new path ([ADR-019](0019-state-migration-dsl.md) amendment 2026-09-01 §7, written from the code, which is the text that was right). ★ A fourth shape fails in neither direction: **an empty collection is a value**, so `present` yields to it and a collection with no elements offers no secret position to resolve — the shape of [`examples/service/redis/migrations/015_system_acl_users/main.yml`](../../examples/service/redis/migrations/015_system_acl_users/main.yml), which is the live example of a re-pointed path and is to be cited **for the path shape only** (its own comment claims that run fails closed; NIM-738 corrects it). **No code change** — the module is right, the sentence was imprecise; the same sentence is corrected in [ADR-0084](0084-explicit-state-capture.md), `docs/keeper/modules.md`, `docs/scenario/orchestration.md` and the `architecture.md` stub in the same commit. | | [0084](0084-explicit-state-capture.md) | Explicit state capture — `core.state.` replaces end-of-run `state_changes` | amended | A scenario's state is written **where the scenario says so**, by a keeper-side step, and the write lands **at that step** rather than at an end-of-run commit. The `state_changes:` block is retired: it was a second grammar for the thing tasks already do, evaluated in its own CEL context, invisible to `when:`/`register:`/`onfail:`, and committed after every host had finished — so a run that died half-way recorded nothing it had in fact done, and a task could not read what an earlier task in the same run had just written. **The address is the verb.** One base module `core.state` carries seven states — `set` / `present` / `add` / `append` / `modify` / `remove` / `unset` — the [ADR-057](0057-state-changes-crud-verbs.md) verb set, applied by the same engine (`keeper/internal/stateop`) the retired block used, so a verb cannot mean two different things depending on which path wrote it. A param the verb does not take (`patch:` on a `set`) is an authoring error rather than a silent drop. **Secret resolution is orthogonal to the verb** — the rule that made [ADR-0083](0083-declared-secret-state-fields.md) §4 name its module `present` moves onto the *property*: on `type: secret` every verb keeps an existing Vault value and mints only what is absent, so `core.state.set` overwrites the field's ordinary content and still does not rotate a live credential, while `present` now answers the separate question of whether the incoming value reaches the field at all. Deliberate rotation stays inexpressible; it gets its own decision. **State accumulates within a run** — each capture is committed under the run that produced it, so a run that fails half-way keeps what it had already captured, and a later task reads what an earlier one wrote. Rejected: keeping `state_changes:` beside the verbs (two grammars for one write, and the ordering question stays unanswered); a single `core.state.write` module taking the verb as a param (a verb is not data — the address is what a linter, a diff and a reader all key on); and compare-and-swap `expect:` on `set`, deferred because ADR-057's `expect` counts match cardinality and a whole-field `set` has no `match:`, making it a new mechanism rather than an extension. **A `when:` reading `register.*`/`soulprint.*` on a keeper-side task becomes an ERROR** (`when_on_keeper_dynamic_unsupported` offline, `ErrUnsupportedDSL` at render, one shared `IsStaticPredicate` behind both): `when:` is a Soul-side predicate and a keeper task never reaches a Soul, so the key was accepted and dropped — and the step that ran regardless of its condition is now the step that writes state. A static `when:` is untouched and stays the working form. **Amends [ADR-0083](0083-declared-secret-state-fields.md) §4 / [ADR-057](0057-state-changes-crud-verbs.md) / [ADR-009](0009-scenario-dsl.md) / [ADR-017](0017-keeper-side-core.md).** Impl — NIM-699. **Amendment 2026-08-26 (NIM-711): `register.hosts.` — the per-host route into state.** A capture is a keeper task, so it binds no soulprint, targets no host and reads only the keeper register bucket — which left the verbs with a hole the retired `state_changes:` block did not have: a value that exists only per host (a probed node id, a generated port, a member address) had no route into `incarnation.state` at all. One accessor closes it — `register.hosts.`, the map {SID → payload} for one register across the hosts that produced it, so ONE capture writes the whole per-host set in one expression. It is an accessor, not a task key: the dependency it declares is on ``, so the same `ExtractRegisterRefs` parser that feeds `render.Stratify` puts the capture in a **later Passage** than the probe (without that hop the ref reads as `hosts`, which nothing emits — a bogus `unknown_register_reference` and, worse, a missing Passage edge that collapses probe and capture into one Passage reading an empty map, with soul-lint exiting 0). The synthetic `keeper` bucket is excluded (it is not a host). Secret safety is inherited for [ADR-0083](0083-declared-secret-state-fields.md) §6 — the map is built from the same per-host buckets `hostRegister` reads, so a `type: secret` field still lands as a `vault:` reference and has no plaintext form here — but §8 had to be taught the shape: a module-declared `secret: true` output reaches `apply_task_register` in the clear by design, and the render-time seal that keeps it off `apply_run_plan.params`/`status_details` matched `.` only, so the two-hop `register.hosts.` slipped past it (its inner field is `hosts`, a name reserved at parse and therefore never sealed) — `selectBaseField` now flattens the hops, and since a keeper task could not read a host register at all before this amendment, that was the one route by which a sealed register could have reached an unmasked audit surface. **Outside an `on: keeper` task it is a compile error, not an empty map** (host task, destiny pass, `when:`/`changed_when:`/`until:`, migration): a host task's `register.` is deliberately its OWN value (§5), and an empty map would make `.size() == 0` and an empty `foreach` read as facts. The gate is a flag separate from `soulprint.hosts`'s `AllowHosts` — that one is TRUE for host tasks in the scenario pass, exactly where this must stay closed — fail-closed by zero value and part of the compile-cache key. `register: hosts` on a task is refused at parse (**`register_name_reserved`**): such a register is unreadable from either side — the accessor sits at a fixed field of the `register` root and wins there, and on a host task the syntactic cut-off refuses `register.hosts` whether or not the register exists. NOT added: per-host values into *different* state fields (needs a new task key AND a new CEL root, for a case with no user); rejected alternatives — `soulprint.hosts.registers(...)` (a register is volatile, the soulprint root is stable-only by [ADR-018](0018-soulprint-typed.md)) and dispatching `core.state` to hosts (impossible by construction — `soul` links no Postgres client, [ADR-011](0011-go-layout.md)). Impl — NIM-711. **Amendment 2026-09-02 (NIM-746, [ADR-0083](0083-declared-secret-state-fields.md)): half of "resolve the same way" is wrong — a missing value is NOT minted on its own.** § *Secret resolution is orthogonal to the verb* says every verb *"keeps an existing Vault value, mints a missing one from the step's `generate_secret()` request"*: keeping is verb-independent and stands, minting is not — it is the **request** that mints, and where no marker reaches the resolve a missing value is a refusal (`keeper/internal/coremod/state/state.go:682`). The two-question table stays right in shape and wrong in one reading: *"always resolve, never rotate"* holds, "resolve" ≠ "mint if absent". The verb row is not purely verb-only either — `present` yielding to a populated field also decides what the SECRET resolve sees (it swaps in the stored element, from which `config.StripDeclaredSecrets` has already removed the marker, and sets `noMint`), which is the one case that fails closed at `state.go:679-681`. Three-outcome table, cites and the re-pointed-path consequence live in [ADR-0083](0083-declared-secret-state-fields.md), amendment of the same date. No code change; rotation stays inexpressible under every verb. | | [0085](0085-entity-id-and-label.md) | A registry entity carries an immutable `id` and a mutable `label` | active | One field, `name`, does two jobs that pull apart: an **identifier** that must never move (the `TEXT PRIMARY KEY` of twelve tables, segment 2 of every derived Vault path per [ADR-0083](0083-declared-secret-state-fields.md) §1, the CEL root `incarnation.name`, 49 `{name}` OpenAPI path templates, the RBAC `incarnation=` scope value) and a **caption** an operator wants to edit. The identifier wins everywhere, so the caption is simply not expressible — and putting one in the identifier is not cosmetic. **Decision: two fields.** **`id`** — one grammar for every registry, `^[a-z0-9][a-z0-9-]{0,62}$` (incarnation's existing pattern, unifying the four dialects on disk and closing `serviceregistry`'s `^[a-z][a-z0-9-]*$` having **no upper bound at all** while its value is a Vault path segment; a leading digit stays legal, so the live `9redis` fixture keeps its premise) — **immutable**, set once at registration, the PK / CEL root / URL segment / Vault segment / scope value. **`label`** — free text, capitals allowed, mutable, non-unique, optional, **display only**, seeded at registration with a Title-cased default from the git path. ★ **THE INVARIANT: `label` participates in NOTHING derived** — no Vault path, no RBAC scope, no snapshot directory, no `incarnation.<…>` in CEL, no selector, no resolver. Only under it is *"I changed the label and nothing moved"* a guarantee rather than a hope; without it the split buys nothing. **Immutability is mechanics, and it is TWO separate arguments that must not be merged.** (1) *A rename orphans:* `SecretField.VaultPath` joins its segments **verbatim** (`shared/config/secret_field.go:153-188`, checked only by `^[a-zA-Z0-9_-]+$` at `:59-66` — capitals pass, nothing folds case, Vault KV is case-sensitive) and **nothing in the tree rewrites a Vault path when an identifier changes — no rename operation exists anywhere**, so a changed id derives a *different* path and the old value becomes unreachable with no error raised. Sharpest instance, one hop and no state schema in between: `/herald//` and `/provider//credentials` take that segment **directly from the registry row's name** (`keeper/internal/secretwrite/writer.go:124-135`, [ADR-064](0064-secret-write-path.md)) — Herald and Provider are two of the eight. **No merge-or-replace claim belongs in this argument.** (2) *Replace-not-merge is what makes a COLLISION destructive* — `WriteKV` calls `Put`, not `Patch` (`keeper/internal/vault/client.go:458`/`:460`), which is the ADR-0083 / NIM-706 reserved-namespace argument and **not** the rename argument. ★ Framing: all of these are **already** immutable de facto — every PK is `name TEXT PRIMARY KEY`, no surface offers a rename, [ADR-0079](0079-incarnation-name-template.md) records it for incarnation — so this does not *introduce* immutability, it **names** it and adds the escape hatch that was missing. The capital letter lives in `label` and never in `id`. **`id` means "code word", not "opaque identifier"** — kebab, human-chosen, legible in a URL / a Vault segment / a CEL expression, like `SID` and `AID`; where the platform wants an opaque handle it uses a **surrogate** and says so (`apply_id`/`errand_id`/`AuditEvent.id` are ULIDs, `Rite.id` a sequence number), and `augur.Rite.ID int64` sitting **inside a renamed registry** is carved out in prose. **"label" now names two things and they never meet** — a **matching label** is a Coven tag / Trait pair ([ADR-008](0008-coven-stable-tags.md) / NIM-281 "a label is never inherited"), an entity's **`label` field** is a caption nothing derived reads; the selector metavariable is re-spelled `coven=` across ten doc sites so the two senses never share a line. **Scope is platform-wide, not the eight `NamePattern` registries:** all twelve `name TEXT PRIMARY KEY` tables convert (`rbac_roles`, `synods` and the choir FK columns included) and all 49 `{name}` path templates become `{id}` (`/v1/roles/{name}`, `/v1/synods/{name}`, `/v1/modules/{name}` included) — **a half-rename at the URL layer is the same defect that was rejected at the wire/DB boundary**, surviving in the layer an operator reads (tail beyond the eight — NIM-732). **`operators.display_name` → `label`** (NIM-733); `aid` stays that registry's id and its wider grammar is the one recorded exception (AIDs arrive from external identity providers). `service.yml` carries neither field — the manifest key `name:` is **deleted** (NIM-726), the same shape of decision [ADR-007](0007-versioning-git-ref.md) made for `version:`. **CEL reach:** `incarnation.name` → `incarnation.id`, `name_template:` → `id_template:`, `composes_name` → `composes_id`, `name_not_composable` → `id_not_composable` — which breaks every service repository, so the engine accepts both roots for a window, `soul-lint` warns with a line address, then the old root is dropped; ⚠ **that window is a DIFFERENT AXIS from [ADR-0076](0076-engine-compat-window.md)'s** (a keeper-version window per entity) and the two must not be conflated. **Consequences, three of them major:** (a) **a fence surface is lost** — deleting the manifest key removes one of ADR-0083's four reserved-namespace enforcement surfaces (`0083:621-626`, the manifest load); **not** a security regression, the floor still refuses to emit a colliding path (`shared/config/secret_field.go:164-167`), but the author stops finding out at authoring time (NIM-726 closes the offline half with `soul-lint --service-name`); (b) **a stale CEL root is a RUNTIME failure, not a compile error** — `incarnation` is DynType in all three envs (`shared/cel/engine.go:50-57`, `:82-87`, `:218-222`), and one of them is flow-control evaluated **on the host** (`flow_context = {input, vars, incarnation, self}`, `keeper/internal/render/pipeline.go:1358-1359`), so a stale `when:` dies mid-run after earlier tasks applied — the class [ADR-012](0012-keeper-soul-grpc.md)'s 2026-08-03 amendment recorded for `essence` ("a mixed-version fleet across this boundary is unsupported"), which makes **soul-lint's warning load-bearing, the only static catcher**; (c) **consumer breakage** — `soul-stack-web` (codegen `types.gen.ts` + routing, breaks **silently**: core `make check` cannot see it), `soulctl/internal/client` (manual, no codegen), the derived [`openapi.yaml`](../keeper/openapi.yaml) ([ADR-054](0054-openapi-code-first.md); `make check-openapi` reds until `make gen-openapi`, and the huma `pattern:` tags are hand-transcribed from the Go constants), nine MCP tool docs (MCP is a **primary** surface per [ADR-004](0004-binaries.md), not a wrapper), **every service repository** (35 occurrences over 23 files under `examples/` alone; external repos invisible here), `keeper/migrations`, `soul-stack-plugins`. **Two names are NOT decided here** and need propose-and-wait before NIM-728…733: the **permission** for the label mutation (`incarnation.update` was deliberately deleted, and the RBAC catalog is a closed enum whose unparseable grant aborts the snapshot load) and the matching **audit event** (`shared/audit/event_types_gen.go` is derived and pinned by `TestGeneratedEventTypes_NoDrift`). Rejected: a rename operation (needs a Vault path migration, a both-paths-live window and a crash answer — the split buys the same outcome with none of it); a half-rename leaving `{name}` in URLs; renaming everywhere except CEL (one entity, two names); an opaque ULID/UUID surrogate as the entity id (destroys the property `id` is for); scoping to the eight registries (the epic's original text, widened by the user); keeping `operators.display_name`. **Amends [ADR-0079](0079-incarnation-name-template.md) / [ADR-0083](0083-declared-secret-state-fields.md) / [ADR-029](0029-service-registry.md) / [ADR-064](0064-secret-write-path.md) / [ADR-008](0008-coven-stable-tags.md) / [ADR-047](0047-purview.md) / [ADR-0081](0081-roster-at-create.md) / [ADR-0082](0082-service-vars.md) / [ADR-012](0012-keeper-soul-grpc.md) / [ADR-025](0025-augur.md) / [ADR-030](0030-vigil-oracle.md) / [ADR-052](0052-herald-notifications.md) / [ADR-017](0017-keeper-side-core.md) / [ADR-032](0032-push-orchestrator.md) / [ADR-044](0044-choir.md) / [ADR-014](0014-operator-identity.md).** Status: **accepted, not implemented** — epic NIM-725, this ADR NIM-727, implementation NIM-728…NIM-733. | | [0086](0086-one-schema-dialect.md) | One schema dialect — `state_schema` is written in the input DSL | active | A service author writes two schemas, `input:` and `state_schema`, in **two different dialects** — the platform's own input DSL on one side, JSON Schema on the other. [ADR-062](0062-input-types.md) rejected `$ref` precisely to avoid *"a second schema DSL alongside our own input DSL (a divergence of `properties`/`required` semantics, the `type` vocabulary, `input_*` error codes)"*, then left that divergence standing in the one place it was already shipping. **Decision: `state_schema` becomes a map `` → schema, like `input:`.** The `type: object` / `properties:` wrapper and the list `required: [names]` are refused **at the root**; requiredness is `required: true` on the field. ★ **Root-only** — a nested object field still declares `type: object` with its fields under `properties:` — whereas the **list form leaves the whole dialect**, `types.yml` and nested objects included (28 flow-form sites across both dialects; the block form is authored nowhere). The vocabulary becomes snake_case, which is **alignment, not invention**: the input DSL already spells these that way (`shared/config/input_schema.go:322-342`) — `additionalProperties`→`additional_properties` (26 real sites), `minimum`→`min` (6), `minLength`/`maxLength`→`min_length`/`max_length`, `minItems`/`maxItems`→`min_items`/`max_items`, `exclusiveMinimum`→`exclusive_min` (those four with zero authored uses); `patternProperties` is refused outright — no counterpart, zero authored uses. Two capabilities follow from the shared dialect. **`$type` may carry the node's own `properties:` — in `state_schema` only** — framed as a **widening of an existing closed overlay set**, not a carve-out: `applyRefOverlay` already overlays `description`/`required`/`required_when` (`shared/config/input_types.go:430`) while `input_type_ref_conflict` refuses the closed `{type, properties, items}` (`:100`); `properties` moves from the second list to the first, **`type` and `items` stay refused everywhere** (they replace a shape rather than extend it, and a reference that replaces the shape is not a reference), and the merge is [ADR-009](0009-scenario-dsl.md)'s `extends:` covenant **by reference** — add-only shallow, fail-closed `input_type_ref_overlay_conflict`. And **`type: secret` becomes legal in `types.yml`**: *a property with `type: secret` in a shared type is not asked for on input — the platform mints it; in `state_schema` it means a declared secret.* **The input half of that was deferred and is **resolved and built since 2026-09-02 (NIM-751)** — such a property is stripped from the projected operator form, is never required and takes no default, and a value supplied anyway is refused as `input_secret_type_not_writable`; the render seal deliberately stays provenance-based on `secret: true`, because a value refused at the gate reaches no cell to mask. The secret node's grammar stays closed to `type`/`key`/`label` — the shared input keys (`default`, `enum`, `pattern`, `min_length`/`max_length`, `secret`, `prefill_from_state`, `required_when`, and `description`, that last one because `label` already carries the caption) are refused on it — and `required: true` on a `type: secret` property is refused as `secret_field_required`, since no state instance can satisfy it. **Breaking, no transition window.** The old envelope is refused **by name** (`state_schema_legacy_json_schema_form`) rather than left to parse, because read as the new dialect it is two state fields called `type` and `properties`, moving every real field a level down and losing every declared secret with no error raised; the list form is refused as `input_required_list_removed`. The open question §14 recorded — `validateObjectSchema` demanding `properties` on a `type: object` even when `additional_properties` carries a schema, which left the corpus's map-shaped state fields with no expressible form — was **resolved by NIM-742 as the ADR's candidate 1**: an object describes its contents through `properties` OR `additional_properties`, and the bare `false` counts as neither (`shared/config/input_schema.go:1155-1172`). ⚠ §14's own text in the ADR file still reads as open; amending it is NIM-741's. ⚠ And the migration is **not** parser-only: the read-path masking walk hardcodes `properties`/`items`/`additionalProperties` and would **fail open silently** (see [ADR-010](0010-templating.md)), so it moves in the same commit. Status: **accepted, implemented** — NIM-742 (engine + the masking walk), NIM-743 (`soul-lint list-secret-paths`), NIM-751 (the input side of §5); **NIM-744 (`examples/` and the WB redis service) is outstanding**. Epic NIM-740, this ADR NIM-741. **Amendment 2026-09-02 (NIM-751): §5's open question is resolved — the input side of `type: secret` is enforced by the engine, not by the author.** Candidate 1: a value submitted for such a property is **refused** (`input_secret_type_not_writable`, 422, naming the path and never the value), the property is **stripped from the projected operator form** after `$type` resolution (`state_schema`, which shares the resolver, keeps its secrets), and it is **never required and takes no default** (nor `required_when`) — §7's refusal of `required:` runs off `state_schema`, so a type reached only from `input:` never passes it. Two positions needed their own mechanism: `additional_properties`, which ordinary value validation never reaches (`refuseDeclaredSecretValues` — declared secrets only, no other validation of undescribed values), and the `form:` layout beside the schema, filtered by the same pass so a stripped field cannot ship as a labelled input with nothing behind it. Candidate 2 (refuse the `input:` reference outright) stays rejected: it forbids the type-sharing §5 grants. The seal is deliberately NOT widened to `type: secret` — a value refused at the gate reaches no cell to mask. **Amends [ADR-003](0003-destiny-format.md) / [ADR-009](0009-scenario-dsl.md) / [ADR-010](0010-templating.md) / [ADR-062](0062-input-types.md) / [ADR-0083](0083-declared-secret-state-fields.md).** | | [0087](0087-task-side-derived-from-module-address.md) | A task's side is derived from its module address, not declared by `on:` | active | `on:` is overloaded: for a Soul task it is a coven filter, and `keeper` is a magic scalar in the same key meaning "do not send this to hosts at all" — one key, two unrelated jobs, the second of them a routing verdict the platform already knows. `examples/service/redis` repeats `on: keeper` **89** times over 14 files (20 in `scenario/create/main.yml` alone) and `examples/` carries **171** in total (`tests/` carries none), mostly on `core.state.` captures where the address has already said it. Counts are of `on:` at key position with the value exactly `keeper` — a comment or a line inside a block scalar is not a task key and is not counted. **Decision: routing is DERIVED from the module address; `on:` returns to one meaning — a list of covens — and the scalar `keeper` on a CORE address becomes an error.** The derivation is unambiguous because the two core registries are **disjoint**: seven keeper-side bases (`core.bootstrap`/`core.cert`/`core.choir`/`core.cloud`/`core.soul`/`core.state`/`core.vault`) against twenty-one Soul-side ones registered in one map (`soul/internal/coremod/registry.go:62-82`); routing today is the single line `task.On.(string) == "keeper"` (`keeper/internal/render/dispatch.go:88-91`). **The refusal matrix is a table, not prose**, because an omitted `on:` now means two different things depending on the address: `on_keeper_redundant` on a keeper-side core, `on_covens_on_keeper_module` (never silently ignored — the defect class [ADR-0084](0084-explicit-state-capture.md) F-D removed for `when:`), and `on_keeper_on_soul_module` as a **separate** code from redundancy since the two are opposite mistakes with opposite fixes. ★ **The unknown rule is load-bearing ADR text:** a base absent from `shared/coremanifest` has **no side** — every new diagnostic stays silent and routing falls back to the written `on:`. Two live bases are absent today (`core.cert`, whose states ship keeper-side but has no `coreModules` entry, and `core.augur`, already recorded unchecked by [ADR-0076(p)](0076-engine-compat-window.md)), so without the rule `on: keeper` on `core.cert.registered` would be a **false error on working code**; both holes close in this epic. **The offline answer is not a new constant set** — `shared/coremanifest/coremanifest.go:51-83` already lists both sides in one table separated by a comment (`:74`), already sits in `shared/`, and already depends on nothing but `sdk/schema`; the side becomes a field on **`sdk/schema.Module`** (`:114-128`), subsuming the three ad-hoc constants that are the present drift surface (`stateModuleAddr`, `refreshModuleAddr`, `vaultEmitterModuleAddr`), pinned by widening `TestManifestAndDispatchAgree` — ⚠ whose registry is **conditional**, so the guard must build a full-deps registry or pass vacuously on three of seven. **A plugin module declares `side: keeper | soul`, default `soul`, PER MODULE** beside `capabilities`/`side_effects` (a bundle serves several modules; a document-level field forecloses a mixed artifact and lands on the three kinds with no `modules[]`); **the absent key and `side: soul` are indistinguishable BY DESIGN, permanently** — documents are signed and stored, so stamping the default in would change every sha256 and invalidate every approval at once. `on: keeper` on a **plugin** address stays legal permanently — a plugin's side lives in its stamped schema document, which a scenario cannot read — and ★ **NIM-758 made `side: keeper` a control**: `keeper_dispatch` falls back from the `coremod.Registry` to the discovered plugins and executes one declaring it, refuses one declaring `soul` **by name**, and still dies `unknown keeper-side module` **loudly, not as a silent skip** on an address nothing declares — an unenforced declaration is the defect [ADR-020](0020-plugin-infrastructure.md)'s 2026-08-06 amendment already undid for `side_effects`. **Consequences, three of them major:** (a) ★ **THE BLOCKER is the Passage stratifier, not a validator** — `onTargetsRoster` is literally `on == nil` (`shared/config/passage_refresh.go:240-242`), so dropping the key makes every keeper task a roster reader and pushes it into a **later Passage**. The affected set is narrower than the 171 lines — `readsRoster` is filled only when a plan has a refresh emitter (`shared/config/passage.go:174-178`) and only for tasks standing after one (`:254`), which in `examples/` is four trees (`redis`, `dragonfly`, `example-cloud-bootstrap`, `create-roster-guard`) — but **the hazard is silence, not volume**: the re-stratification is invisible (more Passages, more `apply_runs` rows, different register-visibility windows, no diagnostic anywhere), and one silently re-cut plan is already a wrong run nobody can see. The refresh emitter itself would also become a roster consumer, which the code comment at `:190-192` says must not happen — therefore `onTargetsRoster` **must become side-derived in the same move**, a requirement on NIM-749/750, not a follow-up; (b) **the `when:` hole is worse than "ignored"** — a dynamic `when:` **with** `on: keeper` is refused offline and at render, but one on a keeper-side module that **omits** the key is caught for `core.state.` only and **for the other six bases not at all**: it rides to the agent, is evaluated **before** the module lookup, and `when` false on every host gives SKIPPED everywhere, a **GREEN run**, and the cloud-create / soul-register / bootstrap-delivery silently never happened. The decision removes a divergence the tree **already documents** — three places in `shared/config` and the L0 trial derive from the address while the router derives from `on:`, and [`naming-rules.md`](../naming-rules.md)'s entry for `state_capture_not_on_keeper` records the consequence in so many words; (c) **the break is taken in one release with NO transition window** (user decision 2026-09-01, warn-then-error offered and declined): 171 lines become a load-time validation error (`PhaseSchemaValidate` / `PhaseSemanticValidate` — the YAML still parses, the definition no longer loads), and since a service loads at a pinned `ref:` ([ADR-007](0007-versioning-git-ref.md)) an unswept external fork simply **stops loading** — a different axis from [ADR-0076](0076-engine-compat-window.md)'s engine-compat window. Of the six `on:`-keyed validators, `state_capture_not_on_keeper` and `validateBlockOnKeeper` are **deleted** (keeper-side inside a block becomes *inexpressible* rather than refused), `validateBlockChildOnKeeper` survives with a new trigger keeping `block_on_keeper_invalid`, the `async`/`when` pair relocates from the `present["on"]` branch to `present["module"]`, and `validateOnField` (the `on:`-**shape** check, `enum_invalid`, "only 'keeper' is allowed as scalar") **survives unchanged**, because under (f) the scalar stays grammatically legal on a plugin address; **one new code the framing does not reach — `keeper_module_in_destiny`**, because `renderKeeperTask` exists only on the scenario path and the deleted validator was carrying that duty for `core.state`. Rollout for `side:` is **souls first, then keeper, then re-stamp** — decoding is strict (`sdk/schema/canonical.go:62`), so a document carrying the key fails to parse on an older `soul`, which reads it at install. Rejected: `runs_on:` (echoes the very key being retired); a `Document`-level `side`; a transition window; recording this as amendments inside ADR-009/ADR-017. **Amends [ADR-009](0009-scenario-dsl.md) / [ADR-017](0017-keeper-side-core.md) / [ADR-020](0020-plugin-infrastructure.md) / [ADR-0084](0084-explicit-state-capture.md).** Status: **accepted**; implemented by NIM-749 (the field, the diagnostics, the side-derived routing and stratifier) and NIM-758 (the keeper-side plugin executor that makes `side: keeper` a control) — epic NIM-747, this ADR NIM-748; **NIM-750** (sweeping the redundant `on: keeper` out of the service repositories) is outstanding. | ## Drafts — no number yet Rows here are ADRs in flight, authored as `draft-.md` under the convention above: they are **decided and citable**, and what they lack is only the number, which is stamped at squash-merge in the release worktree (rename to `NNNN-.md`, then move the row up into the table above with that number). Cite a draft **by its path**, never by a guessed number — a number written before the merge is exactly the collision the convention removes. *None right now.* The first ADR written under the convention — [One schema dialect](0086-one-schema-dialect.md) — was authored as `0086-one-schema-dialect.md` and stamped **0086** at its merge, taking the number that had been reserved for it by hand. > ADR-034, ADR-036, ADR-037 — numbers unused (a gap in numbering). ADR-0071 — reserved for in-flight design work, not yet written; ADR-0075 was reserved and is now written ([intra-host async tasks](0075-intra-host-async-tasks.md)). **ADR-0086 was the last number reserved by hand** — held for whichever of NIM-735 / NIM-741 needed it while 0087 was being written, and taken by NIM-741; under the convention above an in-flight ADR carries no number at all, so reservations should not be needed again.