Executable doctrine runs only from trusted publishers
Filename: 2026-08-13-4-executable-doctrine-runs-only-from-trusted-publishers.md
Status: Proposed
Date: 2026-08-13
Deciders: Operator (ATDD)
Technical Story: ADR 2026-08-13-2 makes gates run shipped code. Org and project packs can ship gates too — arbitrary code executing on every mission transition. That is a supply-chain surface and needs a trust model.
Post-dialectics revision (2026-08-13)
Hardened after the dialectics pass (work/gate-design-dialectics/03-..., 99-COHERENCE.md). Settled deltas:
- KEEP — built-in release signing as the architectural target (operator decision). Note the dialectic's caveat: built-in ships in the same wheel as its verifier + bundled key, so signing's near-term threat value is low; its value is establishing the provenance chain for fetched / org packs. Whether to implement signing now is a scoping decision, separate from keeping it as the to-be design.
- CHANGE — untrusted is not a skip. An untrusted publisher is one severity-bearing could-not-run outcome; the operator's error-handling strategy decides the effect — see ADR 2026-08-13-6. Security gates therefore fail closed by default. The "skip + warn, transition proceeds" language below is superseded by that model.
- CHANGE — trust is keyed on the operator-config coordinate + observed content-hash, not the
self-declared
pack-meta.yamlidentity (spoofable, and it fights the operator-authority invariant where the loader overrides pack-side identity). - KEEP — prompt at activation, never run time; CI blocks loudly on a missing trust seed (never a silent skip).
- STRIKE — the "one hash, three jobs" framing. Signature + identity are sound; the cache-key role is removed (deferred with the daemon — ADR 2026-08-13-5).
Context and Problem Statement
Declarative gates execute code shipped inside a doctrine pack. Built-in gates are first-party.
But the pack ecosystem (org packs, project packs) means a downstream pack could ship a gate
whose asset runs on every transition — an unreviewed code-execution surface. There is no
trust, signing, or provenance-verification machinery today (grep confirms: the nearest is
drg/override_policy.py, which is merge policy, not trust).
Inert doctrine (directives, tactics, styleguides) is text and is never executed — so a trust decision is only ever needed for executing shipped code (gate assets). The scope is narrow by construction.
Decision Drivers
- Never execute unreviewed third-party code silently.
- Never hang or block a mission on a trust decision — gates fire in CI and (later) under a daemon, where no operator is present to prompt.
- Reuse the
pack-meta.yamlidentity/version/content-hash already proposed for the pack restructure rather than invent a parallel identity.
Considered Options
- A — Trust everything (execute any pack's gate). Rejected: supply-chain hole.
- B — Trust nothing but built-in (never run org/project gates). Rejected: kills the ecosystem value.
- C — Trust-on-first-use (TOFU), SSH-
known_hostsstyle, keyed on publisher identity.
Decision Outcome
Chosen option: "C".
- Built-in / spec-kitty-signed = trusted by default. Built-in ships a signature over its
pack-meta.yamlcontent-hash, verifiable against a bundled public release key. This gives the content-hash a second job (it is signed) alongside its cache-invalidation role. - Org/project packs = TOFU. On first encounter of a pack that ships executable gates, the
operator is asked, once, whether they trust the publisher (identity from
pack-meta.yaml). The decision persists in aknown_hosts-equivalent trust store in the spec-kitty home, keyed on publisher identity + the signed/observed content-hash. - The trust prompt happens at pack activation/install time, never mid-transition. At run time the decision is looked up non-interactively.
- Low-trust ⇒ skip + warn, transition proceeds. An untrusted gate is not run; a loud warning records that a transition guard was skipped due to low trust, and the transition is not blocked. Consequence stated explicitly: a fail-closed gate from an untrusted pack provides zero protection — gate protection is only ever as strong as the trust decision.
- CI / non-interactive is the governing constraint. Unknown publisher at run time defaults to skip+warn — never a prompt, never a hang. Trust must be pre-seedable (config/flag) so CI and the daemon can run trusted gates without interaction.
Consequences
Positive
- Third-party code never runs unreviewed; the operator's trust decision is explicit and persisted.
- CI and automation never block or hang on trust; the safe default is "don't run untrusted code".
Negative
- Signing infrastructure (release key, signature in
pack-meta.yaml, verification path) is net-new and must be got right (key rotation, offline verification). - "Skip + proceed" means an untrusted security gate silently protects nothing — this must be surfaced loudly, or an operator may believe a gate is guarding a transition that it is not.
Neutral
- Trust is orthogonal to gate correctness — a trusted gate can still be a bad check; a rejected gate can still be a good one that simply won't run.
Open questions (for the dialectics squad)
- Prompt at activation vs first-execution (TOFU-faithful but interrupts a transition)?
- Is "skip + proceed" the right low-trust default, or should certain gate classes be able to declare "block if I cannot run" (turning untrust into a hard stop)?
- Publisher identity granularity — per pack, per publisher key, per source URL?
- Signing: detached signature over the content-hash vs a signed manifest; where the public key ships and how it rotates.