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

10 KiB
Raw Blame History

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.