Mission Specification: Close four defects — dossier guard widening, CLI re-export trim, analyze commitlint cleanup, and SK-63 path-relativization

Mission Branch: fix/dossier-guard-reexport-analyze-cleanup-3676 Created: 2026-08-22 Status: Draft Input: GitHub issues #3676, #3677, #3678, plus ledger entry SK-63's secondary (path-relativization) defect — folded per operator decision D3. All four are grounded, already-diagnosed defects in spec-kitty's own tooling (this is a spec-kitty-on-spec-kitty mission); this is a bugfix-class mission, not exploratory research.


Clarifications / Decision Record

This section is binding and takes precedence over any ambiguity in the FR/AC tables below. It records operator decisions, charter-clause resolutions, and points where grounding this spec against the live checkout corrected or sharpened an assumption in the mission brief.

Operator decisions

  • D1 — No deferred work leaves this mission (narrowed by operator ruling — see the corrected "§486 Pre-existing Failure Reporting Rule vs D1" entry below). Adjacent work encountered while grounding this spec is folded in; anything that genuinely cannot be folded is recorded here as an explicit operator-escalation candidate, never filed as a new GitHub issue and never silently left as a "follow-up." This escalate-not-file default governs adjacent work the charter is silent on; it does not govern pre-existing test failures outside #3284's reported set, where the corrected precedence order (charter > operator standing orders > CLAUDE.md) makes §486 charter-compelled instead (full disposition below). D1's blanket phrasing also does not override §106's per-change deferral judgment wherever §106 genuinely supports deferring something and the rationale is recorded as §106 requires. No such escalation was found while authoring this spec — the four defects below are the complete, disjoint, in-scope set. If implementation surfaces a fifth item, it is folded per this same rule, filed per §486 if it is a pre-existing failure outside #3284's set, or escalated here otherwise — never deferred silently.
  • D2 — Stacked on PR #3672. This mission's target branch (fix/dossier-guard-reexport-analyze-cleanup-3676) descends from refactor/dossier-emitters-canonical-only-1058 (PR #3672, open against main, verified open at spec time via gh pr view 3672). This mission's PR targets main and merges after #3672 lands. The current checkout's relationship to origin/main is expected, not a defect to fix.
  • D3 — SK-63 scope is bounded. Folded: the #3678 commit-subject fix AND the governing-root-relative input-artifact-path fix (repo_root for spec.md/plan.md/tasks.md, canonical_root for charter — see Grounding Correction 3), both confined to src/specify_cli/cli/commands/agent/mission_record_analysis.py (commit message) and src/specify_cli/analysis_report.py (path recording — see the Grounding Correction on the exact call site below; the mission brief named mission_record_analysis.py for both, but the four absolute-path writes actually live in analysis_report.py's _artifact_hash_entry / collect_input_artifact_hashes, which mission_record_analysis.py calls into via write_analysis_report). NOT folded: SK-63's other half (bounding the record-analysis retry/backoff loop that can hang after the artifact is written, and adding a committed: field to the analysis report) — that reaches into the sync/telemetry layer, a different subsystem from the string-formatting and path-recording fix here. This remains open in the ledger (the workspace-root SPEC-KITTY-LEDGER.md, sibling to this checkout, entry SK-63) and out of scope for this mission. Rationale (§106): the path-relativization fix is directly connected, proportional, and in the same function call chain as #3678's fix (both are in the commit/write path mission_record_analysis.py exercises on every record-analysis invocation); the retry-loop bound is a different subsystem (sync/telemetry) with a different failure mode (a hang, not a public-repo data leak) and would grow the file set beyond what smallest-viable-diff + locality-of-change license here.

