Phase 0 Research: Charter as Sole Door: Close Bypass Access Paths

Three parallel research agents were dispatched against the spec's four bypass categories, reading full call sites (not just the file:line anchors) and, where relevant, git history. Their findings produced four planning-time corrections to the spec (each confirmed with the operator via AskUserQuestion before this plan was written) plus a set of implementation-level design decisions that did not require operator input.

Corrections requiring operator decision (resolved)

D1 — FR-004's original acceptance criterion is unsatisfiable

repository.py:98) as the single missions-root authority; retarget the other two hardcodes onto it. Defer the doctrine.pack_paths.built_in_dir convergence to #3091`.

no missions/ subdirectory today — confirmed on disk (packs/built-in/ contains directives/ tactics/ styleguides/ procedures/ paradigms/ agent_profiles/ glossary_packs/ toolguides/ assets/, no missions/). pack_paths.py's own docstring names TEMPLATE/MISSION_STEP_CONTRACT/ANTI_PATTERN as kinds with no built-in content dir, citing #3091 (the missions→packs relocation, itself deferred by this mission's C-003) as the future work that adds one. default_missions_root() was found already correctly implemented — importlib.resources-based (wheel-safe), with a documented fallback — making it the better promotion target than inventing a new shared constant.

not deferred wholesale); retarget onto pack_paths.built_in_dir as originally specced (rejected — would require the deferred #3091 relocation to exist first, making the acceptance criterion unsatisfiable).

  • Decision: Promote MissionTemplateRepository.default_missions_root() (`src/doctrine/missions/
  • Rationale: doctrine.pack_paths.built_in_dir resolves under packs/built-in/<kind.plural>/, which has
  • Alternatives considered: Drop FR-004 entirely (rejected — operator wanted the duplication closed now,

D2 — Two AgentProfileRepository sites are not doctrine-asset bypasses

profiles_cmd.py:83 are excluded from FR-001's scope (C-006). Both construct AgentProfileRepository against .kittify/profiles`, and both already separately call the canonical factory correctly for their doctrine-layer data.

it." .kittify/profiles is a legitimate local-override mechanism outside the doctrine activation model by design, not a bug; it is scoped for its own future rework, separate from this mission.

(rejected by operator — risks conflating two deliberately-separate concerns, and the research found no existing factory affordance for that directory to extend from).

  • Decision: src/specify_cli/invocation/registry.py:48 and `src/specify_cli/cli/commands/
  • Rationale (operator): ".kittify contains local overrides, this slice is to be reworked, do not route
  • Alternatives considered: Widen the factory's conceptual scope to also cover .kittify/profiles

D3 — A real C-001 violation surfaced as a byproduct: two divergent builders

service and charter.doctrine_service_builder._build_activation_aware_doctrine_service` as new scope (FR-008), rather than deferring it as a separate issue.

active_languages=infer_repo_languages(repo_root) into the inner DoctrineService construction; the specify_cli builder omits active_languages entirely. active_languages flows into every per-kind repository (src/doctrine/service.py:72,82,92,111,148) for language-scoped filtering, so the two builders can return different catalogs for the same repo_root. Separately, the charter builder requires an explicit org_roots argument (defaulting to None → no org layer) while the specify_cli builder always self-resolves org roots via resolve_org_roots — a caller of the charter variant who forgets org_roots silently loses the org layer. This directly undercuts the very "single canonical authority" claim this mission's C-001 makes, discovered while doing this mission's own work — domain-matched, unlike the five issues confirmed adjacent-but-separate pre-spec.

unchanged (rejected by operator — the divergence is inside the exact factory this mission is making the sole door, so leaving it standing would ship a mission that claims C-001 while visibly violating it).

  • Decision: Fold in unifying `specify_cli.doctrine_service_factory.build_activation_aware_doctrine_
  • Rationale: The two functions are NOT behaviourally identical. The charter builder passes
  • Alternatives considered: File as a separate GitHub issue and leave the mission's original FR list

D4 — mission-type does not fit the mechanical 9-kind pattern

MissionTypeProfileRepository / charter.mission_type_profiles.resolve_mission_type_context() (FR-006), not via a new property on charter.resolver.DoctrineService`. Per operator decision, do the real work rather than narrowing the "all 10 kinds" claim to 9.

frozenset[str], never None_read_activated_mission_types (pack_context.py:601-619) already collapses "key absent" to builtin_mission_type_id_set() at construction time, so the three-state semantics (None/frozenset()/{ids}) the other 6 kinds use don't apply here; the default is baked in before any resolver sees it. Separately, src/doctrine/service.py has no mission_types property at all (confirmed by grepping every @property on the raw service: directives, tactics, styleguides, toolguides, paradigms, procedures, mission_step_contracts, glossary_packs, assets, agent_profiles — no mission-type entry) — mission-type resolution has always lived entirely outside DoctrineService, in a separate repository. There is nothing for charter.resolver.DoctrineService.__getattr__ to even unfilteredly forward for this token the way it does for the other 7 — the "ungated passthrough" framing doesn't fit.

carve-out matching how TEMPLATE/ASSET/ANTI_PATTERN are already carved out via _NON_AUGMENTATION_ELIGIBLE_KINDS (rejected by operator — "extend to MissionTypeProfileRepository too, your original intent").

  • Decision: Gate the mission-type token via `charter.mission_type_profile_repository.
  • Rationale: PackContext.activated_mission_types (src/charter/pack_context.py:120) is a plain
  • Alternatives considered: Narrow to 9/10 real ArtifactKind gating, name mission-type as an explicit

Design decisions resolved without operator input

R1 — _resolve_directives_selection clarifies, does not contradict, the "3 of 10 gated" premise

charter/resolver.py::_resolve_directives_selection (lines 233-298) already implements the exact three-state semantics for directive and reads PackContext.activated_directives directly — but it is private, used only inside the separate resolve_project_governance() function, and is not wired to charter.resolver.DoctrineService's properties (which are exactly paradigms, procedures, agent_profiles — confirmed by grepping @property in the file). It is the fallback-source exemplar to copy the three-state logic from for FR-005's 6 mechanical kinds, not evidence that directive gating already exists on the factory. The spec's "3 of 10 gated" premise stands correctly.

R2 — FR-005's 6 mechanical kinds require zero PackContext schema changes

PackContext (src/charter/pack_context.py:100-259) already carries a three-state activated_<kind> field for every one of directive (144), tactic (152), styleguide (155), toolguide (158), mission_step_contract (170), glossary_pack (173) — each populated via a dedicated _read_activated_* reader (lines 622-676), structurally identical to the readers already wired for paradigms/procedures/ agent_profiles. src/doctrine/service.py confirms matching raw properties for all 6: directives (57), tactics (66), styleguides (76), toolguides (86), mission_step_contracts (115), glossary_packs (124). Extending resolver.py for these 6 is copy-paste of the existing property pattern — no new PackContext field, no new raw-service property, no activation-engine change.

R3 — The "boundary ratchet" comment is a red herring for this migration

git log -p -S 'boundary ratchet' traces the tasks_status_cmd.py:712,823 comments to commit 873832aa1. The stated fear — a direct doctrine import tripping the boundary ratchet — is checked by tests/architectural/test_runtime_charter_doctrine_boundary.py, which only scans module-level from doctrine.* imports (lines 98-102 of that test explicitly exclude lazy/function-local imports). Both the existing AgentProfileRepository import and the factory's internal DoctrineService import are function-local at these sites, so routing through the factory does not trip this gate — the comment's named concern does not reappear. The real, previously-unmeasured risk is construction cost (the factory adds PackContext.from_config + resolve_org_roots + a wrapper vs. today's bare AgentProfileRepository()), covered by NFR-005's latency measurement, not an architectural blocker.

R4 — _doctrine_collect.py's 4 diagnostic sites need the factory's unfiltered mode, not the filtered path

_collect_profile_health, _collect_glossary_pack_health, _collect_doctrine_collisions, and _build_selection_block all deliberately need the unfiltered, all-layer view — activation-aware filtering would silently narrow doctor/health output for deactivated packs, which is exactly the anti-pattern the spec's Acceptance Scenario 2 (User Story 1) warns against. Fix: wrap with charter.resolver.DoctrineService(inner, pack_context=None) — same class, explicit unfiltered construction, satisfying C-001 (one factory) without regressing diagnostic completeness.

R5 — projection.py:84 and runtime_bridge_io.py:576 need capabilities the filtered wrapper doesn't expose

projection.py:84 (default_profile_repository) needs register_overlay() (mutation) and get_ancestors() (lineage) — neither exists on the filtered dict charter.resolver.DoctrineService.agent_profiles returns. runtime_bridge_io.py:576 needs repo.resolve_profile(profile_id) (lineage composition), also absent from the filtered dict. Both currently reach around the gate via svc._inner.agent_profiles (already used at registry.py:64) — a private-attribute reach-around that works but isn't a real public contract. Design decision: add one new public accessor to charter.resolver.DoctrineService for "the mutable/lineage- capable repository, still activation-aware for read paths" before migrating these two call sites, so the mission doesn't trade one reach-around for a proliferation of _inner accesses at every migrated site.

R6 — FR-003's tier functions stay in doctrine/resolver.py; only the entry point moves

doctrine/resolver.py::_resolve_asset/resolve_mission are pure filesystem-tier functions, unrelated to the DoctrineService object the factory wraps. An existing correct precedent already solves this exact shape: src/specify_cli/runtime/resolver.py reimplements tiers 1-4 itself (pure .kittify/~/.kittify filesystem checks) and routes only tier 5 (package-default) through charter.template_resolver.CharterTemplateResolver — its own comment states the intent: "Keep this call routed through charter so runtime never binds directly to doctrine's repository shape." (Landed under charter-mediated-doctrine-selection-01KRTZCA, WP07.) Design decision: add resolution methods to charter.resolver.DoctrineService that internally call doctrine.resolver.resolve_command/resolve_template/resolve_mission (legal — charter → doctrine is the sanctioned direction); CharterTemplateResolver becomes a thin delegating shim or is retired in favour of direct factory use by its one real caller. Two additional in-scope importers were found during this research and folded into FR-003: src/charter/resolution.py (facade re-export) and src/charter/context_renderers/ template_include.py (lazy ResolutionTier import) — both already inside src/charter/** but still bypassing the new factory methods.

Explicitly out of scope, noted as debt: specify_cli/runtime/resolver.py's own tier-1-4 reimplementation is a second, parallel filesystem-tier implementation (not a doctrine.resolver import, so not a literal bypass) with already-observed semantic drift from doctrine/resolver.py (different exception handling: catches only FileNotFoundError vs. doctrine.resolver's (FileNotFoundError, ImportError)). Flagged for a future mission; not folded in here (would expand IC-02's blast radius well beyond the named FR-003 anchor).

Post-Tasks Squad Findings

A second 4-lens adversarial squad (reviewer-renata, debugger-debbie, paula-patterns, python-pedro) reviewed the 10 generated WP prompts before locking finalize-tasks. All four returned READY WITH FIXES or NOT READY pending fixes; every finding below was folded into the WP prompts and tasks.md (this section records what was found, not a duplicate of the WP text):

FindingLensSeverityFolded into
WP02/WP03/WP04's dependencies: [] frontmatter never declared WP01, despite prose saying so everywhere — found INDEPENDENTLY by 3 of 4 delegatesreviewer-renata, paula-patterns, python-pedroCRITICALWP02/03/04 frontmatter fixed
WP01 (accessor) and former WP07 (6 properties) both edited src/charter/resolver.py; the 3-way split (with WP05) forced an awkward, benefit-free serializationpaula-patternsHIGHWP01+WP07 merged
Gate 5 (._inner) and Gate 4 (hardcoded paths) each only ever guarded one WP's own surface; a separate WP for them added dependency edges for no benefitpaula-patternsHIGHGate 5 → WP04, Gate 4 → WP06
Accessor method name never pinned ("e.g. agent_profile_repository") — three dependent WPs could each invent something differentreviewer-renata, python-pedroCRITICAL (compounding)Pinned exact name in WP01
Accessor method-list wrong: get_ancestors() unused; projection.py needs only register_overlay(); registry.py/org_profiles.py need get_provenance(), named nowheredebugger-debbieMEDIUMCorrected in WP01/WP02/WP04
_doctrine_collect.py line citations drifted +2 (193/283/420/828, not 191/281/418/826) after a later commit inserted 2 linesdebugger-debbieMEDIUMCorrected in WP03/WP09
WP05's core premise was false: specify_cli/runtime/resolver.py never imported doctrine.resolver — it imports the charter.resolution facade and charter.template_resolver, both already inside src/charter/**debugger-debbieHIGHWP05 reframed as entry-point consolidation, not bypass removal
WP05's suggested new method names collided with CharterTemplateResolver's existing resolve_command_template/resolve_content_template (different signatures)debugger-debbieLOWWP05 requires distinct names
Gate 3 (doctrine.resolver import) is already green today — proves nothing about a WP05 closure, since no such violation existed outside src/charter/**debugger-debbieHIGHWP09 T039 reframed as forward-looking guard only
Gate 5's naive ._inner-anywhere scan would false-positive on unrelated ._inner attributes in auth/transport.py/events/decision_log.pydebugger-debbieHIGHWP04's gate scoped to doctrine-service-typed receivers
resolver.py:402-413 cited as lineage-traversal precedent is actually an isinstance(dict) compat fallback — weak/wrong precedentdebugger-debbieMEDIUMSoftened in WP01/WP02
WP07's "remove from __getattr__ passthrough" step was wrong — __getattr__ is a generic catch-all a new @property shadows automatically; nothing to editdebugger-debbieMEDIUMStep removed from WP01
profile_resolution.py:81's _default_agent_profile_repository() is a zero-arg module-level cache with no repo_root — WP02's original T010 ("replace with the gated property") was a type mismatch, not implementablereviewer-renataHIGHWP02 T010 reframed as confirm-and-document, not migrate
NFR-005's perf DoD (a single committed p95 constant) is author-written and unfalsifiable; cross-machine comparison invalidreviewer-renataMEDIUMWP02 requires raw timing series, same session
FR-007's composite-key exclusions and function-local self-mutation requirement were correctly specified, but WP10's "post a GitHub comment" DoD had no non-fakeable evidence requirementreviewer-renataMEDIUMWP10 requires pasted gh issue view --comments output
A post-tasks sweep for additional missions-root hardcodes found 3 more root-relative constructions (kernel/paths.py, template/manager.py, list_cmd.py) beyond WP06's 2 named sitesreviewer-renataLOWNamed as an explicit, untouched residual in WP06 (citations not independently re-verified)

Not folded in (explicitly deferred to implementation-time judgment, per the squad's own concession that these are appropriately WP-time decisions): the exact chosen names for WP05's new factory methods beyond "must not collide"; whether CharterTemplateResolver becomes a thin shim or is retired outright (WP05's T020 leaves this as an implementer choice, justified in the Activity Log).

Post-Plan Squad Findings

A 4-lens adversarial squad (architect-alphonso, reviewer-renata, debugger-debbie, planner-priti — each profile-loaded, each independently reading spec.md/plan.md/research.md and, for the highest-stakes claims, the actual code and git history) reviewed the plan before /spec-kitty.tasks. All four returned READY WITH FIXES; every HIGH-severity finding was independently verified and folded into spec.md/plan.md/ data-model.md/contracts/quickstart.md (this section records what was found and where it landed):

FindingLensVerified howFolded into
3 more raw DoctrineService(...) sites (org_layer.py:244,275, generate.py:56), one with a fail-open except ImportError: pass bugdebugger-debbieRead the actual filesFR-002, FR-008
._inner.agent_profiles reach-around at registry.py:64 and org_profiles.py:117 defeats every gatereviewer-renataRead the actual filesFR-010 (new), NFR-001
NFR-001's text-grep gate can't distinguish the sanctioned pack_context=None wrapper from the forbidden raw class (both contain the substring DoctrineService()reviewer-renataRead spec.md's own FR-002 vs NFR-001 side by sideNFR-001 (qualname resolution, not text match)
IC-01 (builder unification, FR-008) scheduled before IC-04 (FR-005's 6 new properties) means FR-008's "assert identical output across all 9 properties" can't be written yetarchitect-alphonsoRead plan.md's IC-01/IC-04 dependency chainFR-008, plan.md IC-01 (scoped to 3 kinds now, extended at IC-04)
charter/resolution.py and template_include.py are type-only imports, not resolution-call bypasses; a real third tier surface (doctrine.template_catalog, 5 importers) was missedarchitect-alphonsoRead both files' actual import statements and usageFR-003 (corrected), plan.md IC-02
runtime/home.py's retarget onto MissionTemplateRepository (doctrine-layer) creates the same runtime→doctrine boundary tension #2986 already tracksarchitect-alphonsoRead test_runtime_charter_doctrine_boundary.py's scan scope + home.py's existing import shapeFR-004 (named as an explicit residual risk, not silently different)
builtin_missions_root() was already a promoted authority (WP06/#2668) — FR-004 needed to make it a delegate, not a second authorityarchitect-alphonsoFound the prior-promotion comments in action_grain.py/mission_type_profiles.pyFR-004
R5's lineage/mutation accessor left two semantic questions (mutation-leak-through-filter; lineage-crosses-deactivated-parent) unansweredarchitect-alphonsoRead resolver.py:402-413's existing precedentcontracts/charter-doctrine-service-contract.md (pinned)
Bare-project / mission-type regression assertions were existence/subset checks, fakeable by a partial leakreviewer-renataRead the contract files' assertion languagecontracts/*.md, data-model.md (equality/set-equality required)
Self-mutation proofs unconstrained to function-local scope would repeat R3's exact vacuity lessonreviewer-renataCross-checked against R3's own findingFR-007, NFR-003
FR-007's "excluding by name" had no defined shape; quickstart used whole-file grep -vreviewer-renataRead quickstart.md's actual commandsFR-007 (composite-key requirement), quickstart.md
NFR-005's baseline is never scheduled to be captured — "within 10%" is unfalsifiable without onereviewer-renata + architect-alphonso (convergent)Read plan.md's IC schedule for a baseline step (none existed)NFR-005, plan.md IC-00 (new)
quickstart.md's own verification commands already produce false positives today (before any change lands)debugger-debbieRan the commandsquickstart.md
FR-009 (deferred issues) only committed to PR-description prose; precedent mission already carries issue-matrix.json rows for 2 of these exact issuesplanner-pritiRead the precedent mission's actual issue-matrix.jsonFR-011 (renumbered, strengthened)

Not folded in (explicitly deferred to tasks-phase WP acceptance criteria, per reviewer-renata's own concession that these are appropriately WP-time decisions, not plan-blocking): the exact method names for FR-003's new factory methods; the exact test file names throughout (left as "assigned at tasks time" in quickstart.md); architect-alphonso's MEDIUM finding on whether mission-type's separate-repository gating constitutes a "two doors" seam worth a wording amendment to SC-004 rather than a design change — SC-004 already states "real 10/10 coverage across two repositories, not a mechanical 9/10 shortcut," which the squad's own alternative resolution (amend the wording rather than redesign) is satisfied by.

R7 — profile_resolution.py:81's repo_root is None branch is a genuine bootstrap case

The module's repo_root is None branch is the process-wide cached built-in-only fast path used when there is no repo context to build a factory instance from — the bootstrap/circularity case the spec's Edge Cases section already anticipated. It is not a site this mission routes through the factory; it is the legitimate absence-of-context case C-002 already carves out by design (surfaced, not silently allowlisted).