09 — Context Decomposition: A Conceptual Model
Phase: 2 (conceptual modeling) · Date: 2026-06-03 Hypothesis under test (Stijn): "Context" is not one object. It is several domain-owned chunks of contextual information/config — infrastructure, filesystem, version control, execution preferences, execution state — each modeled individually in its proper domain, then aggregated by composition into fit-for-purpose composites passed through the API.
⚠ Revised by 11. A corroborate-vs-refute pass found that composed domain-owned context already exists as
ActionContext(core/execution_context.py:44, ADR 2026-03-09-1 "commands resolve context, prompts consume it"). The fragments below are best read as the internal structure of a hardenedActionContext, not six new public objects — keep it a deep module. Read11before treating this as the target.
Verdict up front: the hypothesis holds, and it is the right model. It satisfies every doctrine
constraint in 04, it dissolves the OperationalContext naming collision (07 §2), and ~4 of the
~6 fragments already exist in the codebase in some form. This document formalizes the model.
1. Two axes for classifying contextual information
A single flat MissionExecutionContext (the #1619 field list) conflates information that differs on
two independent axes. Separating them is what makes decomposition natural.
Axis A — Scope / lifetime (how long it's stable, how widely it applies)
| Scope | Stable for… | Examples |
|---|---|---|
| Install | the machine/install | shipped doctrine root, ~/.kittify, ~/.spec-kitty |
| Repo | a checkout | repo root, org packs, default target branch, config.yaml |
| Mission | one mission's life | mission_id, coord branch, lanes.json, coord worktree |
| Operation | one command invocation | cwd, op_kind, destination_ref for this op, flags |
Axis B — Domain (which bounded context owns the rules)
Infrastructure · Filesystem · Version Control · Execution Preferences · Execution State (+ Identity as the foundational zeroth domain everything keys on).
A field has one position on each axis: e.g. coordination_branch is Version-Control domain ×
Mission scope; destination_ref is Version-Control domain × Operation scope; shipped doctrine root is Infrastructure domain × Install scope. The flat object hid both distinctions.
A third distinction — primitive vs derived
- Primitive = read from a source (
meta.json,config.yaml,lanes.json, cwd, env, git). - Derived = computed by a domain rule combining primitives
(
coord_worktree = f(repo_root, slug, mid8);destination_ref = coordination_branch or current_branch).
The key move: a fragment is not a data bag. It encapsulates its domain's derivation rules (e.g. the Filesystem fragment owns the
.worktrees/<slug>-<mid8>-{coord,lane}convention; the Version-Control fragment owns thekitty/mission-<slug>-<mid8>naming). That is the deep-module (04) discipline applied per domain — the four duplicated path-builders (02) collapse into the Filesystem fragment's rules.
2. The fragment catalogue
Six fragments. Five are immutable value objects; one (Execution State) is the mutable
aggregate. Each is owned by exactly one domain and references the others by identity only
(Aggregate Design Rules, 04).
| # | Fragment (proposed name) | Domain | Scope | Primitive / derived | Exists today? |
|---|---|---|---|---|---|
| F0 | MissionIdentity |
Identity | Mission | primitive (meta.json) |
scattered → consolidate |
| F1 | InfrastructureEnv |
Infrastructure | Install/Repo | primitive (env, importlib, packs) | exists (DoctrineService roots, state-roots, get_kittify_home) |
| F2 | FilesystemLayout |
Filesystem | Repo+Mission+Op | derived (from F0 + roots + conventions) | partial (resolve_mission_read_path, CoordinationWorkspace) → consolidate (NEW) |
| F3 | VersionControlScape |
Version Control | Mission+Op | derived (from F0 + git + naming) | partial (branch_naming, CoordinationWorkspace.branch_name) → consolidate (NEW) |
| F4 | OperationalContext |
Execution Preferences | Session+Op | primitive (session, CLI, profile) | exists, wired (charter/invocation_context.py:155) |
| F5 | StatusSnapshot (VO) + MissionStatus (aggregate) |
Execution State | Mission | derived (hydrate via reduce) |
exists (status/) → formalize aggregate (07 §4) |
Fragment detail
F0 — MissionIdentity (value object; the key every other fragment carries)
- Fields:
mission_id(ULID),mid8,mission_slug,mission_run_id,friendly_name,mission_type. - Rule:
mission_idis canonical identity;mission_run_idis the distinct runtime/session id (ADR A4/A6,03). Other fragments reference F0 by value, never hold object refs to each other. - Today:
_identity_for_request(status_transition.py:105),resolve_mission_identity,mid8_from_slug(branch_naming.py) — consolidate into one VO + resolver.
F1 — InfrastructureEnv (value object; mostly ambient)
- Fields: built-in/shipped doctrine root,
kittify_home(~/.kittify),global_sync(~/.spec-kitty), package asset roots, org-pack roots, state-root classification. - Today: already modeled —
resolve_doctrine_root(charter/catalog.py:153),get_kittify_home(kernel/paths.py:24),StateRoot(state/contract.py),DoctrineServiceroots. - Open question: F1 is largely install/repo scoped and cross-mission — it may not belong inside the mission composite at all; it's ambient and already injected via
DoctrineService. Likely referenced, not embedded.
F2 — FilesystemLayout (value object; the #1619 path fields)
- Primitive:
primary_root(repo root),current_cwd. - Derived (owns the conventions):
feature_dir(primary),coord_worktree, lane worktrees, integration root,status_read_dir,status_write_dir,execution_workspace,prompt_source_dir,allowed_command_cwd. - Rule: owns the
.worktrees/<slug>-<mid8>-{coord,lane-<id>}convention + sparse-exclusion facts (currently inCoordinationWorkspace). This is where the four duplicated path-builders (02) go.
F3 — VersionControlScape (value object; the #1619 branch/ref fields)
- Primitive:
current_branch, worktreeHEAD, defaulttarget_branch(frommeta.json). - Derived (owns the naming):
coordination_branch(kitty/mission-<slug>-<mid8>), lane branches, integration branch,destination_ref(per op). - Rule: owns
kitty/mission-…naming (ADR A1). Theworktree_root == destination_refinvariant lives at the F2×F3 seam (a tiny explicit shared kernel) — today it issafe_commit's head-mismatch guard (commit_helpers.py:858).
F4 — OperationalContext (value object; ALREADY EXISTS — resolves the naming collision)
- Fields:
active_model,active_profile,active_role,current_activity,tech_stack. - This existing object is the Execution-Preferences fragment. We do not repurpose it for
filesystem aspects (that was the
07§2 collision). It slots into the model as F4 unchanged. - Open question: operation flags (
force,--no-auto-commit,execution_mode) — fold into F4, or split a tinyOperationPolicyfragment? (They're operation-scoped policy, not session preference.)
F5 — StatusSnapshot (read VO) + MissionStatus (write aggregate) (Execution State)
- The one mutable domain. CQRS-shaped already:
reduce(events) -> StatusSnapshotis the read model (frozen, composable into read/prompt contexts);MissionStatusis the write aggregate (load → claim/transition → save) per07§4. - Modeling clarification: Execution State appears in two forms — a frozen
StatusSnapshotfragment you compose into read/render composites, and theMissionStatusaggregate you load using a composite to mutate. The aggregate is a consumer of the context, not a member of it.
3. The derivation graph
Fragments are not peers in a flat bag; they form a small DAG rooted at Identity.
sources: meta.json config.yaml lanes.json git cwd/env session/CLI
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
F0 MissionIdentity ◄──────┘ │ │ │ │
│ │ │ │ │ │
│ └──────────────┐ │ │ │ │
▼ ▼ ▼ ▼ │ ▼
F3 VersionControl F2 FilesystemLayout ◄─────────┘ │ F4 OperationalContext
(branch naming) (worktree/path conv.) │ (model/profile/role)
│ │ │ │
│ worktree_root │ │ read_dir / write_dir │
└──── == ──────────┘ └──────────────┐ │
destination_ref ▼ │
(F2×F3 shared kernel = F5 StatusSnapshot ◄────┘ (cwd/op selects which dir)
safe_commit invariant) (reduce(events @ read_dir))
- F0 is the root; F2 and F3 derive from it + conventions; F5 hydrates from F0+F2.
- F1 sits to the side (install/repo-ambient).
- F4 is independent of F0 (it's about the operator/session, not the mission) — which is why it composes cleanly and why it shouldn't have been conflated with mission topology.
- The only inter-fragment coupling is the F2×F3 shared kernel: the
(worktree_root, destination_ref)pairing — small, explicit, already embodied bysafe_commit. Everything else references F0 by value. This satisfies DIRECTIVE_031 (no shared mutable state; explicit kernel).
4. Composition into fit-for-purpose composites
The object passed through the API is an operation-specific composite that selects only the fragments that operation needs. The composite is a deep module (small interface); the fragments are its hidden structure.
| Operation | F0 Id | F2 FS | F3 VC | F4 Pref | F5 State | Composite (working name) |
|---|---|---|---|---|---|---|
| status read / kanban | ✓ | read_dir | snapshot | ReadContext |
||
| render agent prompt | ✓ | prompt_source | branch (display) | session | snapshot | PromptContext |
| claim / implement-start | ✓ | write_dir, workspace | dest_ref, lane | actor, flags | aggregate | WriteContext |
| move-task / transition | ✓ | write_dir | dest_ref | actor, force | aggregate | WriteContext |
| review | ✓ | read+write, artifact dir | dest_ref | reviewer | aggregate | ReviewContext |
| merge → done | ✓ | integration root | integration branch, dest_ref | aggregate (all WPs) | IntegrationContext |
|
| doctrine/guidance load | ✓ | (project root) | active action | (uses F1 + action scope) |
Three observations:
ReadContextandWriteContextare different composites — exactly the read/write split the two existing half-resolvers (02) already imply, now made explicit. This directly satisfies I-2 ("distinct read/write/destination outputs, never one fusedfeature_dir").WriteContextis the home of the atomicity invariant (I-4): it bundlesF2.write_dir+F3.destination_ref+ the F2×F3 kernel, so a caller cannot commit to a mismatched pair. This is how #1618/#1348 get closed rather than avoided.PromptContextrenders from F2+F4+F5 — so prompts are derived from the same fragments the CLI writes through. That is I-6 (#1616) by construction: the agent contract can't contradict the topology.
Composition, not inheritance
Composites are assembled by composition (hold fragment instances), never by subclassing. This is
the ProjectContext-holds-PackContext precedent (07 §1b) generalized. Fragments stay independently
testable; composites are thin selectors.
5. The central builder
One factory in mission_runtime/ assembles fragments → composite, mirroring
_build_doctrine_service_with_org_layer (07 §3):
build_mission_context(selector, *, op_kind, cwd) -> <Read|Write|Prompt|...>Context:
identity = resolve_identity(selector) # F0 (meta.json)
fs = FilesystemLayout.for_mission(identity, repo_root, lanes) # F2
vc = VersionControlScape.for_mission(identity, repo_root) # F3
prefs = build_operational_context(...) # F4 (existing builder)
return compose(op_kind, identity, fs, vc, prefs) # selects fragments for op_kind
# MissionStatus.load(ctx) only when mutation is needed (F5 aggregate)
- Dataclasses (F0–F4) live in
charter(import-clean ofspecify_cli); builders live inspecify_cli/runtime— the layer law (07§1e).MissionStatus/StatusSnapshot(F5) stay instatus/. op_kindlives in the builder, not on the fragments (fragments are op-agnostic; the composite is op-specific).
6. How this resolves the open decisions
Decision (08) |
Resolution from this model |
|---|---|
| D1 OperationalContext naming collision | Resolved. Existing OperationalContext = the F4 Execution-Preferences fragment, unchanged. The filesystem concept is F2 FilesystemLayout, a different fragment. No rename. |
| D5 durable topology vs per-invocation context | Resolved into a cleaner cut: durable = mission-scoped fragments (F2/F3 derivations); per-invocation = the composite + op-scoped fields (cwd, dest_ref, flags). Not two objects — one fragment set, op-specific composites. |
| D2 does MissionStatus own the commit seam | Sharpened: the commit target is owned by WriteContext (F2×F3 kernel); MissionStatus.save() uses that target. Atomicity is enforced by the composite, the aggregate rides it. |
| D4 object vs service vs façade | Reframed: fragments are value objects (A); the write composite + aggregate form the operation service (B) that enforces I-4. We get A and B at different layers, not either/or. |
| D6 central builder signature | Drafted (§5): build_mission_context(selector, *, op_kind, cwd). |
7. Constraint check (against 04)
- ✅ Boundaries by ubiquitous language — each fragment is a domain (Filesystem, VC, Preferences, State), not a runtime stage. (DIRECTIVE_031)
- ✅ No shared mutable state across boundaries — only F5 mutates, as a single-writer aggregate; F0–F4 frozen; the one cross-fragment coupling (F2×F3) is an explicit small kernel. (DIRECTIVE_001/031)
- ✅ Reference by identity — fragments carry
mission_id, not object refs to each other. (Aggregate Design Rules) - ✅ Deep modules — composites expose small interfaces; derivation rules hidden in fragments. (Deep Module Design)
- ✅ Layer law — VO dataclasses in
charter, builders inspecify_cli/runtime. (test_layer_rules) - ✅ ≤ a few contexts — 6 fragments, 4 op-composites; not per-field or per-entity. (over-split failure mode avoided)
- ⚠️ DIRECTIVE_032 — fragment names (
FilesystemLayout,VersionControlScape,InfrastructureEnv) are provisional; ratify in glossary + ADR before coding.
8. Open modeling questions (for the next session)
- Is F1 (Infrastructure) in or out of the mission composite? Leaning out — it's install/repo
ambient, already injected via
DoctrineService. Mission composites would reference it, not embed it. - Do operation flags (
force,--no-auto-commit,execution_mode) live in F4, or a 6th tinyOperationPolicyfragment? They're op-scoped policy, not session preference. - Is
current_cwda Filesystem (F2) field or its ownInvocationFactsfragment (cwd + op_kind- actor + timestamp)? Argues for a 7th micro-fragment — weigh against over-split.
- Composite granularity: are
Read/Write/Prompt/Review/Integrationthe right composites, or do we want fewer (oneOperationContextwith optional fragments) — at the cost of a larger interface? - Where does the F2×F3 kernel object live? A dedicated
CommitTargetvalue object (worktree + ref, self-validating), or keep it implicit insafe_commit? ACommitTargetVO would make I-4 a type, not a runtime check. - Does
StatusSnapshot(F5 read VO) get embedded in composites, or fetched lazily via the aggregate to avoid staleness? (Embedding = a point-in-time view; lazy = always-fresh but couples render to load.) - Fragment naming ratification (DIRECTIVE_032) — lock the vocabulary before the first ADR.
9. Why this model is the answer (summary)
The #1619 flat field list was a symptom: it bundled install-scope, mission-scope, and operation-scope
facts from five different domains into one object that every caller had to rebuild. Decomposing by
domain × scope, encapsulating derivation rules per fragment, and composing per operation
gives us: distinct read/write/destination outputs (I-2), atomicity-by-construction (I-4),
prompts-from-context (I-6), single-writer state (I-9), and a clean home for each rule — while
reusing F1 and F4 (which already exist) and formalizing F2/F3/F5 from logic that is already
written but scattered. It is the same compositional pattern the codebase already trusts
(ProjectContext⊃PackContext), applied to mission execution.