Implementation Plan: Upgrade keeps an absent activation key absent
Branch: issue-6629-upgrade-seeded-activation-provenance | Date: 2026-10-25 | Spec: spec.md Input: Mission specification from kitty-specs/upgrade-keeps-absent-activation-absent-01M5T0YE/spec.md
Summary
unify_charter_activation_promote_answers (shipped at 3.2.6rc1) seeds an absent activated_<kind> key with the whole in-force set before it appends interview selections. On the #6629 shape this makes charter synthesize demand 38 companion tactics and skip the fresh-project seed. The plan has four parts:
- Supersede the shipped migration through
SUPERSEDED_MIGRATIONS, with its module untouched. - Add a successor,
4.0.0rc8_promote_interview_selections, listed after4.0.0rc8_activation_ledger. It: - promotes answers-only ids into present lists only, through
resolve_activation_write_target+promote_activations, so the deny guard and the activation ledger seam apply; - never materialises an absent key;
- records
successon its first run on a project with selections; - warns, without changing anything, when a present list looks seeded.
- No changes to the readers. Synthesis, the establishment probe and the activation ledger schema stay unchanged.
Technical Context
Language/Version: Python 3.11+ Primary Dependencies: ruamel.yaml, typer (existing) Storage: .kittify/charter/activation.yaml (activation ledger), .kittify/charter/interview/answers.yaml, .kittify/migration-ledger.jsonl Testing: pytest. A runner-driven integration test (end to end through MigrationRunner and charter synthesize --dry-run --json) plus focused unit tests of the successor. Target Platform: the CLI on Linux, macOS and Windows Project Type: single Performance Goals: the in-force set is resolved at most once per run, and only for the seeded-list hint (NFR-002) Constraints: DIRECTIVE_044 (no edit to a shipped migration); ADR 2026-10-17-1 (migration ledger, supersession amendment 2026-10-25); ADR 2026-10-19-1 (activation ledger: no new key, writes through the seam); #6499 (no silent activation change) Scale/Scope: one new migration module, one supersession entry, one manifest line, test updates
Charter Check
- Single canonical authority: the successor writes through the one write-target resolver and the one promotion primitive (
promote_activations). It has no write path of its own. PASS. - Canonical sources / DIRECTIVE_044: the shipped module stays byte-identical, and supersession uses the #6589 table. PASS.
- ATDD / red-first:
tests/specify_cli/upgrade/test_upgrade_keeps_absent_activation_6629.pyis red on main (it seeds a 38-entry directive list and a 14-entry paradigm list). It goes green with the fix. PASS. - Architectural gates:
test_migration_order_manifest.py,test_deny_list_survives_upgrade.pyandtest_requires_git_repository_audit.pyare implicated and run targeted. PASS. - Terminology: "activation ledger" vs "Migration Ledger" are named explicitly. PASS.
Project Structure
Documentation (this mission)
kitty-specs/upgrade-keeps-absent-activation-absent-01M5T0YE/
├── spec.md
├── plan.md
├── traces/ # tooling-friction, approach, design-decisions
├── decisions/
└── tasks.md # /spec-kitty.tasks
Source Code (repository root)
src/specify_cli/upgrade/
├── supersession.py # + unify_charter_activation_promote_answers -> successor
└── migrations/
├── m_unify_charter_activation.py # UNCHANGED (shipped)
├── m_4_0_0rc8_promote_interview_selections.py # NEW successor
└── order_manifest.txt # + successor after 4.0.0rc8_activation_ledger (tie note)
tests/specify_cli/upgrade/
├── test_upgrade_keeps_absent_activation_6629.py # end-to-end red-first reproduction
├── test_m_4_0_0rc8_promote_interview_selections.py # NEW focused unit tests
└── test_unify_charter_activation_migration.py # + registered substitute assertion
tests/architectural/test_deny_list_survives_upgrade.py # old id -> ORDER_EXEMPT, successor -> BUILDERS
docs/adr/4.x/2026-10-17-1-explicit-migration-ledger.md # amendment line for this supersession
docs/changelog/CHANGELOG.md # [Unreleased] Fixed entry
Structure Decision: one successor module beside the other rc8 migrations. It reuses _compute_promotions-style resolution through public charter seams (resolve_selected_id_to_stem, resolve_offering_root), not private helpers of the shipped module, so the superseded module can be retired later without breaking an import.
Design
Detection (detect): True when answers.yaml holds a non-empty selected_<kind> for any of the 8 promoted kinds. It reads only answers.yaml. Because apply then succeeds, the runner records success and the migration settles (FR-007, NFR-001).
Apply: 1. Load the answers. Resolve each id to a built-in stem (resolve_selected_id_to_stem). An unresolved id becomes a warning (FR-006). 2. resolve_activation_write_target(project) gives the authoritative surface: the activation ledger after 4.0.0rc8_activation_ledger, or the legacy pointer/config target on a project that has no keys and therefore no ledger. 3. For each kind whose key is present, normalise the list entries to stems (identity space, #6423). The promotion is the resolved answer stems that are not in it. A kind whose key is absent is skipped (FR-001). 4. If any promotions exist, call promote_activations(..., effective_sets={}, repo_root=project). Denied ids are skipped there (FR-006). Then restamp_bundle_hash(project) (FR-009). 5. Seeded-list hint (FR-008): for each present list whose stems are set-equal to the in-force set of that kind, while the answer stems are a strict subset, add a warning naming charter interview --narrow-activation and charter activate <kind> <id> --only. The in-force set is resolved once for the candidate kinds through resolve_effective_sets(project, keys). Because a key is present, resolve_effective_sets is asked for what would be in force if the key were absent, which is the full set. 6. Dry run: report would promote {key: ids} and the warnings, and write nothing (FR-010).
Supersession: SUPERSEDED_MIGRATIONS["unify_charter_activation_promote_answers"] = "4.0.0rc8_promote_interview_selections".
Flags: target_version = "4.0.0rc8", runs_on_worktrees = False, clone_local_effect = False. requires_git_repository stays False: the architect probe found no resolve_canonical_repo_root call on this path, and the audit test is run to confirm.
Complexity Tracking
No charter violations.
Implementation Concern Map
IC-01 — Successor promotion migration
- Purpose: promote interview selections into present lists through the activation ledger seam, never materialise an absent key, and settle after one run.
- Relevant requirements: FR-001, FR-002, FR-003, FR-004, FR-006, FR-007, FR-008, FR-009, FR-010, NFR-001, NFR-002, NFR-003
- Affected surfaces:
src/specify_cli/upgrade/migrations/m_4_0_0rc8_promote_interview_selections.py,order_manifest.txt, new unit tests, the end-to-end test - Sequencing/depends-on: none
- Risks: the legacy-scope resolution inside
BaseMigration(legacy_upgrade_scope) must still name the activation ledger on a migrated project (the architect confirmed_one_moderesolves LEDGER once the file exists).
IC-02 — Supersede the shipped promotion and update its pins
- Purpose: register the shipped migration as a recorded no-op, and move the tests and gates that drove it to the successor.
- Relevant requirements: FR-005, C-001, C-006
- Affected surfaces:
supersession.py,tests/architectural/test_deny_list_survives_upgrade.py,tests/specify_cli/upgrade/test_unify_charter_activation_migration.py,tests/upgrade/test_consolidate_charter_bundle_migration.pydocstrings, the ADR amendment, the CHANGELOG - Sequencing/depends-on: IC-01
- Risks: the fold's "relocates seed output" contract (MG6) becomes vacuous for this seed. Its docstring is updated and its assertions are kept.