Context

ADR 2026-06-03-1-execution-state-domain-model.md ratified the bounded-module decomposition and named Execution / Runtime as the owner of workspace resolution, branch state, and CWD-invariant context. It also recorded that the residue surfaces (~40 command surfaces re-deriving execution context) must route through resolve_action_context (core/execution_context.py).

What that ADR did not lock in was the physical home of the execution-state domain. Today resolve_action_context and its ActionContext value object live in src/specify_cli/core/execution_context.py, buried inside the CLI package. That placement makes the domain invisible at the package level: there is no top-level module name that screams "this is where execution state lives", and nothing prevents new surfaces from re-deriving their own context rather than importing the canonical one.

The design analysis in docs/plans/engineering-notes/runtime_and_state_overhaul/06-proposed-domains-and-splits.md §4/§5 resolves this. §4 records the operator decision (Stijn, 2026-06-03) to create a net-new top-level mission_runtime/ umbrella package, and §5 fixes the owner shape (Strangler façade → operation service; value-object resolver as fallback).

Per C-006, this ADR is written before any mass code lands. The umbrella is stood up empty-but-registered in this work package (WP02); the relocation of the hardened ExecutionContext logic into it is WP03.

Decision

1. Module name: top-level mission_runtime/

The canonical execution-state surface is a net-new top-level package src/mission_runtime/. Rationale (doc 06 §4):

  • Screaming Architecture — the package structure should name the domain. A top-level mission_runtime/ makes the execution-state domain discoverable at the highest level of the source tree, rather than hiding it under specify_cli/core/.
  • Strangler Fig — the new home grows alongside the old. The umbrella is the new canonical surface; existing call-sites are strangled onto it incrementally, and the old core/execution_context.py becomes a thin re-export shim removed once unreferenced (FR-003).

mission_runtime is preferred over harden-in-place (keeping the logic in core/) for domain clarity. The trade-off — a new top-level package must be registered in the layer meta-guard — is accepted and discharged below.

2. Layer placement: charter-level sibling of runtime

mission_runtime is registered in _DEFINED_LAYERS in both tests/architectural/test_layer_rules.py and the landscape fixture in tests/architectural/conftest.py. Omitting either causes test_no_unregistered_src_packages to fail (doc 06 §4).

It sits at the charter level, consistent with runtime and glossary. The spine remains kernel ← doctrine ← charter ← specify_cli; runtime, glossary, and now mission_runtime are siblings above charter. mission_runtime may import specify_cli.* (it delegates to today's resolver during migration) but must not import CLI presentation layers (specify_cli.cli / specify_cli.next), mirroring the runtime boundary.

3. Lean public API expressed over context objects

The package root exposes a minimal __all__ (the __all__ Declaration Convention, C-007):

__all__ = ["ExecutionContext", "resolve_action_context", "ActionContextError"]
  • ExecutionContext — the immutable, complete resolved context (read/write/dest dirs, branch, WP identity, prompt). The API is expressed over this context object, never over path fragments. Consumers receive a resolved context; they do not reconstruct main_repo_root / "kitty-specs" / mission_slug themselves (FR-009).
  • resolve_action_context(repo_root, mission, wp_id=None, *, mode=None) -> ExecutionContext — the single resolution entry point. CWD-invariant, topology-aware, mode-correct target_branch, and raises ActionContextError on unresolvable context with no silent fallback.
  • ActionContextError — the only error type consumers catch.

Everything else is internal. The implementation lives in submodules (mission_runtime/context.py, mission_runtime/resolution.py) that consumers must not import directly — enforced by tests/architectural/test_mission_runtime_surface.py (FR-005). This keeps the surface lean and prevents the internal-leakage that let the old core/ resolver sprawl.

4. Stage-C shape only

This umbrella adopts the Stage-C shape from doc 06 §5: a Strangler façade (resolve_action_context) returning an immutable ExecutionContext value object, delegating to today's resolver during migration (option C → A). The Stage-B operation service / CommitTarget (commit-seam atomicity) is out of scope (C-008) — doc 06 §6 step 7 confirms safe_commit already enforces the commit invariant, so CommitTarget is an ergonomic improvement deferred to later work, not a safety gate this surface must provide.

5. Strangler migration order

  1. WP02 (this WP) — stand up mission_runtime/ empty-but-registered: package skeleton + lean __all__ (stub symbols), layer-guard registration in both files, sole-resolver surface test, and this ADR.
  2. WP03 — relocate the hardened ExecutionContext / resolve_action_context logic from core/execution_context.py into the umbrella; wire the __all__ symbols; leave core/execution_context.py as a thin re-export shim (NFR-001: behaviour preserved).
  3. Downstream WPs — strangle the residue surfaces onto mission_runtime.resolve_action_context; delete duplicated path-builders; remove the core/execution_context.py shim once unreferenced (FR-003).

Consequences

What changes

  • A new top-level package src/mission_runtime/ exists and is enforced by the layer meta-guard. Future code that adds execution-state logic has a named home.
  • The lean __all__ becomes the contract; the surface test makes internal imports a build failure, so the leakage that bloated the old resolver cannot recur.

What stays the same

  • Nothing relocates in this WP. core/execution_context.py and all current call-sites are untouched (relocation is WP03). The umbrella symbols are stubs until WP03 wires them.
  • Legacy missions continue to resolve through the existing resolver until they are strangled (NFR-001).

What is now explicit

  • The execution-state domain has a screaming, top-level home: mission_runtime.
  • The public surface is lean and expressed over context objects (originally four symbols; ExecutionMode was retired 2026-08-21 — see Amendments), and internal submodules are import-forbidden from outside the package.
  • The migration is Strangler-ordered: umbrella first (WP02), relocation second (WP03), call-site cutover and shim removal after.

Amendments

  • 2026-08-21 — ExecutionMode retired (mission rc3-execution-mode-consolidation-01M0GGX1, M7). mission_runtime.context.ExecutionMode (a worktree / code_change enum) was never consumed: MissionExecutionContext.execution_mode carries a raw str, and no code compared against the enum's members. It was removed from context.py, from the package __all__, and from this ADR's public-API listing. The worktree-vs-direct execution-mode axis is owned by the external spec_kitty_events.status.ExecutionMode (worktree / direct_repo); the retired local enum was a dead, mis-named duplicate of that axis whose code_change token also collided with the ownership enum's unrelated code_change ("WP produces code"). Retiring it removes both the dead symbol and one half of that class-name/token footgun.

  • 2026-09-28 — OwnedCheckout and OwnedRefusalCode added to the root surface (mission owned-checkout-lifecycle-authority-01M3M2ZB). mission_runtime.OwnedCheckout is the validated ownership fact: the owned checkout, the repository root checkout, the mission's stored topology and its target branch, proven once per command. The placement seam, WP workspace resolution, the status transition pipeline, the next runtime and the prompt builder consume it, so it lives in this package and adds no outbound edge into the CLI application layer. It is minted only by specify_cli.core.owned_mission (architectural gate G3) and is exported on the root because MR-1/MR-2 forbid submodule imports. mission_runtime.OwnedRefusalCode is a second new root export: a StrEnum (value == name) holding the OWNED_* refusal-code registry from data-model.md, exported alongside OwnedCheckout so code and tests import the member instead of repeating the literal (Sonar S1192). OwnedCheckoutPathRefused, the exception that subclasses the already-public ActionContextError, is deliberately not exported. tests/architectural/test_mission_runtime_surface.py pins both OwnedCheckout and OwnedRefusalCode in _PUBLIC_SURFACE.

References