Runtime → Charter → Doctrine — boundary audit and recommendations
Author: Architect Alphonso Date: 2026-05-17 Status: investigation + recommendations. Does NOT propose implementation; outputs are: import audit, classification, ratchet recommendation, and a draft architectural test that pins the boundary going forward.
Related: doctrine-artifact-selection-preflight.md — the user-journey investigation that depends on this boundary being enforced before the selection mission begins.
The intent
The user has stated the layering target:
runtime → charter → doctrine
No direct calls from the runtime to doctrine are allowed; they must pass through the charter proxy. Doctrine acts as a knowledge-retrieval store. Multiple doctrine packs may be added. The charter holds a registry of (doctrine-pack + element) tuples for contexts (mission_type + action, or generic) and defines which agent profiles are accessible — same as before: it combines multiple packs and exposes a subset, resolved through the charter, based on activation context.
This is a tightening of the existing ADR 2026-03-27-1 layer rule:
| Rule | Status today |
|---|---|
| kernel imports nothing | enforced (pytestarch, 8 tests passing) |
| doctrine imports only kernel | enforced |
charter imports doctrine + kernel (no specify_cli except specify_cli.runtime) |
enforced |
| specify_cli (runtime) may import doctrine directly | currently allowed — must change |
The current ADR permits the runtime to call doctrine. The new direction reserves doctrine access to the charter and requires the runtime to go through it. That is the only architectural change required by Cases 1 and 2 of the pre-flight; everything else is additive on top.
Today's surface — audit results
Counted via rg "^from doctrine|^import doctrine" against src/specify_cli/.
22 direct imports across 10 files, in 656 total .py files. Manageable scope.
src/specify_cli/doctrine/ (the pack-management subpackage authored in mission A and extended here) imports zero from doctrine.* — it consumes only charter-exposed and locally-owned types. That subpackage is correctly inside the boundary.
Imports grouped by purpose
| Group | Caller | Imports | Should migrate to |
|---|---|---|---|
| Agent profiles | invocation/registry.py |
AgentProfile, AgentProfileRepository |
charter.profiles.* (new facade) |
invocation/router.py |
DEFAULT_ROLE_CAPABILITIES, Role |
Same | |
| Mission step contracts | mission_loader/registry.py |
MissionStepContract, MissionStepContractRepository |
charter.mission_steps.* (new facade — charter already template-resolves these via template_resolver.py) |
mission_loader/contract_synthesis.py |
MissionStepContract models |
Same | |
mission_step_contracts/executor.py |
MissionStep, MissionStepContract, MissionStepContractRepository, ArtifactKind, DRG models, DRG query |
Same + DRG via charter (below) | |
| DRG (Doctrine Reference Graph) | calibration/walker.py |
DRGEdge, DRGGraph, DRGNode, Relation, load_graph, merge_layers, NodeKind, resolve_context |
charter.drg.* (new facade — charter already loads DRG via _drg_helpers.load_validated_graph) |
glossary/drg_builder.py |
DRGEdge, DRGGraph, DRGNode, NodeKind, Relation |
Same | |
mission_step_contracts/executor.py |
DRGGraph, NodeKind, ResolvedContext, resolve_context |
Same | |
| Primitives | missions/__init__.py |
PrimitiveExecutionContext, execute_with_glossary from doctrine.missions |
charter.primitives.* (new facade) |
| Resolution types | runtime/resolver.py |
ResolutionResult, ResolutionTier from doctrine.resolver |
charter.resolution.* (charter already wraps this via template_resolver) |
| Shared helpers | bulk_edit/occurrence_map.py |
SchemaUtilities from doctrine.shared.schema_utils |
Consider moving SchemaUtilities to kernel (genuine shared helper) — bypasses the layer question entirely |
| Versioning (borderline — charter-cohesive) | cli/commands/charter.py, cli/commands/charter_bundle.py, upgrade/migrations/m_3_2_6_charter_bundle_v2.py |
check_bundle_compatibility, get_bundle_schema_version from doctrine.versioning |
These are charter-bundle versioning helpers consumed by charter CLI surfaces. Re-export from charter.versioning (a thin pass-through) so the runtime sees a charter surface |
Charter's existing doctrine surface (the proxy as it stands today)
For reference, src/charter/ has 20 doctrine imports — these are the legitimate proxy surface. They already cover most of the territory the runtime needs:
- Agent profiles (
charter.contextimportsdoctrine.agent_profiles) - DRG loading + validation (
charter._drg_helpers,charter.synthesizer.*,charter.reference_resolver) - Template / mission resolution (
charter.template_resolverimportsdoctrine.missions.repository,doctrine.resolver) - Versioning (
charter.extractorimportsdoctrine.versioning) - Shared scoping helpers (
charter.catalog,charter.language_scopeimportdoctrine.shared.scoping) - SPDD reasons (
charter.contextimportsdoctrine.spdd_reasons)
The charter is almost already the proxy. The runtime's 22 direct imports largely duplicate access paths the charter already has internally; the migration is a re-routing exercise more than a build-from-scratch exercise.
Recommended approach — three phases, smallest viable scope per phase
Phase 1 — Pin the boundary contract with an architectural test (no source migration)
Land an architectural test that asserts the boundary and captures the current 22 violations as a baseline allowlist. Future PRs that add new specify_cli → doctrine imports outside the allowlist fail loud. The allowlist shrinks over time as phases 2–3 land.
This is the cheapest first step (≈ 100 lines of test code, zero source change) and it stops the drift while the larger work is being planned.
Draft sketch:
# tests/architectural/test_runtime_charter_doctrine_boundary.py
"""Runtime (`src/specify_cli/`) must reach doctrine artifacts via the charter
proxy. Direct `from doctrine.*` / `import doctrine` is reserved for the
charter layer and for `src/specify_cli/doctrine/` (the pack-management
subpackage explicitly designed as the doctrine-management surface)."""
_ALLOWLIST_BASELINE: set[str] = frozenset({
"src/specify_cli/bulk_edit/occurrence_map.py",
"src/specify_cli/calibration/walker.py",
"src/specify_cli/cli/commands/charter.py",
"src/specify_cli/cli/commands/charter_bundle.py",
"src/specify_cli/glossary/drg_builder.py",
"src/specify_cli/invocation/registry.py",
"src/specify_cli/invocation/router.py",
"src/specify_cli/mission_loader/contract_synthesis.py",
"src/specify_cli/mission_loader/registry.py",
"src/specify_cli/mission_step_contracts/executor.py",
"src/specify_cli/missions/__init__.py",
"src/specify_cli/runtime/resolver.py",
"src/specify_cli/upgrade/migrations/m_3_2_6_charter_bundle_v2.py",
})
The test walks AST per file under src/specify_cli/, flags every direct from doctrine.* / import doctrine, exempts the src/specify_cli/doctrine/ subpackage, exempts allowlisted files, fails on any new entry. As phases 2–3 land and migrate callers, the allowlist shrinks (the failure message tells the maintainer to remove the entry when they migrate).
Phase 2 — Build charter facades for the migration targets
Add the following surfaces to the charter layer:
charter.profiles.resolve(...)— proxiesdoctrine.agent_profiles.AgentProfileRepositorylookups; takes an activation context (mission_type, action) and returns the resolved profile + its accessible doctrine artifact set per the charter's selection.charter.mission_steps.resolve(...)— proxiesdoctrine.mission_step_contracts.repository.MissionStepContractRepository; the charter already template-resolves mission steps incharter.template_resolver, so this is a thin re-export.charter.drg.load_for(...)— proxiesdoctrine.drg.loader.load_graph+merge_layersper context; charter already does this in_drg_helpers.load_validated_graph— make it public.charter.primitives.execute(...)— proxiesdoctrine.missions.execute_with_glossary.charter.resolution.{ResolutionResult, ResolutionTier}— re-export the types fromdoctrine.resolverso callers can keep their type annotations.charter.versioning.{check_bundle_compatibility, get_bundle_schema_version}— thin pass-through todoctrine.versioning.
Each facade is a 5–10 line module. They do not move code; they re-export. The doctrine implementations stay where they are.
Phase 3 — Migrate the 10 runtime files
One-file-per-PR (or one consolidated PR, depending on how much you want to land at once). Each PR:
- Replaces
from doctrine.<x> import Ywithfrom charter.<facade> import Yin the runtime caller - Removes the file from the allowlist in the architectural test
- Runs the relevant integration / contract / architectural tests
Estimated effort: 10 PRs × small mechanical change each, or a single 1-day sweep. The mechanical-substitution side is small; the careful side is reviewing whether any caller was depending on a now-non-public detail of the doctrine subpackage. Charter's surface only re-exports the public symbols, so callers depending on internals would need refactoring.
The SchemaUtilities case at bulk_edit/occurrence_map.py deserves its own decision: move it to kernel (where it likely belongs anyway, given it's a generic helper) instead of routing through charter.
The migration of versioning to a charter facade is borderline — the three callers (cli/commands/charter.py, cli/commands/charter_bundle.py, the migration) are themselves charter-CLI surfaces, so importing the doctrine versioning module directly is arguably fine. Document the decision either way.
Estimated total effort
- Phase 1: 0.5 day (test + baseline + commit). Stops the drift immediately.
- Phase 2: 1 day (write the facades; they're mostly re-exports).
- Phase 3: 1 day (migrate the 10 files).
About 2.5 days of focused work to fully enforce runtime → charter → doctrine. Phase 1 alone is the must-have.
What this audit does NOT cover
- It does not propose where the activation registry (the
(activation_context, doctrine_pack_id, artifact_id)tuple registry from the pre-flight) lives in the charter — that's a selection-mission concern. - It does not propose how the charter facade methods should be named or what their argument shapes should look like — that's design work for phase 2.
- It does not audit
tests/for direct doctrine imports — tests legitimately reach into doctrine to construct fixtures; that's a separate scope. - It does not audit the agent CLI command surface (
spec-kitty agent <verb>) for doctrine bypasses — agent commands are themselves runtime; if they import doctrine directly they'd show up in this audit. They don't.
Recommendation in one paragraph
The runtime → charter → doctrine boundary is desirable, it matches the user's stated intent, and the current state is close enough that enforcing it is a 2–3 day project rather than a 2–3 month one. Land Phase 1 (the architectural test with the 13-entry baseline allowlist) before starting the doctrine-artifact-selection mission described in the pre-flight. That single move (a) pins the boundary so the selection mission doesn't accrete new direct imports while it's being built, and (b) makes the remaining migration a ratcheting exercise rather than a one-shot sweep. Phases 2–3 can land before, alongside, or after the selection mission — they're independent so long as Phase 1 prevents regression.
Appendix — raw import inventory (snapshot 2026-05-17)
src/specify_cli/bulk_edit/occurrence_map.py -> doctrine.shared.schema_utils
src/specify_cli/calibration/walker.py -> doctrine.drg, doctrine.drg.models, doctrine.drg.query
src/specify_cli/cli/commands/charter.py -> doctrine.versioning
src/specify_cli/cli/commands/charter_bundle.py -> doctrine.versioning
src/specify_cli/glossary/drg_builder.py -> doctrine.drg.models
src/specify_cli/invocation/registry.py -> doctrine.agent_profiles.{profile,repository}
src/specify_cli/invocation/router.py -> doctrine.agent_profiles.{capabilities,profile}
src/specify_cli/mission_loader/contract_synthesis.py -> doctrine.mission_step_contracts.models
src/specify_cli/mission_loader/registry.py -> doctrine.mission_step_contracts.{models,repository}
src/specify_cli/mission_step_contracts/executor.py -> doctrine.artifact_kinds, doctrine.drg.{models,query},
doctrine.mission_step_contracts.{models,repository}
src/specify_cli/missions/__init__.py -> doctrine.missions
src/specify_cli/runtime/resolver.py -> doctrine.resolver
src/specify_cli/upgrade/migrations/m_3_2_6_charter_bundle_v2.py -> doctrine.versioning
22 imports, 10 files. Snapshot for the architectural test's baseline allowlist.