Mission Specification: Charter directive-resolution hardening

Mission: charter-directive-resolution-hardening-01M29022 Issues: #4239 (memoize resolver scans), #4240 (unresolvable-token diagnostic) — epic #2519 Origin: deferred robustness items from the #4194/#4185 landing.

Intent Summary

Two internal robustness improvements to the charter directive resolution path, neither of which changes what any resolution returns:

1. Performance (#4239): collapse repeated per-stem filesystem globbing during a single directive resolution so each doctrine layer is walked once, not once per candidate stem. 2. Observability (#4240): when the delivered directives service best-effort normalizes a fully unresolvable activation token, emit a per-token warning so a stale or mistyped activated_directives entry surfaces instead of resolving silently.

Primary actor: the operator/agent host that runs spec-kitty charter generate / interview / activate (and the delivered DoctrineService.directives). Trigger: directive resolution over an org/project doctrine layout. Desired outcome: same resolution result, less repeated I/O, and a visible signal on an unresolved token. Invariant that must always hold: the single overlay-precedence authority (_iter_artifact_paths high→low order) remains the sole ordering, and no resolution outcome changes.

Discovery was minimized per operator direction ("scale to the small work"); the issues and steer are a complete prescriptive brief. Assumptions are recorded below.

User Scenarios & Testing (mandatory)

User Story 1 - Directive resolution avoids redundant filesystem scans (Priority: P1)

An operator activates several org-pack directives and runs charter compile / interview. Resolution reads each doctrine layer's directory once per resolution pass rather than re-globbing every layer once per activated stem. The resolved directive set is identical to before.

Acceptance: for a fixture with D directives across L layers, a single resolution pass performs at most one filesystem scan per layer (asserted via a counted scan seam); the resolved result is byte-for-byte identical to the pre-change result.

User Story 2 - A stale activation entry is no longer silent (Priority: P1)

An operator's activated_directives contains a token that names no real directive (a typo, or a directive removed from the pack). Today the delivered service best-effort normalizes it and may co-activate an unrelated directive with no signal. After this mission, the service emits a per-token warning naming the token and the normalized form it fell back to, so the operator can spot and fix the stale entry.

Acceptance: a fully-unresolvable activated directive token produces a WARNING that names the token and its normalized form; a token that resolves (by filename stem, exact declared id, or a known catalog id) produces no warning; the set of delivered directives is unchanged in both cases.

Edge Cases

beyond the single-pass minimum.

memoization must not turn an absent layer into a spurious hit.

a later pass must observe the new content (mtime-correct invalidation, or a cache scoped so a new pass cannot see stale content).

  • A token that resolves normally by stem or exact id: no warning, no extra scan work
  • A layer directory that does not exist on disk: scanned/absent handling is unchanged;
  • Files changed on disk between two resolution passes (different service instances):

Requirements (mandatory)

Functional Requirements

IDRequirementStatus
FR-001Directive resolution in kind_vocabulary memoizes each doctrine layer's artifact-path scan (and/or the stem→id map) so a single resolution pass walks each layer's filesystem at most once, instead of re-globbing per candidate stem.Proposed
FR-002The memoization invalidates correctly when a scanned path changes: a resolution performed after an on-disk change observes the new content (keyed on (path, mtime), or scoped so a fresh resolution cannot reuse stale content).Proposed
FR-003The delivered resolver.directives property emits a per-token WARNING when it falls back to normalize_directive_id for a fully unresolvable activation token, naming both the raw token and the normalized form it used.Proposed
FR-004The warning of FR-003 fires only on the fully-unresolvable fallback path; a token that resolves via filename stem, exact declared id, or an existing catalog id emits nothing.Proposed

Non-Functional Requirements

IDRequirementThreshold / MeasureStatus
NFR-001Reduced filesystem work per resolution pass.≤ 1 layer scan per doctrine layer per single directive resolution pass (asserted by a counted scan seam in a test).Proposed
NFR-002No resolution-outcome change.The full tests/charter/ suite passes unchanged (2850+ passed at mission start); resolved directive sets are identical pre/post.Proposed
NFR-003Code quality.New code passes ruff/ruff format/mypy with zero issues; per-function cyclomatic complexity ≤ 15; every new branch/helper has a focused test in the same commit.Proposed

Constraints

IDConstraintStatus
C-001Resolution behavior/outcomes MUST NOT change — this mission is perf + observability only.Active
C-002The single overlay-precedence authority (_iter_artifact_paths high→low order) remains the sole ordering; memoization MUST NOT introduce a second ordering or precedence path.Active
C-003The FR-003 warning MUST NOT change the resolution result (it is a signal, not a gate); the best-effort legacy-compat fallback is preserved.Active

Key Entities

  • Directive activation token — an entry in activated_directives (a filename stem, an exact declared id, or a stale/typo'd value).
  • Doctrine layer scan — the per-layer artifact-path enumeration produced by _iter_artifact_paths (built-in / org packs / project overlay), the unit being memoized.

Success Criteria (mandatory)

Measurable Outcomes

  • A single directive resolution pass over an N-layer doctrine layout performs at most N filesystem layer-scans (down from up-to N×stems), verified by a scan-count test.
  • An operator with a stale/mistyped directive activation entry sees a warning naming the token on the next resolution, where previously they saw nothing.
  • Zero change to resolved directive sets: the existing tests/charter/ suite passes unchanged, plus new tests pinning the scan-count and the warning-only-on-unresolved behavior.