Files
msd-core/docs/adr/1213-capability-state-writer.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

83 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-1213: Capability write side — the Capability State Writer [Proposed]
- **Status:** Proposed
- **Date:** 2026-06-14
- **Issue:** #1213
- **Completes:** Capability system (ADR-857) — the write half of the phase-4 "Wire" step
- **Builds on:** Capability declaration format (ADR-894), Capability command contribution (ADR-959), Skill Surface Budget Module (ADR-0011)
## Why this is still `Proposed` (audited 2026-07-17)
**What shipped.** The module this ADR decided is real and in production use: `setCapabilityState` / `cmdCapabilitySet` are implemented at `src/capability-writer.cts:140` and `:446`, wired into the CLI at `msd-core/bin/msd-tools.cjs:1941` and `:2314`, and `msd-core/workflows/settings.md:448` routes gate writes through `capability set --gate`. `CONTEXT.md:249` carries the glossary entry, and the landing commit (`bf634b95c`, "feat(#1213): Capability State Writer — write-side inverse of the resolver (#1225)") is a confirmed ancestor of `origin/next`. The write-side invariant this ADR set out to build — off means off, enforced at write time — is in force.
**The blocker.** This ADR's own Decision section (lines 27–29, as written above) declares the writer's return shape as `{ capabilities: CapabilityStateEntry[]; warnings: string[] }`, and decision item 4 says post-write divergence "is returned as warnings, not silently swallowed" — a warnings-only channel, no separate hard-failure signal. The shipped code does not match that: `src/capability-writer.cts:113-117` defines `SetCapabilityStateResult` as `{ capabilities, warnings, errors }`, and `errors[]` is populated both by pre-write validation rejections (e.g. `"unknown capability"`, `"cannot enable ... not in the install profile"`, which abort with zero writes) and by post-write assert failures (e.g. `"failed to disable ... still surfaced after write"`), which the CLI (`cmdCapabilitySet`, lines 483–493) turns into a non-zero exit — a real hard-failure channel this ADR's Decision section does not describe. This is not drift or a bug: it is a later, deliberate redesign. ADR-1411 (Accepted; 2026-06-18 Amendment) states explicitly that `capability-writer`'s "`errors[]` (operation-not-applied) is load-bearing and cannot fold into `warnings[]` (advisory)" and records the mutation-verb shape as `{ capabilities, warnings, errors }` (ADR-1411 lines 81, 86) — superseding the two-field interface this ADR decided. ADR-1213's own text has never been updated to note the amendment or to revise the signature, so as written it misdescribes the interface actually shipped.
**Dropped claim.** A second refutation argument held that the parent ADR-857 carried an explicit governance caveat reserving any Proposed→Accepted flip in this ADR family for a maintainer, and that flipping ADR-1213 on shipped-code evidence alone would repeat a move ADR-857 itself refused to make unilaterally. That premise no longer holds: `docs/adr/857-capability-system.md` now reads "Status: Accepted — ratified 2026-07-17" with a "## Ratification (2026-07-17)" section, and the caveat text this argument quoted is no longer present anywhere in that file (confirmed by direct search). ADR-857 was ratified in the same 2026-07-17 audit pass that reviewed this ADR, so this argument is dropped rather than carried forward as a live blocker.
**Unblock condition.** Revise this ADR's Decision section — the return-shape signature and item 4's assert-and-report description — to match what shipped: `{ capabilities: CapabilityStateEntry[]; warnings: string[]; errors: string[] }`, with `errors` describing operation-not-applied hard failures (pre-write validation rejects, post-write assert failures) distinct from advisory `warnings`. Either fold in a one-line "Amended by ADR-1411" pointer or edit the signature directly. Once the Decision section states the interface actually in the tree, this ADR is ready to ratify — the underlying mechanism is already proven in production.
## Context
ADR-857 promised: *"one resolved capability state replaces three contradicting toggle systems; 'off' means off."* The **read** side delivers it. The **Capability State Resolver** (`src/capability-state.cts`) collapses three substrates into one resolved state:
- install profile — `.msd-profile` (is the capability's skill set installed?)
- runtime surface — `.msd-surface.json` (is it surfaced into the runtime skills dir?)
- config gates — `config.json` `workflow.*` (is each hook configured on?)
with `enabled = installed && surfaced` and `active = enabled && configured`.
There is **no write side**. Three independent writers each mutate one substrate — `writeSurface` (`src/surface.cts`), `setConfigValue` (`src/config.cts`), `writeActiveProfile` (`src/install-profiles.cts`) — and every caller (`msd:surface`, `msd:settings`/`msd:config`, `install.js`, and the future ADR-959 capability command) coordinates them by hand. So *off means off* holds only as a **read-time computation the write side can violate**: the worst case is a capability left surfaced while every hook is config-gated off — "present but dead", still materialized and still costing context, but doing nothing.
The three substrates are not three ways to say one "off". They are **orthogonal axes at different lifecycles**: install is files-on-disk (uninstall removes them), surface is reversible-without-reinstall runtime state, and config gates are per-workstream and version-controlled in `.planning/`.
## Decision
Introduce the **Capability State Writer** (`src/capability-writer.cts`), the inverse of the resolver:
```
setCapabilityState(cwd, runtimeConfigDir, desired: DesiredCapability[])
-> { capabilities: CapabilityStateEntry[]; warnings: string[] }
DesiredCapability = { id: string; enabled?: boolean; gates?: Record<string, boolean> }
```
It accepts a *desired* capability state in the resolver's own vocabulary and projects it onto the substrates:
1. **Two orthogonal axes, one interface.** Per-capability `enabled` drives the runtime **surface** (the canonical capability on/off switch); per-hook `gates` drive the federated **config keys** (hook-level granularity within an enabled capability). The install profile is a **read-only floor** the writer never writes.
2. **Surface is the canonical "off".** Disabling unsurfaces — reversible, restart-and-go, and it reclaims the surface budget. It does not uninstall and does not clear config gates, so re-enabling restores prior gates; because `enabled = false` forces every hook `active = false` in the resolver, stale gates are harmless while off.
3. **One write per substrate.** A batch computes the full new surface state (one `writeSurface`) and the config deltas (one `setConfigValue` batch under `withPlanningLock`) — atomic per substrate, so no cross-substrate transaction is required.
4. **Assert-and-report.** After writing, re-run `resolveCapabilityState` and diff against `desired`; divergence (an uninstallable skill, a present-but-dead capability) is returned as `warnings`, not silently swallowed. The resolver is the writer's test surface: `resolve(write(s, d)) == d`.
5. **Callers.** `msd:settings` and the ADR-959 capability command route capability mutations through it, and `msd-tools capability set` is the direct CLI. `msd:surface` is a broader skill-surface tool operating on the **cluster superset** — capability clusters plus hand-authored clusters such as `utility`/`audit_review` — so it keeps its own surface mechanism rather than routing through the capability-scoped writer; the resolver honours any surface write, so *off means off* holds regardless of which path wrote. `install.js` keeps the profile-floor write (install lifecycle).
A `msd-tools capability set` subcommand (sibling to `capability state`) exposes it.
New domain term recorded in `CONTEXT.md`: **Capability State Writer**.
## Alternatives considered
| Decision | Rejected alternative | Why rejected |
|---|---|---|
| Substrate model | Collapse the three substrates into one capability-intent store | They encode genuinely different lifecycles (install = files on disk; surface = reversible runtime; config = per-workstream, version-controlled). Orthogonal axes, not redundant toggles; one store cannot hold the per-workstream config dimension without reinventing it. The resolver + writer win the invariant **without** merging the lifecycles. |
| Canonical "off" | Uninstall (drop from profile + delete files) | Heavy; needs a reinstall to undo; frees disk, not the context budget surface already reclaims. Keep uninstall as a separate explicit install-lifecycle operation. |
| Canonical "off" | Config-gate every hook | Leaves the skill surfaced-but-dead ("present but dead") and is per-workstream — contradicts off-means-off at capability granularity. |
| Interface scope | Capability-level enable only; hook gates stay in `config-set` | Splits the off-means-off invariant across two interfaces; the surfaced-but-all-gated check then has no single home. |
| Verification | Hard rollback (snapshot + restore) | Earns its keep only with cross-substrate transactions, which the one-write-per-substrate split avoids; assert-and-report suffices. |
## Consequences
**Positive**
- *Off means off* becomes a **write-time invariant**, not caller discipline.
- **Locality:** the projection and the invariants (including the present-but-dead check) live in one module.
- **Leverage:** one capability-mutation interface used by `msd:settings`, the ADR-959 capability command, and the `capability set` CLI.
- Symmetric with the resolver seam; the surface and config writers become its internal adapters.
**Negative / costs**
- A new always-on module to build and keep correct (and a generated `.cjs` to ship via `build:lib`).
- The desired-state vocabulary becomes a depended-on interface (Hyrum's Law) — name it and keep it compatible.
- The present-but-dead signal is advisory: the writer warns rather than auto-mutating, respecting explicit intent.
## Open questions
- Whether `enabled: true` for a capability below the install floor should auto-add it to surface `explicitAdds` (transitive closure) or warn-and-refuse.
- Whether the round-trip `resolve ∘ write == identity` warrants a deterministic CI conformance test alongside ADR-894's registry gates.