Charter-clause resolutions

  • §486 Pre-existing Failure Reporting Rule vs D1 — corrected precedence (operator ruling, mid-mission). The operator has ruled explicitly: "the charter has precedence over my rules." Corrected precedence order for this mission: charter > operator standing orders > CLAUDE.md. Applied to §486: §486 requires opening a GitHub issue for pre-existing test failures before treating them as accepted baseline, and §486 now binds ABSOLUTELY. Issue #3284 ("main full suite has 23 untracked failures and 2 errors after bootstrap prewarm", confirmed OPEN via gh issue view 3284) discharges §486's MUST only for failures genuinely inside its already-reported set — cite #3284, file nothing new for those. Anything found outside that set MUST get a new GitHub issue filed per §486 (the issue must include the command run, the relevant failure summary, and the reasoning for believing the failure is pre-existing rather than introduced by this mission) — this is now charter-compelled, not an operator-escalation candidate. The spec's ORIGINAL disposition — "that is an operator escalation per D1 ..., never a new issue and never a silent skip" — is WITHDRAWN for this specific §486 case. §106 legitimises deferral as a per-change judgment: it directs recording "what was folded in and what was deferred, and why," and D1's blanket "no deferred work leaves this mission" cannot override that — wherever §106's own change-scope reconciliation genuinely supports deferring something, deferral is available, provided the rationale is recorded as §106 requires. D1 still governs wherever the charter is silent: the charter nowhere compels filing follow-up issues for ADJACENT work (as opposed to pre-existing test failures specifically), so the operator's fold-in-rather-than-file preference remains fully operative there. This ruling narrows D1; it does not delete it. Note (non-retroactive scope): this correction changes the disposition RULE going forward — it does not retroactively change any already-written FR/AC/scope content in this spec. Zero pre-existing failures outside #3284's set were found during authoring or any of the three prior review rounds (re-verified against the Grounding Corrections and the review trail for this round: the only failure-adjacent references anywhere in this spec are to test_charter_hash_resolves_canonical_root_from_worktree and its four sibling assertions in Grounding Correction 3, which are existing, currently-GREEN tests exercising #1823's cross-root behavior — not failures, and not outside #3284's set — so there was nothing for the withdrawn disposition to have acted on). No such out-of-set failure was found while authoring this spec.
  • §106 change-scope reconciliation. Beyond the two dossier files (tests/architectural/test_dossier_emitter_positional_guard.py, src/specify_cli/dossier/__init__.py), this mission also touches src/specify_cli/cli/commands/agent/mission_record_analysis.py (commit-subject fix, #3678), src/specify_cli/analysis_report.py (governing-root-relative path recording — repo_root for spec.md/plan.md/tasks.md, canonical_root for charter, SK-63), and the two existing test files whose charter-path assertions Grounding Correction 3 / FR-007 require updating to match the new canonical_root-relative value: tests/specify_cli/test_analysis_report.py and tests/specify_cli/test_analysis_report_charter_yaml_staleness.py. commitlint.config.cjs is not touched by this mission — Grounding Correction 4 / C-004 resolve this definitively in favor of a conforming commit subject, not an ignore-list widening. Justification: #3678's fix is proportional and directly connected to a single named defect (a broken commit-subject format); SK-63's fold is justified in D3 above; the two test-file edits are the direct, necessary consequence of FR-007's path-relativization change, traced exhaustively (all five affected assertions) in Grounding Correction 3. A seventh file, tests/specify_cli/cli/commands/agent/test_mission_record_analysis.py, is touched for FR-006's RED-first commit-message-construction fixture (T010): it is the sole existing test module for mission_record_analysis.py, disjoint from WP01's two test_analysis_report*.py files, and is the direct, necessary landing spot for a fixture proving the CURRENT non-conforming commit subject fails commitlint before the fix lands (added during a post-tasks adversarial-review fix pass, TASKS-DECOMP-001, after the tasking review found the original two-option "Files" guidance either collided with WP01's exclusively-owned test files or fell outside the pre-fix file scope). No other file is touched, beyond the seven files named above. Tracker references: #3676, #3677, #3678, ledger SK-63, ledger SK-64 (see Grounding Correction 4).
  • §581 __all__ convention (C-007). Binds src/charter/ + src/kernel/ for the MUST-declare rule; tests/architectural/test_no_dead_symbols.py walks __all__ repo-wide in practice, including src/specify_cli/dossier/__init__.py. Empirically re-run against the live checkout at spec time (pytest tests/architectural/test_no_dead_symbols.py -q26 passed, 2026-08-22): the gate is currently green and does not flag the seven #3677 type names as dead. See the Grounding Correction below for the precise (traced, not assumed) mechanism and why the gate will still be green — because it will have nothing to check — after their removal.
  • Baseline (#3284). main carries ~23 known-red tests + 2 errors (confirmed via the issue title itself). No red in this mission's touched-test-surface may be attributed to the mission without first checking it against #3284's set — "the suite is red" is never a finding on its own.

Grounding corrections (where reading the live checkout sharpened or corrected the mission brief)

1. #3676 reachability claim, precision note. The mission brief cites src/specify_cli/sync/dossier_pipeline.py and src/specify_cli/dossier/drift_detector.py as "real callers" that make the attribute-chain call shape "reachable... a real, live shape." Verified: both files actually import the emitters via from specify_cli.dossier.events import emit_X (the submodule directly), not via the package-level from specify_cli.dossier import emit_X, and both call them as bare Name calls — a shape the existing (pre-widening) guard already covers. Neither current caller exercises an attribute-chain call today. The underlying reachability claim still holds structurally — because specify_cli/dossier/__init__.py re-exports the four emit_ names, import specify_cli.dossier as dossier; dossier.emit_artifact_indexed(...) is a valid, real Python call shape available to any future caller — but it is a potential shape, not a currently-exercised one. This does not change the fix (the guard must still widen to catch it), only the evidentiary framing. 2. #3677 dead-symbol-gate mechanism, traced precisely. The mission brief attributes the gate's blind spot to src/specify_cli/dossier/events.py's own from spec_kitty_events import (ArtifactIdentity, ...) line. Tracing _symbol_has_caller()'s three rescue rules (tests/architectural/test_no_dead_symbols.py:2432-2471) against the actual import graph shows the real mechanism is one hop further out: it is src/specify_cli/dossier/__init__.py's own from .events import (ArtifactIdentity, ContentHashRef, ...) (lines 28-40) that populates the gate's internal per_symbol["specify_cli.dossier.events"] set with these seven names. Rule 3 ("re-export via any submodule") then reads that same set back to "rescue" specify_cli.dossier.__all__'s inclusion of those same names — a fully self-referential loop with zero external caller anywhere in it. (events.py's own import from spec_kitty_events is unrelated to this rescue; its source module, spec_kitty_events, is not a submodule of specify_cli.dossier and never enters rule 3's candidate set.) This does not change the fix or the "gate gives zero signal" conclusion — it is the same blind spot the brief describes — but the precise mechanism is corrected here per the instruction to verify rather than restate. 3. FR-007/NFR-002's relativization contract must not break the existing, tested canonical-root charter-hashing behavior (#1823). Grounding this against the live checkout: analysis_report.py's _charter_path(repo_root) deliberately resolves the charter through resolve_canonical_repo_root(repo_root) (not repo_root itself) specifically so that a linked worktree's analysis report hashes the MAIN checkout's charter, not a worktree-local copy — this is the fix for #1823, and it means the charter's resolved path can legitimately live OUTSIDE the passed-in repo_root. tests/specify_cli/test_analysis_report.py's test_charter_hash_resolves_canonical_root_from_worktree (an existing, currently-green test) asserts exactly this: it builds a main checkout with a charter, adds a wt worktree with a different* charter, calls collect_input_artifact_hashes(feature_dir, worktree), and asserts the returned charter path/hash are the MAIN checkout's — genuinely outside worktree (the passed repo_root). A literal, uniform reading of "every input_artifacts path is relative to repo_root, raise if not possible" (as FR-007/NFR-002 originally read, before this correction) would make this legitimate cross-root case indistinguishable from the SK-63 leak this mission is fixing, and — taken literally — would require raising on every worktree-based analyze run, breaking this test. Correction: the charter entry relativizes against its OWN governing root (the canonical_root _charter_path already resolves via resolve_canonical_repo_root), NOT the passed-in repo_root; the three hash-input artifacts (spec.md/plan.md/tasks.md) still relativize against repo_root as originally specified. test_charter_hash_resolves_canonical_root_from_worktree (tests/specify_cli/test_analysis_report.py:238) does need its path assertion updated as part of this mission's implementation. Its assert hashes["charter"]["path"] == str(charter_file.resolve()) line asserts directly against collect_input_artifact_hashes's return value — the exact value FR-007 changes — so once the charter entry becomes canonical_root-relative instead of the bare absolute str(path), this line will fail as written and MUST be updated to assert the canonical_root-relative path string instead (e.g. str(charter_file.resolve().relative_to(canonical_root)), or the equivalent the implementation produces). This is an explicit item the implementing work package must do — it is not incidental fallout to discover later.

This is not the only such assertion. A full sweep of the live checkout (grep -rn '\["charter"\]\["path"\]' tests/, re-verified at round-3 fix time) confirms five existing assertions across two test files compare hashes["charter"]["path"] (or input_artifacts["charter"]["path"]) against an absolute, resolved filesystem path — every one of them needs the identical correction:

  • tests/specify_cli/test_analysis_report.py:238 (test_charter_hash_resolves_canonical_root_from_worktree) — assert hashes["charter"]["path"] == str(charter_file.resolve())
  • tests/specify_cli/test_analysis_report.py:260 (test_charter_hash_falls_back_to_repo_root_outside_git) — assert hashes["charter"]["path"] == str(charter_file)
  • tests/specify_cli/test_analysis_report_charter_yaml_staleness.py:52 (test_analysis_report_staleness_hashes_charter_yaml_when_md_absent) — assert hashes["charter"]["path"] == str(charter_yaml_path.resolve())
  • tests/specify_cli/test_analysis_report_charter_yaml_staleness.py:94 (test_analysis_report_staleness_no_regression_both_files_present) — assert hashes["charter"]["path"] == str(charter_yaml_path.resolve())
  • tests/specify_cli/test_analysis_report_charter_yaml_staleness.py:137 (test_analysis_report_staleness_hashes_charter_md_when_yaml_absent) — assert hashes["charter"]["path"] == str(charter_md_path.resolve())

Each of these five MUST be updated to assert a canonical_root-relative path string instead of the bare absolute path, for the same reason as the :238 case above — it is an explicit item the implementing work package must do for all five sites, not incidental fallout to discover later. The test's OTHER assertions in each of these tests (hashes["charter"]["sha256"] == _sha256_file(charter_file), the sha256 mismatch against the worktree-local charter, "charter" in hashes, result.input_artifacts["charter"]["sha256"] is not None, and the various freshness/report-success checks) are unaffected by this change and stay as they are; more importantly, each test's underlying resolution behavior — that the charter path correctly resolves to its governing root's charter file (canonical_root for the worktree case; the passed repo_root outside git; the seeded charter.yaml/charter.md path in the two staleness tests) — is unaffected and must continue to hold. Separately: check_analysis_report_current (analysis_report.py; calls collect_input_artifact_hashes internally) and its caller _require_current_analysis_report (cli/commands/agent/workflow.py) have an established contract of never raising — every code path returns/consumes a typed AnalysisFreshness result, even on error. This contract MUST stay intact once relativization can fail against either governing root; see FR-007/NFR-002 below. 4. #3678 fix-direction, resolved in favor of a conforming commit subject, not an ignore-list widening. The mission brief offers two options and asks this spec to pick the smaller/more-correct one, tentatively leaning toward widening commitlint.config.cjs's ignore regex. Two things found while grounding this: (a) the brief's own illustrative alternative message, f"Add analysis for mission {slug}", does not actually satisfy the current ignore regex /^(Add|Update) (meta|spec|tasks|plan) for (feature|mission) /analysis is not in the (meta|spec|tasks|plan) alternation, confirmed by reading commitlint.config.cjs directly — so a message-only change requires the regex to also widen, or a different message shape entirely; (b) ledger entry SK-64 (the workspace-root SPEC-KITTY-LEDGER.md, sibling to this checkout) already investigated this exact defect first-hand on a related mission, measured it (2 of 52 commits fail commitlint, and both are that same record-analysis message), and states its own fix-direction preference order explicitly: "(1) have record-analysis emit a conforming subject... fixes the cause and needs no ignore-list growth; or (2) extend the ignore regex." This spec adopts SK-64's option (1): give the record-analysis commit a real conventional-commit subject (type(scope): subject, e.g. docs(mission): record analysis report for mission <slug> — exact wording finalized at implementation time, constrained by FR-006 below) rather than widening commitlint.config.cjs. Reasoning: it is the smaller diff (one file touched, not two), it fixes the actual cause instead of growing a repo-wide special-case allowlist that every other commit's linting also depends on, and it is consistent with this repo's own established practice — other tool-authored analyze/review commits observed in this checkout's own git log already use real types (e.g. docs(review): commit pre-merge verification and fresh sweep for implementation diff). commitlint.config.cjs is therefore not touched by this mission. This decision explicitly diverges from the mission brief's lean; recorded here per D1's spirit of surfacing rather than silently picking a side.


User Scenarios & Testing (mandatory)

User Story 1 — Analyze-phase commits pass commitlint and never leak a local username (Priority: P1)

As a mission author running record-analysis (every mission's analyze phase, including this one), I want the commit record-analysis produces to (a) satisfy commitlint without relying on a growing ignore-list, and (b) never write my checkout's absolute filesystem path — which contains my OS username — into the committed analysis-report.md, so that every mission's analyze phase stops turning commitlint red and this public repo never permanently publishes a contributor's local directory layout.

Why this priority: #3678 breaks commitlint on every mission that reaches analyze, including this one, later in its own lifecycle — it is a blocking, self-referential defect. SK-63's path leak is a live, public-repo confidentiality issue (charter: never write a username-bearing absolute path into a committed artifact) that shares the same call path.

Independent Test: Run record-analysis against a scaffolded mission, inspect the resulting commit subject with the repo's commitlint invocation (npx commitlint --from <parent> --to <this-commit> or equivalent), and inspect the generated analysis-report.md's input_artifacts frontmatter block for absolute paths. Both checks are independent of #3676/#3677 and can be verified without touching the dossier module at all.

Acceptance Scenarios:

1. Given a scaffolded mission with spec.md, plan.md, and tasks.md present, When spec-kitty agent mission record-analysis runs and produces its commit, Then the commit subject passes commitlint (type-enum, type-case, type-empty, subject-empty) without matching or requiring any change to the existing ignores regex in commitlint.config.cjs. 2. Given the same run, When the resulting analysis-report.md frontmatter's input_artifacts block is inspected, Then every recorded path value is relative to its governing root — repo_root for the three hash inputs (spec.md, plan.md, tasks.md) and canonical_root for the charter entry (the root _charter_path already resolves via resolve_canonical_repo_root; see Grounding Correction 3) — none is an absolute filesystem path, and none contains $HOME or any OS username segment. 3. Given a hypothetical checkout layout where a spec.md/plan.md/tasks.md input artifact's resolved path cannot be expressed relative to repo_root (e.g. a symlinked or foreign-root path) — genuinely hypothetical, with no known production trigger today — When write_analysis_report attempts to record that artifact's path, Then the function raises/reports the failure (consistent with the repo's silent-success-is-the-dominant-failure-mode standing concern) rather than silently falling back to writing the absolute path. This scenario does not apply to the charter entry: its cross-root resolution (worktree → main checkout, #1823) is real, tested, production behavior, not a hypothetical failure case (Grounding Correction 3) — the charter path only raises/reports if it cannot be expressed relative to its own governing root (canonical_root). 4. Given the commit-subject fix lands, When the existing best-effort commit_for_mission(...) call inside record-analysis (wrapped in contextlib.suppress(subprocess.CalledProcessError, OSError, RuntimeError, ValueError)) still fails for an unrelated reason (e.g. a protected target ref), Then that pre-existing best-effort/suppress behavior is unchanged by this mission — this mission fixes the subject format, not the surrounding commit-failure handling, which is out of scope (folds nothing new into that suppression). 5. Given an input artifact path that cannot be expressed relative to its governing root (per Acceptance Scenario 3), When check_analysis_report_current (analysis_report.py) — not write_analysis_report — is invoked, as _require_current_analysis_report (cli/commands/agent/workflow.py) does on every implement gate check, Then it does NOT raise; it returns a typed AnalysisFreshness(ok=False, reason=...) describing the relativization failure, preserving its existing established non-raising contract, and _require_current_analysis_report renders that reason through its existing error-reporting branches.


User Story 2 — The positional-call guard catches attribute-chain and aliased-import emitter calls (Priority: P1)

As a reviewer relying on tests/architectural/test_dossier_emitter_positional_guard.py to close the positional-argument defect class by construction, I want the guard to also flag dossier.emit_artifact_indexed(...)-style attribute-chain calls and from ...events import emit_artifact_indexed as ei aliased-import calls, so that the two gaps the guard's own docstring currently documents as deliberately-deferred design scope are actually closed, not merely written down as accepted risk.

Why this priority: the module's own docstring frames both gaps as a design boundary ("this guard does simple Name-based matching only... widening the detector to handle aliasing is explicitly deferred until a real aliased call site exists") — this mission's premise (from #3676) is that framing itself is the defect, and a gate that can be silently routed around by either call shape is not evidence of anything for those shapes.

Independent Test: Add two new positive-control fixtures (planted violations) to the existing test module — one exercising an attribute-chain call, one exercising an aliased-import call — following the exact fixture idiom test_detector_flags_planted_positional_call already uses (a throwaway tmp_path file, parsed via _find_positional_emitter_calls, asserting exactly one violation with the correct path/lineno/func_name). Both are independently runnable without touching mission_record_analysis.py, analysis_report.py, or specify_cli/dossier/__init__.py.

Acceptance Scenarios:

1. Given a planted fixture file containing dossier.emit_artifact_indexed("m", "k", "c", "p", "h", 1) (an attribute-chain call with six bare positional arguments, callee is ast.Attribute not ast.Name), When _find_positional_emitter_calls scans it, Then it reports exactly one violation identifying that call (matching path, correct lineno, func_name == "emit_artifact_indexed"). 2. Given a planted fixture file containing from ...dossier.events import emit_artifact_indexed as ei followed by ei("m", "k", "c", "p", "h", 1) (an aliased-import call — callee IS ast.Name, but .id == "ei", not one of the four guarded names), When _find_positional_emitter_calls scans it, Then it reports exactly one violation correctly attributed to emit_artifact_indexed (the alias's resolved target), not silently passed through as an unrecognized name. 3. Given the widened detector, When the existing four tests in the module (test_src_tree_has_no_positional_dossier_emitter_calls, test_detector_flags_planted_positional_call, test_detector_does_not_flag_keyword_only_call, test_detector_ignores_unrelated_same_name_free_function) are re-run, Then all four remain green — the widening introduces no new false positives against the real src/ tree or the existing negative-control fixtures. 4. Given the RED-first ATDD discipline (charter C-011), When the two new positive-control tests are run against the pre-widening detector (the _call_target_name shown in the mission brief that only handles bare ast.Name), Then both fail (RED) — proving the widened detector, not an unrelated change, is what makes them pass. 5. Given the widening lands, When the module's own docstring (lines 1-42, "What this guard deliberately does NOT cover") is reviewed, Then it no longer frames attribute-chain and aliased-import calls as deferred/out-of-scope — the docstring is updated to describe what the widened detector does cover and its remaining true boundary (still src/ only, still no full call-graph resolution beyond attribute-chain-final-name and single-level import-alias matching). Mechanically checked by SC-008: the old deferral phrases (explicitly deferred, ` none exist in src/ today `) no longer appear anywhere in the module's docstring.


User Story 3 — The CLI dossier namespace stops offering a second import path to seven external types (Priority: P2)

As a maintainer of specify_cli.dossier's public surface, I want the seven spec_kitty_events type re-exports (ArtifactIdentity, ContentHashRef, LocalNamespaceTuple, and the four MissionDossier*Payload types) removed from both the from .events import (...) statement and __all__ in src/specify_cli/dossier/__init__.py, so that there is exactly one canonical import path to each type (directly from spec_kitty_events, the charter's declared external contract package) instead of two.

Why this priority: charter's single-canonical-authority governing principle is violated by construction today, but the defect is inert (zero callers use the second path) rather than actively harmful, so it is P2 relative to the two P1 stories above.

Independent Test: grep for from specify_cli.dossier import sites naming any of the seven types, confirm zero matches before AND after the change (the change should be a no-op for every existing caller); separately, run tests/architectural/test_no_dead_symbols.py before and after and confirm it stays green both times (empirical gate-behavior proof, not an assumption).

Acceptance Scenarios:

1. Given the current src/specify_cli/dossier/__init__.py, When the seven type names are removed from its from .events import (...) statement and from __all__, Then the four emit_* function re-exports (emit_artifact_indexed, emit_artifact_missing, emit_snapshot_computed, emit_parity_drift_detected) remain untouched — imported and declared in __all__ exactly as before. 2. Given the change, When src/specify_cli/dossier/events.py is inspected, Then its own from spec_kitty_events import (ArtifactIdentity, ContentHashRef, ...) (used to construct its own payload objects) is unchanged — this mission does not touch events.py. 3. Given the change, When tests/dossier/test_events.py (the sole test-suite consumer of these seven names) is re-run unmodified, Then it still passes — it already imports the seven names directly from spec_kitty_events, never via specify_cli.dossier, so it is unaffected by their removal from the CLI re-export surface. 4. Given the change, When tests/architectural/test_no_dead_symbols.py is run, Then it stays green — the seven names are no longer declared in specify_cli.dossier.__all__ at all, so the gate has nothing to check for them (see Grounding Correction 2 for the precise reason this is true both before and after, not merely assumed).


Edge Cases

  • What happens when an attribute-chain call is nested more than one level deep (e.g. a.b.dossier.emit_artifact_indexed(...))? The widened detector matches on the final attribute name (.attr of the outermost ast.Attribute node) regardless of chain depth — this mirrors the existing bare-Name detector's acceptance of some false-positive risk from unrelated code sharing a guarded name (already documented and tested via test_detector_ignores_unrelated_same_name_free_function), extended to the attribute-chain shape.
  • What happens when an aliased import binds to a name that is itself later reassigned (e.g. ei = emit_artifact_indexed_alias; ei = some_other_function)? Out of scope — the detector performs syntactic import-alias resolution (matching an ast.ImportFrom alias to its original name within the same file), not full data-flow/reassignment tracking. This boundary must be stated explicitly in the widened docstring, not silently assumed.
  • What happens when an emitter is reached through dynamic/reflective dispatch (e.g. getattr(module, "emit_artifact_indexed")(...), a dispatch-dict table keyed by name, or a functools.partial-wrapped emitter)? Out of scope — the detector performs syntactic AST matching only (bare Name, attribute-chain-final-name, and single-level import-alias resolution), never data-flow analysis or runtime reflection tracking, so any call shape that resolves the callable indirectly at runtime is invisible to it. This is the same "no full call-graph resolution" boundary AC5 already states in general terms; this bullet names the specific dynamic-dispatch escape hatch explicitly, matching the style of the alias-reassignment bullet above.
  • What happens when record-analysis's best-effort commit (commit_for_mission) fails for a reason unrelated to the subject format (e.g. a protected target ref, a filesystem error)? Unchanged by this mission — the existing contextlib.suppress(...) behavior around that call is out of scope; this mission only changes what string is passed as message=.
  • What happens when a spec.md/plan.md/tasks.md input artifact genuinely cannot be expressed relative to repo_root (e.g. resolved via a symlink escaping the checkout)? This is a hypothetical scenario with no known production trigger today. Must raise/report, never silently fall back to writing the absolute path — silent success is this repo's documented dominant failure mode (#3133, #3212, #3282, #3336) and this defect (SK-63) exists specifically because a previous silent choice (always use str(path)) leaked local layout into a public artifact.
  • What happens when the charter entry's resolved path lives outside repo_root (a linked worktree's analysis report hashing the MAIN checkout's charter)? This is not a hypothetical edge case — it is real, tested, production behavior tied to the #1823 fix. The charter entry therefore relativizes against its own canonical_root, not repo_root, and only raises/reports if it cannot be expressed relative to that root (full per-test enumeration across all five affected assertion sites, and the #1823/resolve_canonical_repo_root rationale: Grounding Correction 3).
  • What happens to any external (non-spec-kitty) code that may currently do from specify_cli.dossier import ArtifactIdentity? None exists — verified via direct grep across src/ and tests/ in this checkout (the actual evidence for the "zero callers" claim; the dead-symbol gate is a separate empirical check and, per Grounding Correction 2, gives zero signal either way about this). If such an external caller exists outside this checkout (e.g. a downstream consumer package), it would break; this is accepted per #3677's explicit finding and the charter's single-canonical-authority principle taking precedence over an unverified/unused compatibility surface.

Requirements (mandatory)

Functional Requirements

IDTitleUser StoryPriorityStatus
FR-001Detect attribute-chain positional calls to guarded emittersAs a reviewer, I want the guard to flag module.emit_artifact_indexed(...)-style calls with positional args, so the attribute-chain gap #3676 documents is closed.HighOpen
FR-002Detect aliased-import positional calls to guarded emittersAs a reviewer, I want the guard to resolve from ... import emit_artifact_indexed as ei and flag ei(...) positional calls, so the aliased-import gap #3676 documents is closed.HighOpen
FR-003Update the guard module's docstring to stop framing the two gaps as deferred design scopeAs a future maintainer reading the guard, I want its own docstring to accurately describe what is and is not covered post-widening, so nobody re-reads the old deferral language as still current.MediumOpen
FR-004Add two RED-first positive-control fixtures proving the widened detector firesAs an implementer honoring charter C-011 (ATDD-first), I want one planted fixture per new call shape (attribute-chain, aliased-import), each RED on the pre-widening detector and GREEN after, so the widening has live proof rather than an unverified claim.HighOpen
FR-005Remove the seven spec_kitty_events type re-exports from specify_cli/dossier/__init__.pyAs a maintainer, I want ArtifactIdentity, ContentHashRef, LocalNamespaceTuple, and the four MissionDossierPayload types removed from both the from .events import (...) statement and __all__, while the four emit_ function re-exports stay, so there is exactly one canonical import path per type.MediumOpen
FR-006Give the record-analysis commit a conventional-commit-compliant subject with type pinned to docsAs a mission author, I want the commit_for_mission(..., message=...) call in mission_record_analysis.py to produce a subject of the shape docs(<scope>): <free-text subject>type MUST be docs, matching this repo's own established convention for tool-authored analyze/review commits (Grounding Correction 4's cited docs(review): ... example) — satisfying type-enum/type-case/type-empty/subject-empty without requiring a commitlint.config.cjs ignore-list change, so every mission's analyze phase (including this one) stops reding the commitlint gate. The exact scope token and free-text subject wording are left to implementation.HighOpen
FR-007Record record-analysis input-artifact paths relative to their governing root, not absoluteAs a maintainer of this public repository, I want collect_input_artifact_hashes / _artifact_hash_entry in analysis_report.py to record each input artifact's path field relative to its governing root instead of via bare str(path)repo_root for the three hash-input artifacts (spec.md, plan.md, tasks.md), and canonical_root (the root _charter_path already resolves via resolve_canonical_repo_root) for the charter entry specifically — so a committed analysis-report.md never publishes a contributor's local username or directory layout. Five existing test assertions compare hashes["charter"]["path"] against an absolute resolved path and need their path assertion updated accordingly as part of this requirement's implementation (five sites, enumerated with per-test rationale in Grounding Correction 3); each test's underlying cross-root/governing-root resolution behavior continues to hold unchanged. Must fail loudly (not silently fall back to absolute) if a path cannot be relativized against its governing root. check_analysis_report_current and its caller _require_current_analysis_report (cli/commands/agent/workflow.py) MUST continue to never raise on this failure — the new relativization error must be caught inside check_analysis_report_current and mapped to AnalysisFreshness(ok=False, reason=...), preserving its existing non-raising contract.HighOpen

Non-Functional Requirements

IDTitleRequirementCategoryPriorityStatus
NFR-001No absolute local paths in a committed public artifactA freshly generated analysis-report.md's input_artifacts block contains zero path values matching an absolute-path-under-$HOME pattern (e.g. /home/<user>/..., /Users/<user>/...) — measurable via `grep -E '"path":\s*"(/home\/Users)/'` returning zero matches.SecurityHigh
NFR-002No silent success on path-relativization failure, without breaking the freshness-check's non-raising contractIf an input artifact's path cannot be expressed relative to its governing root (repo_root for spec.md/plan.md/tasks.md; canonical_root for charter — see Grounding Correction 3), write_analysis_report's path-recording step raises or surfaces an explicit error — it must not silently write the absolute path as a fallback. check_analysis_report_current must translate that same failure into AnalysisFreshness(ok=False, reason=...) rather than raising, preserving the non-raising contract _require_current_analysis_report depends on. Measurable: a unit/integration test constructs this condition and asserts (a) write_analysis_report raises or surfaces an explicit error field, and (b) check_analysis_report_current returns a typed AnalysisFreshness(ok=False, ...) rather than raising.ReliabilityHighOpen
NFR-003Widened guard introduces zero new false positives against real src/ and existing negative controlsAll four pre-existing tests in test_dossier_emitter_positional_guard.py remain green after widening; the real src/ tree's clean-tree assertion (test_src_tree_has_no_positional_dossier_emitter_calls) continues to report zero violations. Measurable: pytest tests/architectural/test_dossier_emitter_positional_guard.py -q — all tests pass, 0 failures.CorrectnessHighOpen
NFR-004Dead-symbol gate stays green across the #3677 changepytest tests/architectural/test_no_dead_symbols.py -q passes both immediately before and immediately after the dossier/__init__.py edit (empirically re-run, per §581's binding-in-practice scope — not assumed from the pre-change baseline alone).ReliabilityMediumOpen

Constraints

IDTitleConstraintCategoryPriorityStatus
C-001Public-repo path-leak prohibitionNo absolute filesystem path containing a username may be written into any artifact this mission commits — including the spec/plan/tasks artifacts themselves, not only the code fix (this instruction is binding on the mission's own authored artifacts too).RegulatoryHighOpen
C-002Keep the four emit_* function re-exports in dossier/__init__.py untouched#3677's fix removes only the seven type names; the four emit_artifact_indexed/emit_artifact_missing/emit_snapshot_computed/emit_parity_drift_detected re-exports (and their real callers in sync/dossier_pipeline.py and dossier/drift_detector.py) are out of scope and must not be modified.TechnicalHighOpen
C-003Do not fold SK-63's retry-loop/committed:-field half into this missionPer D3, this mission fixes the commit-subject format and the path-relativization only; the retry/backoff hang and the missing committed: field remain open in the ledger (SK-63) as a distinct subsystem's defect.TechnicalMediumOpen
C-004commitlint.config.cjs is not modified by this missionPer Grounding Correction 4, the #3678 fix is a message-format change confined to mission_record_analysis.py; the shared commitlint ignore-list config is untouched.TechnicalMediumOpen
C-005No source/test edits during the spec phaseThis spec document (and, if authored, research.md/data-model.md) are the only artifacts this phase produces; src/ and tests/ are implementation-phase scope.TechnicalHighOpen

Key Entities (include if feature involves data)

  • PositionalCallViolation (existing, tests/architectural/test_dossier_emitter_positional_guard.py): one detected positional call to a guarded emitter (path, lineno, func_name). Unchanged by this mission — the widened detector still reports through this same type; only the set of ast.Call shapes that populate it grows.
  • AnalysisReportResult.input_artifacts (existing, src/specify_cli/analysis_report.py): a dict[str, dict[str, str | None]] mapping each hash-input name (spec.md, plan.md, tasks.md, charter) to {"path": ..., "sha256": ...}. This mission changes the semantics of the path value from an absolute filesystem path to a governing-root-relative path string — repo_root-relative for the spec.md/plan.md/tasks.md entries, canonical_root-relative for the charter entry (see Grounding Correction 3); the sha256 value and the dict's overall shape are unchanged.
  • specify_cli.dossier.__all__ (existing, src/specify_cli/dossier/__init__.py): the package's public-surface declaration. This mission shrinks it by seven entries (the type re-exports); the remaining twenty entries (module classes, hash helpers, manifest types, the four emit_* functions, snapshot helpers) are unchanged — verified against the live checkout: 27 entries in __all__ today, minus the 7 removed type names, leaves 20.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: Both new positive-control fixtures for #3676 (attribute-chain call, aliased-import call) fail (RED) against the pre-widening detector and pass (GREEN) against the post-widening detector — verified by running the two new tests against each commit in the WP's implementation lane per charter C-011.
  • SC-002: All four pre-existing tests in tests/architectural/test_dossier_emitter_positional_guard.py remain green after the widening — pytest tests/architectural/test_dossier_emitter_positional_guard.py -q reports 0 failures both before and after.
  • SC-003: grep -rn "from specify_cli.dossier import" src/ tests/ (filtered to the seven removed type names) returns zero matches both before and after the #3677 change — the change is a verified no-op for every existing caller.
  • SC-004: pytest tests/architectural/test_no_dead_symbols.py -q passes (0 failures) both immediately before and immediately after the dossier/__init__.py edit — empirically confirmed, not assumed.
  • SC-005: The repo's commitlint invocation accepts the record-analysis commit subject on this mission's own branch when the mission itself reaches its analyze phase — commitlint --from <parent-sha> --to <analysis-commit-sha> reports 0 problems, without any change to commitlint.config.cjs.
  • SC-006: grep -E '"path":\s*"(/home|/Users)/' <freshly-generated analysis-report.md> returns zero matches for all four input_artifacts path fields.
  • SC-007: ruff check and mypy --strict report zero new issues on every file this mission touches (src/specify_cli/dossier/__init__.py, src/specify_cli/analysis_report.py, src/specify_cli/cli/commands/agent/mission_record_analysis.py, tests/architectural/test_dossier_emitter_positional_guard.py, tests/specify_cli/test_analysis_report.py, tests/specify_cli/test_analysis_report_charter_yaml_staleness.py), per the charter's Technical Standards.
  • SC-008: grep -n 'explicitly deferred\|none exist in src/ today' tests/architectural/test_dossier_emitter_positional_guard.py returns zero matches after the FR-003/User-Story-2-AC-5 docstring update — the guard's own docstring no longer describes the attribute-chain and aliased-import gaps as deliberately deferred/nonexistent design scope.