diff --git a/.claude/agents/adr-reviewer.md b/.claude/agents/adr-reviewer.md index e1d19060..8e16ad54 100644 --- a/.claude/agents/adr-reviewer.md +++ b/.claude/agents/adr-reviewer.md @@ -15,9 +15,10 @@ It does not edit artifacts, execute lifecycle verbs, create work items, or perfo When invoked: 1. Read the rendered ADR using `govctl adr show ` (never read the raw TOML file — use the rendered markdown) -2. Run or inspect `govctl check` diagnostics when evaluating source-sensitive reference syntax -3. Evaluate against the checklist below -4. Report findings organized by severity +2. Run or inspect `govctl check` diagnostics for source-sensitive references and projection ownership +3. If rendered structure is duplicated or `E0307` is reported, inspect the owning field with `govctl adr get ` +4. Evaluate against the checklist below +5. Report findings organized by severity ## Review Checklist @@ -56,6 +57,14 @@ When invoked: - [ ] Rejected alternatives have a rejection reason - [ ] Alternatives are genuinely different approaches (not strawmen) +### Projection Ownership + +- [ ] The rendered title, Context, Decision, Consequences, and Alternatives sections each appear once +- [ ] Free-form content does not add `Options Considered`, `Alternatives Considered`, or another renderer-owned heading +- [ ] The structured `alternatives` field is the only options inventory +- [ ] Content does not repeat the generated reference inventory as a heading or standalone list +- [ ] Any `E0307` diagnostic is Critical and is fixed before acceptance; `--force` is not a projection-ownership bypass + ### References - [ ] Links to related RFCs/ADRs that constrained or informed the decision @@ -98,4 +107,4 @@ Overall: [PASS / NEEDS WORK / MAJOR ISSUES] If no findings exist, say so explicitly and still include the overall status. -The most common failure modes are an empty or dishonest Negative section, ADRs that drift into execution tracking, ADRs that try to act like mini-RFCs, ADRs that invent unreferenced product obligations, and ADRs that jump straight to a decision without first documenting the alternatives discussion. If the review finds any of those, flag them as Critical. +The most common failure modes are an empty or dishonest Negative section, duplicated renderer-owned structure, ADRs that drift into execution tracking, ADRs that try to act like mini-RFCs, ADRs that invent unreferenced product obligations, and ADRs that jump straight to a decision without first documenting the alternatives discussion. If the review finds any of those, flag them as Critical. diff --git a/.claude/skills/adr-writer/SKILL.md b/.claude/skills/adr-writer/SKILL.md index fc84a417..fc9ebae6 100644 --- a/.claude/skills/adr-writer/SKILL.md +++ b/.claude/skills/adr-writer/SKILL.md @@ -76,10 +76,6 @@ What specific issue are we addressing? ### Constraints What existing RFCs, ADRs, or technical limitations restrict our options? - -### Options Considered - -Brief overview (details go in the alternatives field). ``` **Key principle:** A reader 6 months from now must understand _why_ this decision was needed without asking anyone. @@ -214,7 +210,7 @@ Examples: ### Quality Checklist -- **Context is complete.** Problem statement, constraints, and options are all present. +- **Context is complete.** Problem statement, constraints, and decision drivers are present. - **Alternatives come first.** The discussion is visible in `alternatives` before the final decision prose settles the issue. - **Decision is decisive.** Starts with "We will..." — not "We might..." or "We could...". - **Consequences are honest.** Negative section is non-empty with mitigations. @@ -251,6 +247,15 @@ decision = "Use `HashMap>` for clause storage" ## Rendering Rules +Each semantic section has one canonical authoring surface: + +| Content | Canonical owner | +| --------------------------------------------------- | ------------------------------------- | +| ADR title, metadata, and fixed section headings | Renderer | +| Reference inventory | `refs` | +| Options, statuses, pros/cons, and rejection reasons | `content.alternatives` | +| Explanatory body prose and allowed subsections | `context`, `decision`, `consequences` | + The renderer auto-generates structural elements from TOML metadata. **Do NOT include these in content fields:** - `## Context`, `## Decision`, `## Consequences` headings — auto-generated for each section @@ -260,12 +265,14 @@ The renderer auto-generates structural elements from TOML metadata. **Do NOT inc - ADR title (`# ADR-NNNN: Title`) — auto-generated from metadata Content fields should contain only the body prose and `[[...]]` references. +Use structured alternatives as the only options inventory; do not add an `Options Considered` or `Alternatives Considered` subsection to prose. `govctl check` and `govctl adr accept` enforce this boundary for proposed ADRs per [[RFC-0000:C-ADR-PROJECTION-OWNERSHIP]]. ## Common Mistakes | Mistake | Fix | | ------------------------------------- | -------------------------------------------------------- | | `## Context` in content field | Don't — the renderer adds section headings automatically | +| `### Options Considered` in context | Use structured `content.alternatives` only | | Empty Negative section | Every decision has trade-offs — document them | | Decision written before alternatives | Add and evaluate alternatives first; then write decision | | No alternatives for a new ADR | Add at least one rejected option | diff --git a/CHANGELOG.md b/CHANGELOG.md index 673cdf01..bc3b83b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,38 @@ Release entries are curated summaries for readers. Work item traceability remain ## [Unreleased] +### Added + +- Proposed ADR checks and acceptance reject renderer-owned headings with field-and-heading diagnostics, including under `--force` (WI-2026-07-15-003) +- Projection validation ignores fenced-code headings and preserves historical terminal ADR compatibility (WI-2026-07-15-003) +- CLI support implements the governed latest-release correction flow (WI-2026-07-15-005) + +### Changed + +- Detected content amendment bumps start the new RFC version at `spec`, while signature baselines and changelog-only updates preserve phase (WI-2026-07-15-001) +- Unreleased `done` Work Items can return to `active`, while release-referenced items remain `done` (WI-2026-07-15-002) +- RFC finalize exposes only the normative target while deprecation remains a separate verb (WI-2026-07-15-004) +- Release governance artifacts define the correction boundary for the latest local release cut (WI-2026-07-15-005) +- Reviewed ADRs retain decision rationale without task execution sections (WI-2026-07-15-006) + +### Fixed + +- RFC bump failure paths preserve the original RFC and clause files (WI-2026-07-15-001) +- RFC lifecycle commands reject forbidden deprecated and implementation phase combinations (WI-2026-07-15-001) +- Schema migration converts legacy RFC signatures into content-only amendment baselines without changing RFC versions, phases, or changelogs (WI-2026-07-15-001) +- Project validation rejects Work Item IDs referenced by multiple releases (WI-2026-07-15-002) +- ADR writer and reviewer guidance assign each rendered section to one authoring surface (WI-2026-07-15-003) +- Existing ADR sources render without duplicated alternatives sections (WI-2026-07-15-003) +- ADR projection conflicts normalize formatted artifact titles by visible CommonMark text (WI-2026-07-15-003) +- Lifecycle command write failures preserve governed artifact bytes (WI-2026-07-15-004) +- Phase-only RFC advancement remains outside amendment detection after legacy signature normalization (WI-2026-07-15-004) +- RFC and Clause invalid-transition diagnostics identify valid targets or terminal states (WI-2026-07-15-004) +- Release creation and schema validation reject invalid dates and missing refs (WI-2026-07-15-004) +- Work Item schema and serialization checks match required scalars and omitted empty-list semantics (WI-2026-07-15-004) +- Release correction failure paths preserve release and Work Item data (WI-2026-07-15-005) +- Legacy release version syntax remains supported alongside latest-release undo (WI-2026-07-15-005) +- ADR placeholder context emits a dedicated diagnostic code (WI-2026-07-15-006) + ## [0.10.2] - 2026-07-05 0.10.2 restores compatibility between the bundled Agent Skills and GitHub diff --git a/docs/rfc/RFC-0000.md b/docs/rfc/RFC-0000.md index 9c96e2b2..e3bde6d9 100644 --- a/docs/rfc/RFC-0000.md +++ b/docs/rfc/RFC-0000.md @@ -1,9 +1,9 @@ - + # RFC-0000: govctl Governance Framework -> **Version:** 1.3.3 | **Status:** normative | **Phase:** stable +> **Version:** 1.5.0 | **Status:** normative | **Phase:** stable --- @@ -17,10 +17,10 @@ govctl is a governance CLI that manages six artifact types: - **Clauses**: Individual requirements within RFCs - **ADRs**: Architectural Decision Records documenting design choices - **Work Items**: Units of work tracking implementation progress -- **Releases**: Published versions and the work items included in each version +- **Releases**: Local version-cut records and the Work Items included in each version - **Verification Guards**: Reusable executable checks that turn completion requirements into auditable pass/fail gates -All artifacts follow explicit lifecycle states, phase gates, or immutability rules to ensure disciplined development. +All artifacts follow explicit lifecycle states, phase gates, or constrained mutation rules to ensure disciplined development. > **Tags:** `core` @@ -78,24 +78,33 @@ Transition rules: ### [RFC-0000:C-PHASE-LIFECYCLE] RFC Phase Lifecycle (Normative) -RFC phase follows this lifecycle: +RFC phase describes the current RFC version and follows this lifecycle within that version: spec → impl → test → stable -**spec**: Defining what will be built. No implementation work permitted. +**spec**: Defining the current version. No implementation work permitted. -**impl**: Building what was specified. Implementation proceeds per spec. +**impl**: Building what the current version specifies. -**test**: Verifying implementation matches specification. +**test**: Verifying implementation against the current version. -**stable**: Released for production use. +**stable**: Implementation and tests for the current version are complete. Phase rules: -- Phases MUST proceed in order; skipping is FORBIDDEN +- Phases within one version MUST proceed in order; skipping is FORBIDDEN - Each phase transition requires the previous phase gate to pass +- `stable` is terminal for one RFC version, not for the RFC across later version bumps +- A version bump that releases a detected content amendment MUST start the new version at `spec` +- Changelog-only updates and signature-baseline bookkeeping without a detected content amendment MUST preserve phase +- Phase progression MUST be rejected while a detected content amendment remains unversioned - draft + stable is FORBIDDEN (cannot stabilize unratified work) - deprecated + impl/test is FORBIDDEN (no new work on deprecated specs) +For amendment detection, RFC content is the canonical RFC and clause data other than RFC `version`, `phase`, `changelog`, and `signature` fields. Those four fields are lifecycle or bump bookkeeping and MUST be excluded from amendment comparison. An RFC without a stored amendment signature has no comparison baseline; establishing its first baseline MUST NOT by itself be treated as a detected content amendment. + +**Rationale:** +Scoping phase to one version lets later amendments reuse the existing ordered gates. Defining the comparison surface prevents phase progression and release bookkeeping from recursively appearing as new content amendments. + > **Tags:** `core`, `lifecycle` *Since: v1.0.0* @@ -209,20 +218,50 @@ ADR status lifecycle: *Since: v1.0.0* +### [RFC-0000:C-ADR-PROJECTION-OWNERSHIP] ADR Projection Ownership (Normative) + +ADR source fields and rendered projections MUST have one canonical owner for each semantic section. + +The ADR renderer owns the artifact title and the fixed `Context`, `Decision`, `Consequences`, and `Alternatives Considered` section headings. The structured `refs` field owns the rendered reference inventory. Structured `content.alternatives` entries own their generated alternative headings and trade-off labels. + +ADR content fields MUST contain section body prose rather than reproducing renderer-owned structure. + +A conflicting heading is a Markdown heading recognized by CommonMark parsing outside a code block whose visible text, after surrounding whitespace is removed, matches one of the following using ASCII case-insensitive comparison: + +- the rendered artifact title `: `; +- `Context`, `Decision`, `Consequences`, `References`, `Alternatives Considered`, or the semantic alias `Options Considered`; +- a heading generated for a structured alternative: its `text`, followed by ` (accepted)` or ` (rejected)` when that status suffix applies. + +Project validation MUST inspect `content.context`, `content.decision`, and `content.consequences` in proposed ADRs for conflicting headings. Project validation MUST report a validation error for each conflict. Each diagnostic MUST identify the content field. Each diagnostic MUST identify the conflicting visible heading text. + +ADR acceptance MUST apply the same projection-ownership validation before changing status. A force option that bypasses alternatives-completeness checks MUST NOT bypass projection-ownership validation. + +Project validation MAY omit projection-ownership diagnostics for ADRs that are no longer proposed so that historical artifacts do not become invalid solely because govctl gained this validation. + +Inline references that support nearby prose remain valid. This clause does not prohibit contextual `[[...]]` references inside content fields. + +**Rationale:** +A single owner for each rendered section prevents structurally valid source fields from producing duplicated or contradictory human-readable ADRs. CommonMark heading events distinguish actual headings from examples in fenced code blocks and normalize inline formatting to visible text. Acceptance-time enforcement prevents a proposed violation from escaping validation by becoming historical. + +*Since: v1.4.0* + --- ## 5. Work Item Specification ### [RFC-0000:C-WORK-DEF] Work Item Definition (Normative) <a id="rfc-0000c-work-def"></a> -A Work Item tracks a unit of work from inception to completion. +A Work Item tracks a unit of work from inception through a release-eligible completion. Release membership freezes its lifecycle status. Every Work Item MUST be stored as a TOML file containing: -- `[govctl]` section with: `id`, `title`, `status`, `created`, `refs` -- optional `[govctl]` fields: `started`, `completed` -- `[content]` section with: `description`, `acceptance_criteria`, `notes` +- `[govctl]` section with required fields: `id`, `title`, `status`, `created` +- optional `[govctl]` fields: `started`, `completed`, `refs` +- `[content]` section with required field: `description` +- optional `[content]` fields: `acceptance_criteria`, `notes` - optional `[verification]` section with: `required_guards`, `waivers` +Omitted list-valued fields `refs`, `acceptance_criteria`, `notes`, `verification.required_guards`, and `verification.waivers` MUST be interpreted as empty lists. + Format evolution is tracked by the project-level `[schema] version` in `gov/config.toml`, not per-artifact fields. Implementations MUST define a machine-readable JSON Schema for the Work Item file structure and MUST validate Work Item files against that schema during project validation. @@ -239,20 +278,32 @@ Waiver entries MUST reference only guards that appear in the work item's effecti A waiver MUST suppress only the named guard. A Work Item MUST NOT disable the verification system globally. -Work Item status lifecycle: +Work Item status transitions: - queue → active → done - ↘ ↘ cancelled + queue → active + queue → cancelled + active → done + active → cancelled + done → active (unreleased only) **queue**: Planned but not started. **active**: Currently in progress. Only one active item recommended per focus area. -**done**: Completed. All acceptance criteria met, and any required verification guards have passed or been explicitly waived. +**done**: Completed and eligible for release. All acceptance criteria met, and any required verification guards have passed or been explicitly waived. **cancelled**: Abandoned from queue or active. Reason documented in notes. -A Work Item MUST NOT transition to done if any acceptance_criteria are pending. +A Work Item MUST contain at least one acceptance criterion before it transitions to done. + +A Work Item MUST NOT transition to done if any acceptance criteria are pending. + +A done Work Item that is not referenced by a release MUST be allowed to return to active. This transition MUST preserve `started` and remove `completed`. + +A Work Item referenced by a release MUST remain done. + +**Rationale:** +An unreleased completion is a correctable judgment. Release membership supplies the immutable lifecycle boundary without adding another persisted Work Item status. Omitting empty list fields keeps the TOML representation minimal without changing the logical data model. > **Tags:** `core` @@ -264,7 +315,7 @@ A Work Item MUST NOT transition to done if any acceptance_criteria are pending. ### [RFC-0000:C-RELEASE-DEF] Release Definition (Normative) <a id="rfc-0000c-release-def"></a> -A Release tracks a published version and the set of work items included in that version. +A Release is a local version-cut record that groups completed Work Items under a semantic version. A Release records local governance state; it is not proof that Git tags, hosted releases, packages, or other external artifacts were published. Release data MUST be stored as a TOML file at `gov/releases.toml` containing: - `[[releases]]` array with entries containing: `version`, `date`, `refs` @@ -275,14 +326,36 @@ Each `releases[].version` MUST be a valid semantic version. Each `releases[].date` MUST use ISO 8601 calendar date format `YYYY-MM-DD`. -Each `releases[].refs` entry MUST reference an existing Work Item ID. +Each `releases[].refs` entry MUST reference an existing Work Item ID whose status is `done`. + +A Work Item ID MUST appear in the `refs` of at most one release. + +A release cut MUST include every Work Item whose status is `done` and which is not referenced by an existing release. It MUST reject the cut without modifying governed artifacts when no such Work Item exists. + +When a release cut omits `--date`, it MUST use the current calendar date in the host's local timezone. + +Release entries MUST be stored newest-first. Release creation MUST prepend the new entry to the `releases` array. + +A Work Item referenced by any release MUST remain `done`. Lifecycle commands MUST reject transitions away from `done` for that Work Item. + +Project validation MUST report a release reference to a Work Item whose status is not `done`. + +Project validation MUST report a Work Item ID referenced by more than one release. + +A release entry has no persistent lifecycle status. An existing entry MUST remain immutable except when the newest entry is removed through the guarded latest-release correction defined below. -A release entry has no lifecycle and MUST be treated as immutable once created. +The latest-release correction MUST require an expected version. The correction MUST reject an empty release history or an expected version that does not exactly match the newest entry's version without modifying governed artifacts. + +A successful latest-release correction MUST remove only the newest release entry. It MUST NOT mutate the referenced Work Items; those items remain `done` and become unreleased because no release references them. + +The latest-release correction MUST mutate only canonical release data. It MUST NOT create, remove, or modify `CHANGELOG.md`, Git tags, hosted releases, or published packages. + +After a latest-release correction, `govctl render changelog --force` MUST derive release membership only from the current canonical release entries. The full projection MUST omit release versions absent from canonical release data and MUST classify as unreleased every `done` Work Item not referenced by a current release. Implementations MUST define a machine-readable JSON Schema for the release file structure and MUST validate `gov/releases.toml` against that schema during project validation. **Rationale:** -Releases are first-class governance artifacts because changelog generation and release membership depend on them. Giving releases a defined TOML structure and required JSON Schema removes the remaining schema-less storage special case. +Release membership is the immutability boundary for a completed Work Item while its release entry exists. Unique membership and a frozen `done` status preserve release history. Restricting correction to the newest entry restores the immediately preceding local state without permitting arbitrary history rewriting, while the expected-version check rejects stale operator intent. > **Tags:** `core`, `release` @@ -328,6 +401,47 @@ A guard check MUST pass only when the command exits successfully. If `pattern` i ## Changelog +### v1.5.0 (2026-07-15) + +Define guarded correction for local release cuts + +#### Added + +- Define guarded newest-release correction and canonical changelog projection + +#### Changed + +- Define Releases as local version-cut records + +### v1.4.2 (2026-07-15) + +Clarify optional Work Item verification lists + +#### Changed + +- Define omitted verification guard and waiver lists as empty + +### v1.4.1 (2026-07-15) + +Clarify Work Item serialized field requirements + +#### Changed + +- Allow empty Work Item list fields to be omitted while requiring created metadata and completion criteria + +### v1.4.0 (2026-07-15) + +Clarify lifecycle and ADR projection boundaries + +#### Added + +- Define ADR projection ownership validation + +#### Changed + +- Scope RFC phase to each version +- Freeze Work Items at unique release membership + ### v1.3.3 (2026-06-11) Clarify inline reference validation diff --git a/docs/rfc/RFC-0001.md b/docs/rfc/RFC-0001.md index fd42a912..c78602a9 100644 --- a/docs/rfc/RFC-0001.md +++ b/docs/rfc/RFC-0001.md @@ -1,9 +1,9 @@ <!-- GENERATED: do not edit. Source: RFC-0001 --> -<!-- SIGNATURE: sha256:1ec031f45ae7ff70a8257a1b32bbf048c58d003be81e56825115c888a08e5f01 --> +<!-- SIGNATURE: sha256:f33d3482f0bc9ddd909cea5c5b6ec06aff15bb6eca68d02502396fce1e1741ad --> # RFC-0001: Lifecycle State Machines -> **Version:** 0.4.2 | **Status:** normative | **Phase:** stable +> **Version:** 0.5.0 | **Status:** normative | **Phase:** stable --- @@ -45,24 +45,31 @@ Invalid transitions (MUST be rejected): ### [RFC-0001:C-RFC-PHASE] RFC Phase Transitions (Normative) <a id="rfc-0001c-rfc-phase"></a> -An RFC MUST have exactly one of the following phase values: +An RFC MUST have exactly one of the following phase values. The phase describes conformance work for the RFC's current version: 1. **spec** — Specification phase. The RFC text is being written. No implementation work. -2. **impl** — Implementation phase. Code is being written to conform to the RFC. -3. **test** — Testing phase. Implementation is complete; tests are being written and validated. -4. **stable** — Stable phase. Implementation and tests are complete. No further changes expected. +2. **impl** — Implementation phase. Code is being written to conform to the current RFC version. +3. **test** — Testing phase. Implementation is complete; tests are being written and validated against the current RFC version. +4. **stable** — Stable phase. Implementation and tests for the current RFC version are complete. -Valid transitions (forward only, via `advance` command): +Within one RFC version, valid transitions are forward only via the `advance` command: - spec → impl - impl → test - test → stable -Invalid transitions (MUST be rejected): +Within one RFC version, the following transitions MUST be rejected: - Any backward transition (e.g., impl → spec) - Any skip (e.g., spec → test, spec → stable) -- stable → any (stable is terminal for phase) +- stable → any (`stable` is terminal for that version) + +A version bump that releases a detected RFC or clause content amendment MUST start the new RFC version in `spec`, regardless of the prior version's phase. This starts a new version lifecycle and is not a backward transition within one version. + +A changelog-only update MUST preserve the current phase. Establishing an amendment signature baseline without a detected content amendment MUST also preserve the current phase. + +Amendment content and bookkeeping fields are defined by [RFC-0000:C-PHASE-LIFECYCLE](../rfc/RFC-0000.md#rfc-0000c-phase-lifecycle). When an RFC has a stored amendment signature and its amendment content differs from that signature, `advance` MUST reject phase progression until the amendment is released by a version bump. -Rationale: Phase discipline ensures specification precedes implementation, and testing validates implementation before stabilization. +**Rationale:** +Scoping phase to the current RFC version preserves ordered specification, implementation, and testing gates while allowing the RFC itself to evolve through versioned amendments. > **Tags:** `lifecycle` @@ -74,25 +81,37 @@ A Work Item MUST have exactly one of the following status values: 1. **queue** — Initial state. The work item is defined but not yet started. 2. **active** — The work item is currently being worked on. -3. **done** — The work item is complete. All acceptance criteria are satisfied, and any required verification guards have passed or been explicitly waived. +3. **done** — The work item is complete and eligible for release. All acceptance criteria are satisfied, and any required verification guards have passed or been explicitly waived. 4. **cancelled** — The work item was abandoned. No further work will be done. -Valid transitions (via `move` command): +Valid transitions via the `move` command are: - queue → active (start work) - queue → cancelled (abandon before starting) - active → done (complete work) - active → cancelled (abandon in progress) +- done → active when no release references the Work Item (correct a pre-release completion) -Invalid transitions (MUST be rejected): -- done → any (done is terminal) -- cancelled → any (cancelled is terminal) +The following transitions MUST be rejected: +- done → active when any release references the Work Item +- done → queue or cancelled +- cancelled → any (`cancelled` is terminal) - queue → done (cannot complete without being active) - active → queue (no "un-start") +Release membership is derived from `releases[].refs`; it does not add another Work Item status. A released Work Item MUST remain `done`. + Timestamp behavior: -- queue → active: Sets `started` date if not already set -- active → done: Sets `completed` date -- active → cancelled: Sets `completed` date +- queue → active: Sets `started` if it is not already set +- active → done: Sets `completed` +- active → cancelled: Sets `completed` +- done → active: Preserves `started` and removes `completed` + +Returning a Work Item to `done` after reopening MUST apply the same acceptance-criteria and verification-guard gates as any other active → done transition. + +Reopening a Work Item MUST NOT mutate existing loop state or round artifacts. Existing loop-local `done`, `failed`, and `cancelled` outcomes remain terminal according to [RFC-0006:C-WORK-ITEM-INTERACTION](../rfc/RFC-0006.md#rfc-0006c-work-item-interaction), [RFC-0006:C-LOOP-RESUMPTION](../rfc/RFC-0006.md#rfc-0006c-loop-resumption), and [RFC-0006:C-LOOP-SCOPE-MUTATION](../rfc/RFC-0006.md#rfc-0006c-loop-scope-mutation). A reopened Work Item can be executed through a new loop or explicit non-terminal loop scope according to those clauses. + +**Rationale:** +Completion remains correctable until release membership freezes the lifecycle record. Clearing `completed` on reopening keeps status and timestamp semantics consistent, while release membership prevents the same Work Item ID from re-entering later release collection. Keeping loop outcomes separate preserves existing execution audit history. > **Tags:** `lifecycle` @@ -213,6 +232,15 @@ Future gates MAY be added via RFC amendment, but MUST NOT break existing valid w ## Changelog +### v0.5.0 (2026-07-15) + +Scope terminal states to versions and releases + +#### Changed + +- Restart phase progression for amended RFC versions +- Allow unreleased done Work Items to return to active + ### v0.4.2 (2026-06-28) Split direct-edge preservation requirements diff --git a/docs/rfc/RFC-0002.md b/docs/rfc/RFC-0002.md index 6c45c486..a82f4996 100644 --- a/docs/rfc/RFC-0002.md +++ b/docs/rfc/RFC-0002.md @@ -1,9 +1,9 @@ <!-- GENERATED: do not edit. Source: RFC-0002 --> -<!-- SIGNATURE: sha256:6897d0b025a8fb3e5fcb2eb7e35e1f58619b567237320b823b6378cfa6b46021 --> +<!-- SIGNATURE: sha256:dae64b23b09b1d41dea582cf6aea5f76cb3ce28e0d0ec0544719faf565b12861 --> # RFC-0002: CLI Resource Model and Command Architecture -> **Version:** 0.10.4 | **Status:** normative | **Phase:** test +> **Version:** 0.12.1 | **Status:** normative | **Phase:** stable --- @@ -33,7 +33,7 @@ The design follows established patterns from Docker, kubectl, and other modern C ### [RFC-0002:C-RESOURCE-MODEL] Resource-First Command Structure (Normative) <a id="rfc-0002c-resource-model"></a> -The govctl CLI MUST use a resource-first command structure of the form: +The govctl CLI MUST use a resource-first command structure. Except for the stable release-creation form defined below, resource operations MUST use: ``` govctl <resource> <verb> [arguments] [flags] @@ -59,7 +59,13 @@ The resource-first structure (noun-first) provides several advantages over verb- Global command exceptions are governed by [RFC-0002:C-GLOBAL-COMMANDS](../rfc/RFC-0002.md#rfc-0002c-global-commands). A command MAY remain at the global namespace when it operates across multiple resource types, performs project-level or CLI-level work, or manages local execution state that coordinates governed resources without becoming a governed artifact resource. -Resource-specific CRUD, field-editing, and lifecycle operations MUST remain resource-first unless an RFC explicitly defines a global command exception. +Resource-specific CRUD, field-editing, and lifecycle operations MUST remain resource-first unless an RFC explicitly defines an alternative grammar. + +Release creation is a stable alternative grammar. Implementations MUST support `govctl release <version> [--date <YYYY-MM-DD>]`. + +Release correction is a resource-first operation. Implementations MUST support `govctl release undo <expected-version>`. + +Both release forms MUST coexist. The token `undo` selects correction because it is not a valid semantic version; a semantic-version token selects release creation. **Implementation Note:** @@ -78,29 +84,29 @@ The following resource types MUST be supported as top-level command namespaces: Manages RFC specifications (normative documents defining system behavior). - ID Format: `RFC-NNNN` (e.g., RFC-0001) -- Lifecycle: draft → normative → deprecated (per RFC-0001:C-RFC-STATUS) -- Phase: spec → impl → test → stable (per RFC-0001:C-RFC-PHASE) +- Lifecycle: draft → normative → deprecated (per [RFC-0001:C-RFC-STATUS](../rfc/RFC-0001.md#rfc-0001c-rfc-status)) +- Phase: spec → impl → test → stable (per [RFC-0001:C-RFC-PHASE](../rfc/RFC-0001.md#rfc-0001c-rfc-phase)) **2. `adr` - Architecture Decision Record** Manages ADRs (records of architectural decisions). - ID Format: `ADR-NNNN` (e.g., ADR-NNNN-style IDs) -- Lifecycle: proposed → accepted → superseded, or proposed → rejected (per RFC-0001:C-ADR-STATUS) +- Lifecycle: proposed → accepted → superseded, or proposed → rejected (per [RFC-0001:C-ADR-STATUS](../rfc/RFC-0001.md#rfc-0001c-adr-status)) **3. `work` - Work Item** -Manages work items (tasks, features, bugs). +Manages Work Items (tasks, features, bugs). - ID Format: `WI-YYYY-MM-DD-NNN` (e.g., WI-YYYY-MM-DD-NNN) -- Lifecycle: queue → active → done, with cancellation at any stage (per RFC-0001:C-WORK-STATUS) +- Lifecycle: queue → active → done, with cancellation at any stage (per [RFC-0001:C-WORK-STATUS](../rfc/RFC-0001.md#rfc-0001c-work-status)) **4. `clause` - RFC Clause** Manages individual clauses within RFCs. - ID Format: `RFC-NNNN:C-NAME` (e.g., RFC-0001:C-SUMMARY) -- Lifecycle: active → deprecated → superseded (per RFC-0001:C-CLAUSE-STATUS) +- Lifecycle: active → deprecated → superseded (per [RFC-0001:C-CLAUSE-STATUS](../rfc/RFC-0001.md#rfc-0001c-clause-status)) **Clause Namespace vs Storage:** @@ -110,19 +116,20 @@ The CLI namespace is independent of filesystem layout. Implementations MAY store **5. `guard` - Verification Guard** -Manages reusable executable completion checks defined by RFC-0000:C-GUARD-DEF. +Manages reusable executable completion checks defined by [RFC-0000:C-GUARD-DEF](../rfc/RFC-0000.md#rfc-0000c-guard-def). - ID Format: `GUARD-NAME` (e.g., GUARD-CARGO-TEST) - Storage: `gov/guard/` as individual TOML files - No lifecycle (guards are either present or removed) -**6. `release` - Release Version** +**6. `release` - Release Cut** -Manages published versions and their included work item references. +Manages local version-cut records and their included Work Item references. - ID Format: Semantic version (e.g., 1.0.0) -- Storage: `gov/releases.toml` as defined by RFC-0000:C-RELEASE-DEF -- No lifecycle (immutable once created) +- Storage: `gov/releases.toml` as defined by [RFC-0000:C-RELEASE-DEF](../rfc/RFC-0000.md#rfc-0000c-release-def) +- Mutation boundary: the newest entry may be corrected through `release undo`; all other entries are immutable +- No persistent lifecycle status **Resource Identification:** @@ -139,11 +146,11 @@ All date-valued resource metadata fields (such as `created`, `updated`, `started **Tags:** -RFCs, clauses, ADRs, work items, and guards MAY include an optional `tags` array in the `[govctl]` section. Each tag MUST be a string from the project's controlled vocabulary defined in `gov/config.toml` under `[tags] allowed`. Tags MUST match the pattern `[a-z][a-z0-9-]*` (lowercase kebab-case). Releases do not carry tags. `govctl check` MUST reject any artifact that references a tag not present in the allowed set. +RFCs, clauses, ADRs, Work Items, and guards MAY include an optional `tags` array in the `[govctl]` section. Each tag MUST be a string from the project's controlled vocabulary defined in `gov/config.toml` under `[tags] allowed`. Tags MUST match the pattern `[a-z][a-z0-9-]*` (lowercase kebab-case). Releases do not carry tags. `govctl check` MUST reject any artifact that references a tag not present in the allowed set. **Future Extensions:** -Additional resource types MAY be added via RFC amendment. New resource types MUST follow the same structural patterns defined in RFC-0002:C-CRUD-VERBS. +Additional resource types MAY be added via RFC amendment. New resource types MUST follow the same structural patterns defined in [RFC-0002:C-CRUD-VERBS](../rfc/RFC-0002.md#rfc-0002c-crud-verbs). > **Tags:** `cli`, `schema` @@ -151,7 +158,9 @@ Additional resource types MAY be added via RFC amendment. New resource types MUS ### [RFC-0002:C-CRUD-VERBS] Universal CRUD Verbs (Normative) <a id="rfc-0002c-crud-verbs"></a> -All resource types MUST support the following CRUD (Create, Read, Update, Delete) verbs where applicable: +All resource types MUST support the following CRUD (Create, Read, Update, Delete) verbs where applicable. + +Release records MUST NOT expose universal CRUD verbs. Their constrained creation and correction commands are defined by [RFC-0002:C-LIFECYCLE-VERBS](../rfc/RFC-0002.md#rfc-0002c-lifecycle-verbs). **1. `new` - Create Resource** @@ -159,7 +168,7 @@ Syntax: `govctl <resource> new <arguments>` Creates a new instance of the resource. Required arguments vary by resource type but typically include a title or primary identifier. -MUST support for: rfc, adr, work, clause, release, guard +MUST support for: rfc, adr, work, clause, guard Behavior: - Generates unique ID if not provided - Initializes resource with default values @@ -174,13 +183,13 @@ Syntax: `govctl <resource> list [filter] [--tag <tag>[,<tag>...]]` Lists all instances of the resource type. Optional filter narrows results. -MUST support for: rfc, adr, work, clause, release, guard +MUST support for: rfc, adr, work, clause, guard Behavior: - Returns tabular output by default - Supports filtering (exact match or substring) - Sorted by ID (lexicographic order) - MUST respect [RFC-0002:C-OUTPUT-FORMAT](../rfc/RFC-0002.md#rfc-0002c-output-format) flags -- With `--tag`: filters results to artifacts that carry ALL specified tags. MUST support comma-separated tag values. Applies to rfc, clause, adr, work, and guard (releases do not carry tags). +- With `--tag`: filters results to artifacts that carry ALL specified tags. MUST support comma-separated tag values. Applies to rfc, clause, adr, work, and guard. **3. `get` - Read Resource** @@ -225,26 +234,26 @@ MUST NOT support for: rfc, adr (use lifecycle verbs instead) **Deletion Safety Constraints:** For guards: -- MUST verify no work items reference the guard ID in `verification.required_guards` or `verification.waivers[*].guard` before deletion +- MUST verify no Work Items reference the guard ID in `verification.required_guards` or `verification.waivers[*].guard` before deletion - MUST verify the guard is not listed in `verification.default_guards` in `gov/config.toml` before deletion - MUST error with list of referencing artifacts/config if any references exist For clauses: - MUST only allow deletion if containing RFC is in `draft` status - MUST verify no other artifacts reference the clause ID before deletion -- MUST error with list of referencing artifacts if references exist +- MUST error with list of referencing artifacts if any references exist -For work items: +For Work Items: - MUST only allow deletion if status is `queue` -- MUST verify no other artifacts reference the work item ID before deletion -- MUST error with list of referencing artifacts if references exist +- MUST verify no other artifacts reference the Work Item ID before deletion +- MUST error with list of referencing artifacts if any references exist **General Deletion Behavior:** - SHOULD require confirmation unless `--force` flag provided - MUST be atomic (either fully succeeds or fully fails) **Rationale:** -Permanent deletion breaks referential integrity. These constraints ensure deletions are safe and don't leave dangling references in the governance graph. +Permanent deletion breaks referential integrity. These constraints ensure deletions are safe and do not leave dangling references in the governance graph. Release mutation is narrower than universal CRUD because only the newest local cut can be corrected. **Consistency Requirements:** @@ -257,40 +266,47 @@ Permanent deletion breaks referential integrity. These constraints ensure deleti - MUST NOT use different verb names for the same operation (e.g., "show" vs "get") - MUST NOT reorder arguments between resource types (ID always comes before field) -- MUST NOT have resource-specific flags for universal operations. Flags that filter by a cross-resource metadata field (e.g., `--tag`) are permitted on resources that carry that field and silently ignored or unavailable on resources that do not. +- MUST NOT have resource-specific flags for universal operations. Flags that filter by a cross-resource metadata field (e.g., `--tag`) are permitted on resources that carry that field. > **Tags:** `cli`, `editing` *Since: v0.1.0* -### [RFC-0002:C-LIFECYCLE-VERBS] Resource-Specific Lifecycle Verbs (Normative) <a id="rfc-0002c-lifecycle-verbs"></a> +### [RFC-0002:C-LIFECYCLE-VERBS] Resource-Specific Lifecycle Operations (Normative) <a id="rfc-0002c-lifecycle-verbs"></a> + +Resource-specific lifecycle and constrained-mutation operations implement state transitions defined in [RFC-0001](../rfc/RFC-0001.md) or mutation defined by their resource contract. These operations MUST be scoped to their resource namespace. -Resource-specific lifecycle verbs implement state transitions defined in [RFC-0001](../rfc/RFC-0001.md). These verbs MUST be scoped to their resource namespace. +**RFC Lifecycle Operations:** -**RFC Lifecycle Verbs:** +1. `govctl rfc finalize <id> normative` + - Implements the draft → normative transition in [RFC-0001:C-RFC-STATUS](../rfc/RFC-0001.md#rfc-0001c-rfc-status) -1. `govctl rfc finalize <id> <normative|deprecated>` - - Implements [RFC-0001:C-RFC-STATUS](../rfc/RFC-0001.md#rfc-0001c-rfc-status) transitions - - draft → normative or draft → deprecated +2. `govctl rfc deprecate <id>` + - Implements the normative → deprecated transition in [RFC-0001:C-RFC-STATUS](../rfc/RFC-0001.md#rfc-0001c-rfc-status) -2. `govctl rfc advance <id> <spec|impl|test|stable>` +3. `govctl rfc advance <id> <spec|impl|test|stable>` - Implements [RFC-0001:C-RFC-PHASE](../rfc/RFC-0001.md#rfc-0001c-rfc-phase) transitions - - Forward-only phase progression + - Forward-only phase progression within the current RFC version + - MUST reject phase progression while a stored amendment signature differs from current amendment content as defined by [RFC-0000:C-PHASE-LIFECYCLE](../rfc/RFC-0000.md#rfc-0000c-phase-lifecycle) -3. `govctl rfc bump <id> <patch|minor|major> -m <message>` +4. `govctl rfc bump <id> <patch|minor|major> -m <message>` - Version bumping per semantic versioning - Updates changelog automatically - Optional: `--change <description>` for additional changelog entries - - MUST reject a version bump when the RFC has a stored amendment signature and no RFC or clause content has changed since that signature - - MUST treat version, changelog, and signature metadata as bump bookkeeping, not RFC content changes + - MUST reject a version bump when the RFC has a stored amendment signature and no amendment content has changed since that signature + - MUST exclude RFC `version`, `phase`, `changelog`, and `signature` fields from amendment comparison + - MUST set phase to `spec` when the bump releases a detected RFC or clause content amendment - MAY establish a baseline amendment signature for RFCs that do not yet have one - - `--change` alone MUST remain changelog-only and MUST NOT make a later version bump valid + - MUST preserve phase when establishing a baseline amendment signature without a detected content amendment + - `--change` alone MUST remain changelog-only + - `--change` alone MUST preserve phase + - `--change` alone MUST NOT make a later version bump valid -4. `govctl rfc supersede <id> --by <replacement-id>` +5. `govctl rfc supersede <id> --by <replacement-id>` - Marks RFC as superseded by another RFC - Both RFCs must exist -**ADR Lifecycle Verbs:** +**ADR Lifecycle Operations:** 1. `govctl adr accept <id>` - Implements [RFC-0001:C-ADR-STATUS](../rfc/RFC-0001.md#rfc-0001c-adr-status) proposed → accepted @@ -302,14 +318,26 @@ Resource-specific lifecycle verbs implement state transitions defined in [RFC-00 - Implements [RFC-0001:C-ADR-STATUS](../rfc/RFC-0001.md#rfc-0001c-adr-status) accepted → superseded - Both ADRs must exist -**Work Lifecycle Verbs:** +**Work Lifecycle Operations:** 1. `govctl work move <id> <queue|active|done|cancelled>` - Implements [RFC-0001:C-WORK-STATUS](../rfc/RFC-0001.md#rfc-0001c-work-status) transitions - - Validates gate conditions (e.g., acceptance criteria for done) - - Updates timestamp fields automatically + - Validates gate conditions when entering `done` + - MUST reject done → active when any release references the Work Item + - queue → active MUST set `started` when it is absent + - active → done MUST set `completed` + - active → cancelled MUST set `completed` + - done → active MUST preserve `started` + - done → active MUST remove `completed` + - done → active MUST NOT mutate loop state or round artifacts governed by [RFC-0006:C-WORK-ITEM-INTERACTION](../rfc/RFC-0006.md#rfc-0006c-work-item-interaction) + +**Release Lifecycle Operations:** + +1. The CLI MUST support `govctl release <version> [--date <YYYY-MM-DD>]`. The operation MUST create the newest local release entry according to [RFC-0000:C-RELEASE-DEF](../rfc/RFC-0000.md#rfc-0000c-release-def). -**Clause Lifecycle Verbs:** +2. The CLI MUST support `govctl release undo <expected-version>`. The operation MUST correct the newest local release entry according to [RFC-0000:C-RELEASE-DEF](../rfc/RFC-0000.md#rfc-0000c-release-def). + +**Clause Lifecycle Operations:** 1. `govctl clause deprecate <id>` - Implements [RFC-0001:C-CLAUSE-STATUS](../rfc/RFC-0001.md#rfc-0001c-clause-status) active → deprecated @@ -320,26 +348,27 @@ Resource-specific lifecycle verbs implement state transitions defined in [RFC-00 **Consistency Requirements:** -1. All lifecycle verbs MUST validate transitions per [RFC-0001](../rfc/RFC-0001.md) -2. All lifecycle verbs MUST update timestamp fields where applicable +1. All lifecycle operations MUST validate transitions per [RFC-0001](../rfc/RFC-0001.md) or their resource definition +2. All lifecycle operations MUST update timestamp fields where applicable 3. Except for the rollback-failure path in requirement 4, a lifecycle invocation that handles an error and completes through govctl's normal error path with a non-zero exit status MUST restore every governed artifact changed by that invocation byte-for-byte before returning 4. If an I/O or storage error prevents complete restoration, the invocation MUST return a non-zero exit status 5. The rollback-failure diagnostic MUST contain the term `rollback` 6. The rollback-failure diagnostic MUST state that governed-artifact restoration may be incomplete 7. The restoration baseline is the artifact content observed after the invocation acquires its exclusive write scope under [RFC-0004:C-SCOPE](../rfc/RFC-0004.md#rfc-0004c-scope) and before its first governed-artifact mutation 8. Unexpected process termination before normal error completion, host failure, and the rollback-failure path in requirement 4 are outside the lifecycle atomicity guarantee -9. All lifecycle verbs MUST support global `--dry-run` flag +9. All lifecycle operations MUST support global `--dry-run` flag 10. Invalid transitions MUST error with clear explanation of valid transitions **Rationale:** -Lifecycle verbs are resource-specific because: +Lifecycle operations are resource-specific because: - RFCs have status AND phase (two dimensions of state) - ADRs can be rejected (not applicable to RFCs) -- Work items have gate conditions (acceptance criteria) +- Work Items have gate conditions (acceptance criteria) +- Release correction has a newest-entry and expected-version boundary - Each resource has different valid transitions -Scoping these verbs to resources makes their applicability explicit and prevents confusion. Lifecycle atomicity defines the byte content observed when an invocation completes through its normal error path; it does not require a crash-recovery subsystem for the flat-file artifact store. +Scoping these operations to resources makes their applicability explicit and prevents confusion. Lifecycle atomicity defines the byte content observed when an invocation completes through its normal error path; it does not require a crash-recovery subsystem for the flat-file artifact store. > **Tags:** `cli`, `lifecycle` @@ -476,13 +505,15 @@ Behavior: Generates markdown documentation from source-of-truth governance artifacts. -Syntax: `govctl render [targets...] [--output-dir PATH] [--dry-run]` +Syntax: `govctl render [targets...] [--dry-run] [--force]` Behavior: - Renders RFCs from TOML to markdown (published) - Renders ADRs from TOML to markdown (local only) - Renders work items from TOML to markdown (local only) - Generates CHANGELOG.md from releases +- For changelog rendering without `--force`: updates the unreleased section and adds missing releases while preserving existing released sections +- For changelog rendering with `--force`: regenerates the complete changelog from current canonical release data and Work Items - Default: renders RFCs only - With targets: `rfc`, `adr`, `work`, `changelog`, `all` - MUST validate before rendering @@ -741,6 +772,43 @@ Search is discovery across the governance corpus, not a resource-specific CRUD o ## Changelog +### v0.12.1 (2026-07-15) + +Restore stable release creation syntax + +#### Fixed + +- Preserve govctl release <version> alongside release undo + +### v0.12.0 (2026-07-15) + +Make release commands consistently resource-first + +#### Added + +- Add guarded release undo + +#### Changed + +- Replace the legacy no-verb release syntax with release cut +- Exclude release records from universal CRUD verbs + +### v0.11.1 (2026-07-15) + +Align RFC lifecycle verb definitions + +#### Changed + +- Separate RFC finalization from deprecation in the lifecycle command contract + +### v0.11.0 (2026-07-15) + +Align lifecycle command semantics + +#### Changed + +- Align lifecycle verbs with version-scoped phases and pre-release Work Item reopening + ### v0.10.4 (2026-06-28) Clarify lifecycle failure atomicity boundary diff --git a/docs/rfc/RFC-0006.md b/docs/rfc/RFC-0006.md index 7532ec41..8a7f0aca 100644 --- a/docs/rfc/RFC-0006.md +++ b/docs/rfc/RFC-0006.md @@ -1,9 +1,9 @@ <!-- GENERATED: do not edit. Source: RFC-0006 --> -<!-- SIGNATURE: sha256:68bcdf220d7340985bc16432930ac4600bd1254f2a6cd9af53f2a45b571533ab --> +<!-- SIGNATURE: sha256:f071628fb57171cebfac2b1e777ecc91e6482fcd3b1fbbb40e5a84336f2bd9be --> # RFC-0006: Loop Execution Model -> **Version:** 0.5.0 | **Status:** normative | **Phase:** impl +> **Version:** 0.5.0 | **Status:** normative | **Phase:** stable --- diff --git a/docs/rfc/RFC-0007.md b/docs/rfc/RFC-0007.md index a2d23da7..d2ad424f 100644 --- a/docs/rfc/RFC-0007.md +++ b/docs/rfc/RFC-0007.md @@ -1,9 +1,9 @@ <!-- GENERATED: do not edit. Source: RFC-0007 --> -<!-- SIGNATURE: sha256:303da9a81b07039277740dbbb6e4012f89153de338c4a25998f0105ba52027db --> +<!-- SIGNATURE: sha256:838fbf4c7efbabb2b7a9d58c234a2c4cbb1efa714d913cdcc8aff7568031c4e1 --> # RFC-0007: TUI v2 read-only cockpit -> **Version:** 0.2.0 | **Status:** normative | **Phase:** test +> **Version:** 0.2.0 | **Status:** normative | **Phase:** stable **References:** [RFC-0003](../rfc/RFC-0003.md), [RFC-0006](../rfc/RFC-0006.md), [RFC-0002](../rfc/RFC-0002.md) diff --git a/gov/adr/ADR-0021-resource-scoped-render-commands-for-single-item-rendering.toml b/gov/adr/ADR-0021-resource-scoped-render-commands-for-single-item-rendering.toml index a1f48c95..cb60e42d 100644 --- a/gov/adr/ADR-0021-resource-scoped-render-commands-for-single-item-rendering.toml +++ b/gov/adr/ADR-0021-resource-scoped-render-commands-for-single-item-rendering.toml @@ -13,7 +13,6 @@ tags = ["cli"] [content] context = """ - The global `govctl render` command currently has an `--rfc-id` flag for rendering a single RFC, but no equivalent flags for ADRs or work items. This creates an asymmetry in the CLI. ### Problem Statement @@ -26,12 +25,6 @@ When users want to render a single artifact, the current options are: | ADR | (none) | ❌ No | | Work Item | (none) | ❌ No | -### Options Considered - -1. **Add `--adr-id` and `--work-id` flags** — Proliferates flags on global command -2. **Add resource-scoped render verbs** — Clean separation of bulk vs single -3. **Leave as-is** — Accept asymmetry - ### Constraints - [[RFC-0002:C-GLOBAL-COMMANDS]] defines `render` as a global command that "operates across all resources" diff --git a/gov/adr/ADR-0029-path-based-nested-field-addressing-for-artifact-edits.toml b/gov/adr/ADR-0029-path-based-nested-field-addressing-for-artifact-edits.toml index 6faacc59..c39b9cdd 100644 --- a/gov/adr/ADR-0029-path-based-nested-field-addressing-for-artifact-edits.toml +++ b/gov/adr/ADR-0029-path-based-nested-field-addressing-for-artifact-edits.toml @@ -38,13 +38,7 @@ What is missing is direct nested addressing, for example: update only the second - Preserve resource-first command architecture from [[RFC-0002]]. - Preserve verb semantics from [[ADR-0017]] instead of replacing everything with a new universal verb. - Reuse existing array matching behavior from [[ADR-0007]] where possible. -- Keep backward compatibility for existing scripts and habits. - -### Options Considered - -- Option 1: Keep current field-only operations and rely on manual TOML edits for nested changes. -- Option 2: Introduce a new universal `edit` command with JSONPath-like syntax. -- Option 3: Keep current verbs, but allow a path expression in the field position for nested addressing.""" +- Keep backward compatibility for existing scripts and habits.""" decision = """ We will **introduce path-based nested field addressing as an additive capability**, while keeping existing resource-scoped verbs (`set`, `add`, `remove`, `tick`) defined in [[ADR-0017]]. diff --git a/gov/adr/ADR-0030-parser-strategy-for-path-based-field-expressions.toml b/gov/adr/ADR-0030-parser-strategy-for-path-based-field-expressions.toml index 9b8d2078..30f85bd8 100644 --- a/gov/adr/ADR-0030-parser-strategy-for-path-based-field-expressions.toml +++ b/gov/adr/ADR-0030-parser-strategy-for-path-based-field-expressions.toml @@ -36,13 +36,7 @@ It does not decide broader CLI command architecture, which remains governed by [ - Maintain resource-first and verb-separated CLI semantics from [[RFC-0002]] and [[ADR-0017]]. - Keep compatibility commitments from [[ADR-0029]] for legacy dotted prefixes and short aliases. - Preserve deterministic diagnostic behavior (`E0814` to `E0818`) for automation and tests. -- Keep parser dependency and complexity proportionate to grammar size. - -### Options Considered - -- Option 1: Keep manual parser and harden it. -- Option 2: Use a parser combinator library (`winnow`) with a typed AST. -- Option 3: Use a PEG/grammar generator approach (`pest`/similar).""" +- Keep parser dependency and complexity proportionate to grammar size.""" decision = """ We will adopt **`winnow`-based strict parsing with a typed `FieldPath` AST**, replacing the ad-hoc character-walk parser for path expressions. diff --git a/gov/adr/ADR-0031-unified-artifact-edit-engine-with-ssot-and-format-adapters.toml b/gov/adr/ADR-0031-unified-artifact-edit-engine-with-ssot-and-format-adapters.toml index 80ca7d94..3220cf9c 100644 --- a/gov/adr/ADR-0031-unified-artifact-edit-engine-with-ssot-and-format-adapters.toml +++ b/gov/adr/ADR-0031-unified-artifact-edit-engine-with-ssot-and-format-adapters.toml @@ -39,13 +39,7 @@ This increases code volume, review difficulty, and risk of behavioral drift betw - Preserve index and matcher semantics from [[ADR-0007]] (0-based indexing, negative indices from end, existing conflict diagnostics). - Preserve user-facing path syntax and compatibility promises from [[ADR-0029]] and [[ADR-0030]]. - Respect storage decisions from [[ADR-0001]]: ADR/Work artifacts remain TOML at rest unless separately decided. -- Keep deterministic diagnostics and stable error codes for automation scripts. - -### Options Considered - -- Option A: Continue incremental cleanup of current architecture. -- Option B: Standardize internal editing on JSON only (convert TOML at boundaries, no dedicated TOML adapter contract). -- Option C: Introduce a single SSOT-driven semantic edit engine with explicit JSON/TOML adapters.""" +- Keep deterministic diagnostics and stable error codes for automation scripts.""" decision = """ We will adopt **Option C: a single SSOT-driven semantic edit engine with explicit format adapters**. @@ -76,21 +70,7 @@ Field-token acceptance is strict: - Existing path syntax and legacy forms from [[ADR-0029]] remain supported during migration. - Canonical names take precedence over aliases on conflict. - Documentation will prefer canonical path syntax; legacy forms are compatibility-only. -- Legacy form deprecation warnings begin after full V2 parity is reached. - -### Migration Plan - -1. **Phase 1 (foundation):** Introduce V2 SSOT, parser, typed `EditPlan`, and adapter interfaces behind feature-gated path. -2. **Phase 2 (shadow mode):** Run V1 and V2 for ADR/Work operations; compare planned ops and rendered outcomes in tests. -3. **Phase 3 (cutover):** Switch ADR/Work to V2, then RFC/Clause. -4. **Phase 4 (cleanup):** Remove V1 dispatch branches, remove hand-written artifact/field/verb matches, tighten lint/test gates. - -### Completion Criteria - -V2 is considered complete only when: -- adding a new editable field requires SSOT changes only (no manual dispatch branching) -- JSON and TOML share one semantic engine -- compatibility test suite for legacy paths passes in V2 mode""" +- Legacy form deprecation warnings begin after full V2 parity is reached.""" consequences = """ ### Positive diff --git a/gov/adr/ADR-0034-use-toml-as-the-canonical-storage-format-for-all-governance-artifacts.toml b/gov/adr/ADR-0034-use-toml-as-the-canonical-storage-format-for-all-governance-artifacts.toml index f756d8cd..31b3454b 100644 --- a/gov/adr/ADR-0034-use-toml-as-the-canonical-storage-format-for-all-governance-artifacts.toml +++ b/gov/adr/ADR-0034-use-toml-as-the-canonical-storage-format-for-all-governance-artifacts.toml @@ -32,13 +32,7 @@ Finally, existing repositories already contain JSON RFC and clause files. If we - Preserve the normative meaning of existing governance artifacts and references under [[RFC-0000]] and [[RFC-0002]]. - Keep normal operation simple: after migration, the main load/edit/render paths should be TOML-only. - Respect [[ADR-0032]] by keeping this migration command narrow and deterministic. This is not a heuristic project-adoption workflow. -- Preserve auditability and deterministic failures for automation and agent workflows. - -### Options Considered - -- Keep the mixed JSON/TOML storage model and improve adapters further. -- Standardize all governance artifacts on JSON instead. -- Standardize all governance artifacts on TOML with an explicit repository migration step.""" +- Preserve auditability and deterministic failures for automation and agent workflows.""" decision = """ We will **use TOML as the canonical on-disk source-of-truth format for governance artifacts** because it removes a persistent storage-format special case, matches the existing direction of ADR/work/release storage, and gives humans a format they can read and edit without carrying JSON-only baggage forward. @@ -47,16 +41,7 @@ We will **use TOML as the canonical on-disk source-of-truth format for governanc 3. **One compatibility boundary:** Legacy JSON support and deterministic TOML shape upgrades will exist only inside a migration command that converts existing JSON RFC and clause files to TOML and applies govctl-managed structural upgrades such as release-file metadata normalization. 4. **One validation model:** RFC, clause, ADR, work item, and release artifacts will each have a corresponding machine-readable JSON Schema, and generated or edited TOML will be validated against that schema after parsing and normalization. -### Implementation Notes - -- RFC and clause definitions should converge on the same `[govctl]` metadata pattern already used by ADRs and work items, including a required `schema` field. -- `gov/releases.toml` should stop being a schema-less special case. It should carry explicit file-level metadata, including a required `schema` field, alongside the `[[releases]]` collection. -- The schema contract should be explicit rather than implied: one JSON Schema file per artifact type, stored under `gov/schema/`, with the TOML `govctl.schema` field selecting the corresponding version. -- The migration command should be narrow by design: convert legacy JSON governance files already recognized by govctl. It must not become a general project-discovery tool or overlap with the broader migration skill from [[ADR-0032]]. -- Post-migration normal operation should not keep a permanent dual-read compatibility layer. Legacy JSON handling belongs in the migration path, not in every loader and writer. -- Before migration, normal commands should fail with an explicit migration-required diagnostic rather than guessing or partially operating on mixed-format repositories. -- The migration command should rewrite only govctl-managed storage references. Arbitrary repo docs, tests, and scripts remain user-owned follow-up changes. -- If accepted, this decision supersedes the storage split recorded in [[ADR-0001]].""" +This decision supersedes the storage split recorded in [[ADR-0001]].""" consequences = """ ### Positive diff --git a/gov/adr/ADR-0037-canonical-edit-surface-for-nested-artifact-mutation.toml b/gov/adr/ADR-0037-canonical-edit-surface-for-nested-artifact-mutation.toml index 61847eb0..cb9ba7ce 100644 --- a/gov/adr/ADR-0037-canonical-edit-surface-for-nested-artifact-mutation.toml +++ b/gov/adr/ADR-0037-canonical-edit-surface-for-nested-artifact-mutation.toml @@ -34,13 +34,7 @@ The current CLI surface is still too shape-dependent for reliable agent use: - Preserve the SSOT-driven engine direction from [[ADR-0031]]. - Keep a stable migration path for existing human users and scripts. - Make the canonical mutation interface regular enough that agents can synthesize commands from field paths without artifact-specific guessing. -- Avoid introducing a new batch-specific input language unless single-operation ergonomics prove insufficient. - -### Options Considered - -- Option A: Keep `set/add/remove/tick` as the only mutation interface and continue expanding path support. -- Option B: Introduce a canonical `edit` command with path-first operation flags, and keep existing verbs as compatibility sugar. -- Option C: Replace existing verbs completely with a JSONPatch-like or DSL-heavy mutation language.""" +- Avoid introducing a new batch-specific input language unless single-operation ergonomics prove insufficient.""" decision = """ We will introduce a **canonical path-oriented edit surface** for governed artifact mutation: diff --git a/gov/adr/ADR-0038-keep-adr-schema-discussion-oriented-and-avoid-broad-migration.toml b/gov/adr/ADR-0038-keep-adr-schema-discussion-oriented-and-avoid-broad-migration.toml index 087e244a..fa57b310 100644 --- a/gov/adr/ADR-0038-keep-adr-schema-discussion-oriented-and-avoid-broad-migration.toml +++ b/gov/adr/ADR-0038-keep-adr-schema-discussion-oriented-and-avoid-broad-migration.toml @@ -12,7 +12,6 @@ refs = [ ] tags = ["schema"] - [content] context = """ We have two goals that now need to be balanced more carefully. @@ -30,11 +29,7 @@ We have two goals that now need to be balanced more carefully. - Preserve the canonical edit-surface work already captured in [[ADR-0037]]. - Preserve the discussion-oriented alternative model from [[ADR-0027]]. - Avoid a repository-wide ADR migration for a change that is not yet merged. -- Keep the ADR schema simple enough that humans and agents can both use it reliably. - -### Options Considered - -We considered keeping the full ADR-0036 redesign, keeping the current ADR schema and workflow, and adopting a smaller additive field for the selected option only.""" +- Keep the ADR schema simple enough that humans and agents can both use it reliably.""" decision = """ We will **keep the current ADR schema discussion-oriented and avoid the broad schema/migration redesign from [[ADR-0036]]** because: diff --git a/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml b/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml index 05198b81..b6e8d88c 100644 --- a/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml +++ b/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml @@ -11,7 +11,6 @@ refs = [ ] tags = ["schema"] - [content] context = """ As the govctl artifact corpus grows (currently 200+ artifacts), finding related artifacts by domain becomes difficult. Users resort to `grep` or memorizing IDs. @@ -25,11 +24,7 @@ There is no structured way to answer "show me everything related to caching" or - [[RFC-0002:C-RESOURCES]] defines the field surface for each artifact type — adding `tags` requires a schema amendment - [[RFC-0002:C-CRUD-VERBS]] governs how fields are mutated — tags must follow existing `add`/`remove` verb semantics - Tags must be diffable and reviewable in PRs (no hidden state) -- The system should prevent tag sprawl — typos and near-duplicates degrade signal - -### Options Considered - -Two tagging models: controlled vocabulary (registry-first) vs. free-form (tag-on-use). See alternatives for analysis.""" +- The system should prevent tag sprawl — typos and near-duplicates degrade signal""" decision = """ We will use a **controlled-vocabulary tag system** where tags must be registered in a project-level allowed list before any artifact can reference them. diff --git a/gov/adr/ADR-0049-adopt-read-only-cockpit-model-for-tui-v2.toml b/gov/adr/ADR-0049-adopt-read-only-cockpit-model-for-tui-v2.toml index eca3bfb8..7d26f3fd 100644 --- a/gov/adr/ADR-0049-adopt-read-only-cockpit-model-for-tui-v2.toml +++ b/gov/adr/ADR-0049-adopt-read-only-cockpit-model-for-tui-v2.toml @@ -27,22 +27,11 @@ The current TUI is primarily a dashboard plus RFC/ADR/work item browser. It does - [[RFC-0007]] defines TUI v2 behavior for a read-only cockpit. - [[RFC-0002]] owns CLI resource and search semantics. - [[RFC-0006]] owns loop state and loop execution semantics. -- First-phase TUI v2 must serve humans, not agents or machine parsing. - -### Options Considered - -We considered keeping the current TUI and adding isolated views, building a full CRUD TUI editor, and building a read-only human cockpit with loop DAG visualization.""" +- First-phase TUI v2 must serve humans, not agents or machine parsing.""" decision = """ We will **build TUI v2 as a read-only human cockpit with loop DAG visualization**. -The first phase of TUI v2 will focus on human understanding: project overview, artifact browsing, search, persisted loop state, dependency DAGs, and diagnostics. It will not implement artifact editing, lifecycle transitions, loop execution, migration, rendering, or other mutations. - -### Implementation Notes - -- TUI v2 may show suggested CLI commands for mutating workflows, but it must not execute those mutations. -- Loop DAG rendering should use persisted loop state from [[RFC-0006]] rather than recomputing a separate execution model. -- Search behavior should reuse [[RFC-0002:C-SEARCH-COMMAND]] semantics instead of introducing TUI-only query behavior. -- Responsive terminal layouts should degrade to readable simpler views rather than preserving broken multi-pane layouts.""" +The first phase of TUI v2 will focus on human understanding: project overview, artifact browsing, search, persisted loop state, dependency DAGs, and diagnostics. It will not implement artifact editing, lifecycle transitions, loop execution, migration, rendering, or other mutations.""" consequences = """ ### Positive diff --git a/gov/adr/ADR-0050-preserve-direct-clause-supersession-chains.toml b/gov/adr/ADR-0050-preserve-direct-clause-supersession-chains.toml index d7cf4756..1e9eb6b0 100644 --- a/gov/adr/ADR-0050-preserve-direct-clause-supersession-chains.toml +++ b/gov/adr/ADR-0050-preserve-direct-clause-supersession-chains.toml @@ -27,11 +27,7 @@ The stored relation needs an unambiguous meaning that supports repeated clause e - Supersession history must remain auditable. - A lifecycle transition should update only the clause being transitioned. - Malformed references and cycles must remain detectable. -- Qualified clause IDs already provide an unambiguous representation for cross-RFC references. - -### Options Considered - -The discussion considered preserving direct historical edges, flattening all predecessors to the latest replacement, and retaining the active-target invariant that prevents chains.""" +- Qualified clause IDs already provide an unambiguous representation for cross-RFC references.""" decision = """ We will preserve each `superseded_by` value as the direct replacement selected when its source clause was superseded. Later replacements extend the chain instead of rewriting earlier clauses because: diff --git a/gov/adr/ADR-0051-scope-terminal-lifecycle-states-to-revision-and-release-boundaries.toml b/gov/adr/ADR-0051-scope-terminal-lifecycle-states-to-revision-and-release-boundaries.toml new file mode 100644 index 00000000..47b6356f --- /dev/null +++ b/gov/adr/ADR-0051-scope-terminal-lifecycle-states-to-revision-and-release-boundaries.toml @@ -0,0 +1,104 @@ +#:schema ../schema/adr.schema.json + +[govctl] +id = "ADR-0051" +title = "Scope terminal lifecycle states to revision and release boundaries" +status = "accepted" +date = "2026-07-15" +refs = [ + "ADR-0014", + "ADR-0016", + "RFC-0000:C-PHASE-LIFECYCLE", + "RFC-0000:C-WORK-DEF", + "RFC-0000:C-RELEASE-DEF", + "RFC-0001:C-RFC-PHASE", + "RFC-0001:C-WORK-STATUS", + "RFC-0002:C-LIFECYCLE-VERBS", +] +tags = [ + "lifecycle", + "release", +] + +[content] +context = """ +[[RFC-0001]] v0.4.2 treated `stable` as terminal for an RFC, while [[ADR-0016]] allowed a normative RFC to evolve through versioned amendments. A content-bearing version bump therefore left the RFC marked `stable` even though the new revision had not passed implementation and test gates. + +[[RFC-0001]] v0.4.2 also treated `done` as terminal for a Work Item. That prevented correcting a mistaken completion before release. [[ADR-0014]] records release membership separately but describes every completed Work Item as immutable; this decision narrows that consequence by moving the immutability boundary from completion to release membership. + +### Problem Statement + +The terminal boundaries were attached to long-lived artifact identities instead of the revisions and release records they actually close. We need lifecycle semantics that preserve phase discipline and release history without introducing parallel version or reopening state machines. + +### Constraints + +- RFC amendments must continue to use the existing semantic version and changelog model. +- Phase progression within one RFC version must remain ordered. +- Released work must not re-enter the unreleased changelog under the same Work Item ID. +- Existing `phase`, Work Item `status`, timestamps, and release references should remain the storage model.""" +decision = """ +We will **scope terminal lifecycle states to the revision or release record they complete while retaining the existing stored fields**. + +For RFCs, `phase` describes the current version. A content-bearing version bump starts a new phase progression at `spec`; `stable` remains terminal only within the version that reached it. Changelog-only bookkeeping does not start a new phase progression. + +For Work Items, `done` represents a release-ready completion judgment. An unreleased done item may return to `active` so that judgment can be corrected. Once a release references the Work Item, release membership freezes that lifecycle record and later problems are represented by a new Work Item. + +This approach was chosen because: + +1. **Phase accuracy:** Each amended RFC version passes through the existing gates instead of inheriting an earlier version's `stable` result. +2. **Release integrity:** A Work Item cannot re-enter a later release under an ID already frozen in release history. +3. **Minimal state:** Existing phase, status, timestamp, and release-reference fields are sufficient; no parallel lifecycle representation is needed. + +This decision narrows [[ADR-0014]] only with respect to when Work Item immutability begins: release-referenced Work Items remain immutable lifecycle records, while completed but unreleased Work Items may be reopened.""" +consequences = """ +### Positive + +- Each RFC amendment re-enters the same visible phase discipline without a second version-state representation. +- Pre-release Work Item mistakes can be corrected without fragmenting one unit of work across duplicate items. +- Existing schemas, status values, and resource-first commands remain sufficient. +- Release references provide a deterministic boundary for preserving published history. + +### Negative + +- Even an editorial content bump restarts the RFC at `spec`. The friction is bounded to the existing sequential phase transitions, which remain automation-compatible and require no additional state. +- Reopening a Work Item requires reading release membership before changing its status. Release references are local canonical data, so the check adds no external dependency. +- The original completion date is no longer present in the current Work Item after reopening; version control retains that history. +- Historical RFC phase progression remains in version control rather than an artifact-local phase log; adding a second history model was rejected as disproportionate. + +### Neutral + +- Existing loop records remain local execution history and are not reopened when a Work Item returns to `active`. +- Problems found after release continue as new Work Items that can reference the released item.""" + +[[content.alternatives]] +text = "Keep stable and done terminal for the lifetime of their artifacts" +status = "rejected" +pros = ["Preserves the current state machines unchanged"] +cons = [ + "RFC phase becomes stale after a stable RFC is amended", + "Mistaken Work Item completion requires a duplicate follow-up before release", +] +rejection_reason = "Artifact-lifetime terminality conflicts with versioned RFC evolution and creates unnecessary Work Items for pre-release corrections" + +[[content.alternatives]] +text = "Add persistent per-version phase records and explicit reopened or released Work Item states" +status = "rejected" +pros = ["Makes every historical lifecycle transition explicit in artifact data"] +cons = [ + "Introduces two additional state models and migration requirements", + "Duplicates history already available from changelogs, release references, and version control", +] +rejection_reason = "The additional storage and transition surface is disproportionate to the two lifecycle corrections" + +[[content.alternatives]] +text = "Reuse the current phase and status fields, with RFC bumps and release membership as lifecycle boundaries" +status = "accepted" +pros = [ + "Preserves the existing artifact schemas and command vocabulary", + "Restarts phase discipline for each amended RFC version", + "Allows pre-release correction while keeping released Work Items frozen", +] +cons = [ + "Historical RFC phase transitions remain in version control rather than artifact-local records", + "Work Item mutability depends on a lookup in release references", +] diff --git a/gov/adr/ADR-0052-assign-adr-projection-structure-to-canonical-authoring-surfaces.toml b/gov/adr/ADR-0052-assign-adr-projection-structure-to-canonical-authoring-surfaces.toml new file mode 100644 index 00000000..a19f1098 --- /dev/null +++ b/gov/adr/ADR-0052-assign-adr-projection-structure-to-canonical-authoring-surfaces.toml @@ -0,0 +1,94 @@ +#:schema ../schema/adr.schema.json + +[govctl] +id = "ADR-0052" +title = "Assign ADR projection structure to canonical authoring surfaces" +status = "accepted" +date = "2026-07-15" +refs = [ + "RFC-0000:C-ADR-PROJECTION-OWNERSHIP", + "ADR-0003", + "ADR-0024", + "ADR-0027", + "ADR-0042", +] +tags = ["validation"] + +[content] +context = """ +The ADR model combines free-form Markdown content fields with structured metadata and alternatives that the renderer projects into a human-readable document. Existing authoring guidance allowed an options subsection in free-form context even though the renderer independently emitted the same discussion from structured alternatives. For example, a context field containing `### Options Considered` rendered alongside the generated `## Alternatives Considered` inventory. + +### Problem Statement + +When one semantic section can be authored through both prose and structured fields, a source artifact can remain schema-valid while its rendered projection repeats or contradicts itself. Writer prompts and isolated review can reduce this risk but cannot guarantee that it is caught before acceptance. + +### Constraints + +- [[RFC-0000:C-ADR-PROJECTION-OWNERSHIP]] defines the observable validation and acceptance behavior. +- Structured alternatives remain the discussion-oriented model established by [[ADR-0027]]. +- Rendered Markdown remains a projection of authoritative TOML under [[ADR-0003]]. +- Historical ADRs in external repositories must not require semantic rewriting merely to adopt a newer govctl release. +- Inline references in explanatory prose remain useful and are distinct from a generated reference inventory.""" +decision = """ +We will **assign every rendered ADR section to one canonical authoring surface and enforce that boundary before acceptance**. + +Free-form `context`, `decision`, and `consequences` fields own their explanatory body prose. Structured `refs` and `content.alternatives` own reference and option inventories, while the renderer owns the surrounding document headings and labels. + +A deterministic validator will inspect Markdown headings outside fenced code blocks. It will reject conflicts in proposed ADRs and in the acceptance path, where the existing force option remains limited to alternatives completeness. Historical terminal ADRs retain compatibility, while repositories may clean legacy duplication without changing the stored schema. + +This approach was chosen because: + +1. **Deterministic prevention:** Authoring and isolated review remain useful, but a machine gate prevents the known conflict from reaching acceptance. +2. **Single semantic owner:** Structured alternatives remain the options inventory instead of competing with free-form context. +3. **Proportional structure:** Heading-aware validation preserves useful prose and examples without expanding the ADR schema. +4. **Historical compatibility:** Enforcement closes new violations without requiring semantic rewrites of accepted external ADRs. + +Writer and reviewer guidance will mirror the same ownership boundary so agents receive one rule at authoring, review, and validation time.""" +consequences = """ +### Positive + +- Schema-valid ADR sources can no longer introduce new duplicate renderer-owned sections and still pass acceptance. +- Writer, reviewer, validator, and renderer use the same ownership model. +- Heading-aware parsing preserves code examples and inline references. +- Existing external ADR history remains adoptable without semantic migration. + +### Negative + +- Validation must understand enough Markdown structure to distinguish headings from fenced examples; the parser is intentionally limited to heading and fence recognition rather than general Markdown rendering. +- Historical ADR duplication is not automatically eliminated outside repositories that choose to clean it; compatibility is preferred over heuristic rewriting. +- A small set of heading names becomes unavailable for author-defined subsections; the diagnostic directs authors to the corresponding structured field or renderer-owned section. + +### Neutral + +- The ADR schema and renderer output format remain unchanged. +- The existing alternatives-first acceptance gate remains responsible for deliberation completeness. +- Repository-local cleanup of accepted ADR prose is editorial and does not alter prior decisions.""" + +[[content.alternatives]] +text = "Rely on writer and reviewer guidance only" +status = "rejected" +pros = ["No validation or migration work"] +cons = [ + "The same contradictory guidance can recur", + "Review remains probabilistic", +] +rejection_reason = "The failure mode already passed both authoring and review guidance" + +[[content.alternatives]] +text = "Reject projection conflicts in every ADR status immediately" +status = "rejected" +pros = ["Applies one invariant uniformly to the whole repository"] +cons = [ + "Existing external repositories can fail after upgrading", + "Historical prose may require subjective rewriting", +] +rejection_reason = "Uniform enforcement does not justify retroactive compatibility breakage" + +[[content.alternatives]] +text = "Enforce canonical ownership for proposed ADRs and acceptance while preserving historical compatibility" +status = "accepted" +pros = [ + "Prevents new conflicts at deterministic gates", + "Keeps structured alternatives as the sole options inventory", +] +cons = ["Historical ADRs can retain legacy duplication until intentionally cleaned"] diff --git a/gov/adr/ADR-0053-use-guarded-latest-only-undo-for-local-release-cuts.toml b/gov/adr/ADR-0053-use-guarded-latest-only-undo-for-local-release-cuts.toml new file mode 100644 index 00000000..db258201 --- /dev/null +++ b/gov/adr/ADR-0053-use-guarded-latest-only-undo-for-local-release-cuts.toml @@ -0,0 +1,104 @@ +#:schema ../schema/adr.schema.json + +[govctl] +id = "ADR-0053" +title = "Use guarded latest-only undo for local release cuts" +status = "accepted" +date = "2026-07-15" +refs = [ + "ADR-0014", + "ADR-0051", + "RFC-0000:C-RELEASE-DEF", + "RFC-0002:C-LIFECYCLE-VERBS", + "RFC-0004:C-SCOPE", +] +tags = [ + "release", + "lifecycle", + "safety", +] + +[content] +context = """ +[[ADR-0014]] models a release cut as a new entry prepended to `gov/releases.toml`, and [[ADR-0051]] uses release membership as the boundary that freezes completed Work Items. An accidental local cut therefore freezes its members even when the release record has not been used for an external publication. + +The release command records governance data only. It does not create or remove Git tags, hosted releases, or published packages. A correction mechanism should recover the immediately preceding local governance state without implying that external publication can be reversed. + +### Problem Statement + +We need a narrow way to correct the most recent accidental local release cut while preserving older release history and detecting stale operator intent. + +### Constraints + +- Existing release entries form a newest-first local history. +- Released Work Items remain frozen for as long as their release reference exists. +- Correction must not introduce release states, tombstones, or external-service side effects. +- The normal gov-root write lock and atomic file write remain the concurrency and persistence boundary.""" +decision = """ +We will **correct an accidental local release cut by removing only the newest release entry after matching an operator-supplied expected version** because: + +1. **Head-only correction:** Removing the newest entry restores the immediately preceding local release history without selecting or rewriting an older entry. +2. **Stale-intent rejection:** The expected version makes operator intent explicit and prevents delayed automation from undoing a newer release. +3. **Bounded meaning:** The operation changes local governance data only and does not claim to retract externally published software. + +This decision narrows [[ADR-0051]] only with respect to the lifetime of release membership: a Work Item is frozen while a release entry references it. Removing the newest entry through this correction removes that membership boundary but does not reopen or otherwise mutate the Work Item. + +Git tags, hosted releases, packages, and other publication systems remain outside govctl's release correction.""" +consequences = """ +### Positive + +- An accidental latest cut can be corrected without creating replacement Work Items. +- Older release history cannot be selected or rewritten by the correction command. +- Version matching makes stale invocations fail instead of undoing a newer release. +- Existing locking, release storage, and atomic-write mechanisms remain sufficient. + +### Negative + +- Once another release is cut, the earlier entry can no longer be corrected through this operation. The older record remains intact, and any corrective implementation is tracked by a new Work Item and later release rather than by rewriting history. +- Local correction does not retract anything already published externally, so operators must handle those systems separately. +- The canonical release file retains no tombstone for the removed cut. Version-control history is the audit trail for the cut and correction. + +### Neutral + +- Work Items referenced by the removed entry remain completed but become unreleased. +- No persistent release lifecycle state is introduced.""" + +[[content.alternatives]] +text = "Keep every release entry immutable and represent mistakes with later Work Items" +status = "rejected" +pros = ["Preserves the existing append-only interpretation"] +cons = ["Cannot correct an accidental local cut before publication"] +rejection_reason = "It keeps an operational mistake frozen even when no later local release depends on it" + +[[content.alternatives]] +text = "Allow deletion of any release entry" +status = "rejected" +pros = ["Can correct any selected local release record"] +cons = [ + "Rewrites non-head history and can invalidate every later release boundary", + "Makes stale or mistaken target selection more destructive", +] +rejection_reason = "Arbitrary historical deletion breaks the head-only rollback boundary and can invalidate later release history" + +[[content.alternatives]] +text = "Remove only the newest local release when its expected version matches" +status = "accepted" +pros = [ + "Restores the immediately preceding local state without rewriting older history", + "Uses the expected version as an explicit stale-intent guard", +] +cons = ["Cannot correct an older release after another release has been cut"] + +[[content.alternatives]] +text = "Remove the newest local release without requiring its expected version" +status = "rejected" +pros = ["Uses the smallest possible command input"] +cons = ["A delayed or repeated invocation can remove a newer release than the operator intended"] +rejection_reason = "Position alone cannot detect stale operator or automation intent" + +[[content.alternatives]] +text = "Retain corrected releases with a retracted status or tombstone" +status = "rejected" +pros = ["Preserves an artifact-local audit trail of the cut and correction"] +cons = ["Adds persistent release states and requires changelog and Work Item semantics for retracted membership"] +rejection_reason = "Version-control history provides the required audit trail without adding a second release lifecycle model" diff --git a/gov/config.toml b/gov/config.toml index 2949dddb..4c36fc7b 100644 --- a/gov/config.toml +++ b/gov/config.toml @@ -6,7 +6,7 @@ default_owner = "@govctl-org" name = "govctl" [schema] -version = 2 +version = 3 [source_scan] enabled = true diff --git a/gov/rfc/RFC-0000/clauses/C-ADR-PROJECTION-OWNERSHIP.toml b/gov/rfc/RFC-0000/clauses/C-ADR-PROJECTION-OWNERSHIP.toml new file mode 100644 index 00000000..d36990b2 --- /dev/null +++ b/gov/rfc/RFC-0000/clauses/C-ADR-PROJECTION-OWNERSHIP.toml @@ -0,0 +1,33 @@ +#:schema ../../../schema/clause.schema.json + +[govctl] +id = "C-ADR-PROJECTION-OWNERSHIP" +title = "ADR Projection Ownership" +kind = "normative" +status = "active" +since = "1.4.0" + +[content] +text = """ +ADR source fields and rendered projections MUST have one canonical owner for each semantic section. + +The ADR renderer owns the artifact title and the fixed `Context`, `Decision`, `Consequences`, and `Alternatives Considered` section headings. The structured `refs` field owns the rendered reference inventory. Structured `content.alternatives` entries own their generated alternative headings and trade-off labels. + +ADR content fields MUST contain section body prose rather than reproducing renderer-owned structure. + +A conflicting heading is a Markdown heading recognized by CommonMark parsing outside a code block whose visible text, after surrounding whitespace is removed, matches one of the following using ASCII case-insensitive comparison: + +- the rendered artifact title `<ADR-ID>: <title>`; +- `Context`, `Decision`, `Consequences`, `References`, `Alternatives Considered`, or the semantic alias `Options Considered`; +- a heading generated for a structured alternative: its `text`, followed by ` (accepted)` or ` (rejected)` when that status suffix applies. + +Project validation MUST inspect `content.context`, `content.decision`, and `content.consequences` in proposed ADRs for conflicting headings. Project validation MUST report a validation error for each conflict. Each diagnostic MUST identify the content field. Each diagnostic MUST identify the conflicting visible heading text. + +ADR acceptance MUST apply the same projection-ownership validation before changing status. A force option that bypasses alternatives-completeness checks MUST NOT bypass projection-ownership validation. + +Project validation MAY omit projection-ownership diagnostics for ADRs that are no longer proposed so that historical artifacts do not become invalid solely because govctl gained this validation. + +Inline references that support nearby prose remain valid. This clause does not prohibit contextual `[[...]]` references inside content fields. + +**Rationale:** +A single owner for each rendered section prevents structurally valid source fields from producing duplicated or contradictory human-readable ADRs. CommonMark heading events distinguish actual headings from examples in fenced code blocks and normalize inline formatting to visible text. Acceptance-time enforcement prevents a proposed violation from escaping validation by becoming historical.""" diff --git a/gov/rfc/RFC-0000/clauses/C-PHASE-LIFECYCLE.toml b/gov/rfc/RFC-0000/clauses/C-PHASE-LIFECYCLE.toml index f7db3147..185d57e7 100644 --- a/gov/rfc/RFC-0000/clauses/C-PHASE-LIFECYCLE.toml +++ b/gov/rfc/RFC-0000/clauses/C-PHASE-LIFECYCLE.toml @@ -6,24 +6,36 @@ title = "RFC Phase Lifecycle" kind = "normative" status = "active" since = "1.0.0" -tags = ["core", "lifecycle"] +tags = [ + "core", + "lifecycle", +] [content] text = """ -RFC phase follows this lifecycle: +RFC phase describes the current RFC version and follows this lifecycle within that version: spec → impl → test → stable -**spec**: Defining what will be built. No implementation work permitted. +**spec**: Defining the current version. No implementation work permitted. -**impl**: Building what was specified. Implementation proceeds per spec. +**impl**: Building what the current version specifies. -**test**: Verifying implementation matches specification. +**test**: Verifying implementation against the current version. -**stable**: Released for production use. +**stable**: Implementation and tests for the current version are complete. Phase rules: -- Phases MUST proceed in order; skipping is FORBIDDEN +- Phases within one version MUST proceed in order; skipping is FORBIDDEN - Each phase transition requires the previous phase gate to pass +- `stable` is terminal for one RFC version, not for the RFC across later version bumps +- A version bump that releases a detected content amendment MUST start the new version at `spec` +- Changelog-only updates and signature-baseline bookkeeping without a detected content amendment MUST preserve phase +- Phase progression MUST be rejected while a detected content amendment remains unversioned - draft + stable is FORBIDDEN (cannot stabilize unratified work) -- deprecated + impl/test is FORBIDDEN (no new work on deprecated specs)""" +- deprecated + impl/test is FORBIDDEN (no new work on deprecated specs) + +For amendment detection, RFC content is the canonical RFC and clause data other than RFC `version`, `phase`, `changelog`, and `signature` fields. Those four fields are lifecycle or bump bookkeeping and MUST be excluded from amendment comparison. An RFC without a stored amendment signature has no comparison baseline; establishing its first baseline MUST NOT by itself be treated as a detected content amendment. + +**Rationale:** +Scoping phase to one version lets later amendments reuse the existing ordered gates. Defining the comparison surface prevents phase progression and release bookkeeping from recursively appearing as new content amendments.""" diff --git a/gov/rfc/RFC-0000/clauses/C-RELEASE-DEF.toml b/gov/rfc/RFC-0000/clauses/C-RELEASE-DEF.toml index 80141987..6901e02b 100644 --- a/gov/rfc/RFC-0000/clauses/C-RELEASE-DEF.toml +++ b/gov/rfc/RFC-0000/clauses/C-RELEASE-DEF.toml @@ -6,11 +6,14 @@ title = "Release Definition" kind = "normative" status = "active" since = "1.0.2" -tags = ["core", "release"] +tags = [ + "core", + "release", +] [content] text = """ -A Release tracks a published version and the set of work items included in that version. +A Release is a local version-cut record that groups completed Work Items under a semantic version. A Release records local governance state; it is not proof that Git tags, hosted releases, packages, or other external artifacts were published. Release data MUST be stored as a TOML file at `gov/releases.toml` containing: - `[[releases]]` array with entries containing: `version`, `date`, `refs` @@ -21,11 +24,33 @@ Each `releases[].version` MUST be a valid semantic version. Each `releases[].date` MUST use ISO 8601 calendar date format `YYYY-MM-DD`. -Each `releases[].refs` entry MUST reference an existing Work Item ID. +Each `releases[].refs` entry MUST reference an existing Work Item ID whose status is `done`. -A release entry has no lifecycle and MUST be treated as immutable once created. +A Work Item ID MUST appear in the `refs` of at most one release. + +A release cut MUST include every Work Item whose status is `done` and which is not referenced by an existing release. It MUST reject the cut without modifying governed artifacts when no such Work Item exists. + +When a release cut omits `--date`, it MUST use the current calendar date in the host's local timezone. + +Release entries MUST be stored newest-first. Release creation MUST prepend the new entry to the `releases` array. + +A Work Item referenced by any release MUST remain `done`. Lifecycle commands MUST reject transitions away from `done` for that Work Item. + +Project validation MUST report a release reference to a Work Item whose status is not `done`. + +Project validation MUST report a Work Item ID referenced by more than one release. + +A release entry has no persistent lifecycle status. An existing entry MUST remain immutable except when the newest entry is removed through the guarded latest-release correction defined below. + +The latest-release correction MUST require an expected version. The correction MUST reject an empty release history or an expected version that does not exactly match the newest entry's version without modifying governed artifacts. + +A successful latest-release correction MUST remove only the newest release entry. It MUST NOT mutate the referenced Work Items; those items remain `done` and become unreleased because no release references them. + +The latest-release correction MUST mutate only canonical release data. It MUST NOT create, remove, or modify `CHANGELOG.md`, Git tags, hosted releases, or published packages. + +After a latest-release correction, `govctl render changelog --force` MUST derive release membership only from the current canonical release entries. The full projection MUST omit release versions absent from canonical release data and MUST classify as unreleased every `done` Work Item not referenced by a current release. Implementations MUST define a machine-readable JSON Schema for the release file structure and MUST validate `gov/releases.toml` against that schema during project validation. **Rationale:** -Releases are first-class governance artifacts because changelog generation and release membership depend on them. Giving releases a defined TOML structure and required JSON Schema removes the remaining schema-less storage special case.""" +Release membership is the immutability boundary for a completed Work Item while its release entry exists. Unique membership and a frozen `done` status preserve release history. Restricting correction to the newest entry restores the immediately preceding local state without permitting arbitrary history rewriting, while the expected-version check rejects stale operator intent.""" diff --git a/gov/rfc/RFC-0000/clauses/C-SUMMARY.toml b/gov/rfc/RFC-0000/clauses/C-SUMMARY.toml index 63a47da7..a97d5b31 100644 --- a/gov/rfc/RFC-0000/clauses/C-SUMMARY.toml +++ b/gov/rfc/RFC-0000/clauses/C-SUMMARY.toml @@ -16,7 +16,7 @@ govctl is a governance CLI that manages six artifact types: - **Clauses**: Individual requirements within RFCs - **ADRs**: Architectural Decision Records documenting design choices - **Work Items**: Units of work tracking implementation progress -- **Releases**: Published versions and the work items included in each version +- **Releases**: Local version-cut records and the Work Items included in each version - **Verification Guards**: Reusable executable checks that turn completion requirements into auditable pass/fail gates -All artifacts follow explicit lifecycle states, phase gates, or immutability rules to ensure disciplined development.""" +All artifacts follow explicit lifecycle states, phase gates, or constrained mutation rules to ensure disciplined development.""" diff --git a/gov/rfc/RFC-0000/clauses/C-WORK-DEF.toml b/gov/rfc/RFC-0000/clauses/C-WORK-DEF.toml index 5cf6d6ac..c2e8dd42 100644 --- a/gov/rfc/RFC-0000/clauses/C-WORK-DEF.toml +++ b/gov/rfc/RFC-0000/clauses/C-WORK-DEF.toml @@ -10,14 +10,17 @@ tags = ["core"] [content] text = """ -A Work Item tracks a unit of work from inception to completion. +A Work Item tracks a unit of work from inception through a release-eligible completion. Release membership freezes its lifecycle status. Every Work Item MUST be stored as a TOML file containing: -- `[govctl]` section with: `id`, `title`, `status`, `created`, `refs` -- optional `[govctl]` fields: `started`, `completed` -- `[content]` section with: `description`, `acceptance_criteria`, `notes` +- `[govctl]` section with required fields: `id`, `title`, `status`, `created` +- optional `[govctl]` fields: `started`, `completed`, `refs` +- `[content]` section with required field: `description` +- optional `[content]` fields: `acceptance_criteria`, `notes` - optional `[verification]` section with: `required_guards`, `waivers` +Omitted list-valued fields `refs`, `acceptance_criteria`, `notes`, `verification.required_guards`, and `verification.waivers` MUST be interpreted as empty lists. + Format evolution is tracked by the project-level `[schema] version` in `gov/config.toml`, not per-artifact fields. Implementations MUST define a machine-readable JSON Schema for the Work Item file structure and MUST validate Work Item files against that schema during project validation. @@ -34,17 +37,29 @@ Waiver entries MUST reference only guards that appear in the work item's effecti A waiver MUST suppress only the named guard. A Work Item MUST NOT disable the verification system globally. -Work Item status lifecycle: +Work Item status transitions: - queue → active → done - ↘ ↘ cancelled + queue → active + queue → cancelled + active → done + active → cancelled + done → active (unreleased only) **queue**: Planned but not started. **active**: Currently in progress. Only one active item recommended per focus area. -**done**: Completed. All acceptance criteria met, and any required verification guards have passed or been explicitly waived. +**done**: Completed and eligible for release. All acceptance criteria met, and any required verification guards have passed or been explicitly waived. **cancelled**: Abandoned from queue or active. Reason documented in notes. -A Work Item MUST NOT transition to done if any acceptance_criteria are pending.""" +A Work Item MUST contain at least one acceptance criterion before it transitions to done. + +A Work Item MUST NOT transition to done if any acceptance criteria are pending. + +A done Work Item that is not referenced by a release MUST be allowed to return to active. This transition MUST preserve `started` and remove `completed`. + +A Work Item referenced by a release MUST remain done. + +**Rationale:** +An unreleased completion is a correctable judgment. Release membership supplies the immutable lifecycle boundary without adding another persisted Work Item status. Omitting empty list fields keeps the TOML representation minimal without changing the logical data model.""" diff --git a/gov/rfc/RFC-0000/rfc.toml b/gov/rfc/RFC-0000/rfc.toml index 67f3b859..03b466d4 100644 --- a/gov/rfc/RFC-0000/rfc.toml +++ b/gov/rfc/RFC-0000/rfc.toml @@ -3,19 +3,19 @@ [govctl] id = "RFC-0000" title = "govctl Governance Framework" -version = "1.3.3" +version = "1.5.0" status = "normative" phase = "stable" owners = ["@govctl-org"] created = "2026-01-17" -updated = "2026-06-11" +updated = "2026-07-15" tags = [ "core", "schema", "validation", "lifecycle", ] -signature = "ba16714af2ca8c1b32b812963d4b02da4c67a647eafc1c88b03fa622085b1205" +signature = "a8dbcc3587e2f2f2b548c3cf33a3a684c0f5b329c12eeb8433b4f491dc6541d7" [[sections]] title = "Summary" @@ -36,7 +36,10 @@ clauses = ["clauses/C-CLAUSE-DEF.toml"] [[sections]] title = "ADR Specification" -clauses = ["clauses/C-ADR-DEF.toml"] +clauses = [ + "clauses/C-ADR-DEF.toml", + "clauses/C-ADR-PROJECTION-OWNERSHIP.toml", +] [[sections]] title = "Work Item Specification" @@ -50,6 +53,35 @@ clauses = ["clauses/C-RELEASE-DEF.toml"] title = "Verification Guard Specification" clauses = ["clauses/C-GUARD-DEF.toml"] +[[changelog]] +version = "1.5.0" +date = "2026-07-15" +notes = "Define guarded correction for local release cuts" +added = ["Define guarded newest-release correction and canonical changelog projection"] +changed = ["Define Releases as local version-cut records"] + +[[changelog]] +version = "1.4.2" +date = "2026-07-15" +notes = "Clarify optional Work Item verification lists" +changed = ["Define omitted verification guard and waiver lists as empty"] + +[[changelog]] +version = "1.4.1" +date = "2026-07-15" +notes = "Clarify Work Item serialized field requirements" +changed = ["Allow empty Work Item list fields to be omitted while requiring created metadata and completion criteria"] + +[[changelog]] +version = "1.4.0" +date = "2026-07-15" +notes = "Clarify lifecycle and ADR projection boundaries" +added = ["Define ADR projection ownership validation"] +changed = [ + "Scope RFC phase to each version", + "Freeze Work Items at unique release membership", +] + [[changelog]] version = "1.3.3" date = "2026-06-11" diff --git a/gov/rfc/RFC-0001/clauses/C-RFC-PHASE.toml b/gov/rfc/RFC-0001/clauses/C-RFC-PHASE.toml index b9d91109..0fc84a7d 100644 --- a/gov/rfc/RFC-0001/clauses/C-RFC-PHASE.toml +++ b/gov/rfc/RFC-0001/clauses/C-RFC-PHASE.toml @@ -10,21 +10,28 @@ tags = ["lifecycle"] [content] text = """ -An RFC MUST have exactly one of the following phase values: +An RFC MUST have exactly one of the following phase values. The phase describes conformance work for the RFC's current version: 1. **spec** — Specification phase. The RFC text is being written. No implementation work. -2. **impl** — Implementation phase. Code is being written to conform to the RFC. -3. **test** — Testing phase. Implementation is complete; tests are being written and validated. -4. **stable** — Stable phase. Implementation and tests are complete. No further changes expected. +2. **impl** — Implementation phase. Code is being written to conform to the current RFC version. +3. **test** — Testing phase. Implementation is complete; tests are being written and validated against the current RFC version. +4. **stable** — Stable phase. Implementation and tests for the current RFC version are complete. -Valid transitions (forward only, via `advance` command): +Within one RFC version, valid transitions are forward only via the `advance` command: - spec → impl - impl → test - test → stable -Invalid transitions (MUST be rejected): +Within one RFC version, the following transitions MUST be rejected: - Any backward transition (e.g., impl → spec) - Any skip (e.g., spec → test, spec → stable) -- stable → any (stable is terminal for phase) +- stable → any (`stable` is terminal for that version) -Rationale: Phase discipline ensures specification precedes implementation, and testing validates implementation before stabilization.""" +A version bump that releases a detected RFC or clause content amendment MUST start the new RFC version in `spec`, regardless of the prior version's phase. This starts a new version lifecycle and is not a backward transition within one version. + +A changelog-only update MUST preserve the current phase. Establishing an amendment signature baseline without a detected content amendment MUST also preserve the current phase. + +Amendment content and bookkeeping fields are defined by [[RFC-0000:C-PHASE-LIFECYCLE]]. When an RFC has a stored amendment signature and its amendment content differs from that signature, `advance` MUST reject phase progression until the amendment is released by a version bump. + +**Rationale:** +Scoping phase to the current RFC version preserves ordered specification, implementation, and testing gates while allowing the RFC itself to evolve through versioned amendments.""" diff --git a/gov/rfc/RFC-0001/clauses/C-WORK-STATUS.toml b/gov/rfc/RFC-0001/clauses/C-WORK-STATUS.toml index 9fa629e3..5e97eb06 100644 --- a/gov/rfc/RFC-0001/clauses/C-WORK-STATUS.toml +++ b/gov/rfc/RFC-0001/clauses/C-WORK-STATUS.toml @@ -14,22 +14,34 @@ A Work Item MUST have exactly one of the following status values: 1. **queue** — Initial state. The work item is defined but not yet started. 2. **active** — The work item is currently being worked on. -3. **done** — The work item is complete. All acceptance criteria are satisfied, and any required verification guards have passed or been explicitly waived. +3. **done** — The work item is complete and eligible for release. All acceptance criteria are satisfied, and any required verification guards have passed or been explicitly waived. 4. **cancelled** — The work item was abandoned. No further work will be done. -Valid transitions (via `move` command): +Valid transitions via the `move` command are: - queue → active (start work) - queue → cancelled (abandon before starting) - active → done (complete work) - active → cancelled (abandon in progress) +- done → active when no release references the Work Item (correct a pre-release completion) -Invalid transitions (MUST be rejected): -- done → any (done is terminal) -- cancelled → any (cancelled is terminal) +The following transitions MUST be rejected: +- done → active when any release references the Work Item +- done → queue or cancelled +- cancelled → any (`cancelled` is terminal) - queue → done (cannot complete without being active) - active → queue (no "un-start") +Release membership is derived from `releases[].refs`; it does not add another Work Item status. A released Work Item MUST remain `done`. + Timestamp behavior: -- queue → active: Sets `started` date if not already set -- active → done: Sets `completed` date -- active → cancelled: Sets `completed` date""" +- queue → active: Sets `started` if it is not already set +- active → done: Sets `completed` +- active → cancelled: Sets `completed` +- done → active: Preserves `started` and removes `completed` + +Returning a Work Item to `done` after reopening MUST apply the same acceptance-criteria and verification-guard gates as any other active → done transition. + +Reopening a Work Item MUST NOT mutate existing loop state or round artifacts. Existing loop-local `done`, `failed`, and `cancelled` outcomes remain terminal according to [[RFC-0006:C-WORK-ITEM-INTERACTION]], [[RFC-0006:C-LOOP-RESUMPTION]], and [[RFC-0006:C-LOOP-SCOPE-MUTATION]]. A reopened Work Item can be executed through a new loop or explicit non-terminal loop scope according to those clauses. + +**Rationale:** +Completion remains correctable until release membership freezes the lifecycle record. Clearing `completed` on reopening keeps status and timestamp semantics consistent, while release membership prevents the same Work Item ID from re-entering later release collection. Keeping loop outcomes separate preserves existing execution audit history.""" diff --git a/gov/rfc/RFC-0001/rfc.toml b/gov/rfc/RFC-0001/rfc.toml index ed28c271..365a13f4 100644 --- a/gov/rfc/RFC-0001/rfc.toml +++ b/gov/rfc/RFC-0001/rfc.toml @@ -3,17 +3,17 @@ [govctl] id = "RFC-0001" title = "Lifecycle State Machines" -version = "0.4.2" +version = "0.5.0" status = "normative" phase = "stable" owners = ["@govctl-org"] created = "2026-01-17" -updated = "2026-06-28" +updated = "2026-07-15" tags = [ "core", "lifecycle", ] -signature = "9bf9e3a853547590af3b22db2ad5c8ae4296dae6b79fe652a8a2cea9ba5085a1" +signature = "589db866e0b760a4885fce03ea0f2f0a01709ea75959eacdf09c32d105f7632f" [[sections]] title = "Summary" @@ -30,6 +30,15 @@ clauses = [ "clauses/C-GATE-CONDITIONS.toml", ] +[[changelog]] +version = "0.5.0" +date = "2026-07-15" +notes = "Scope terminal states to versions and releases" +changed = [ + "Restart phase progression for amended RFC versions", + "Allow unreleased done Work Items to return to active", +] + [[changelog]] version = "0.4.2" date = "2026-06-28" diff --git a/gov/rfc/RFC-0002/clauses/C-CRUD-VERBS.toml b/gov/rfc/RFC-0002/clauses/C-CRUD-VERBS.toml index 2d30884d..c57f46dc 100644 --- a/gov/rfc/RFC-0002/clauses/C-CRUD-VERBS.toml +++ b/gov/rfc/RFC-0002/clauses/C-CRUD-VERBS.toml @@ -13,7 +13,9 @@ tags = [ [content] text = """ -All resource types MUST support the following CRUD (Create, Read, Update, Delete) verbs where applicable: +All resource types MUST support the following CRUD (Create, Read, Update, Delete) verbs where applicable. + +Release records MUST NOT expose universal CRUD verbs. Their constrained creation and correction commands are defined by [[RFC-0002:C-LIFECYCLE-VERBS]]. **1. `new` - Create Resource** @@ -21,7 +23,7 @@ Syntax: `govctl <resource> new <arguments>` Creates a new instance of the resource. Required arguments vary by resource type but typically include a title or primary identifier. -MUST support for: rfc, adr, work, clause, release, guard +MUST support for: rfc, adr, work, clause, guard Behavior: - Generates unique ID if not provided - Initializes resource with default values @@ -36,13 +38,13 @@ Syntax: `govctl <resource> list [filter] [--tag <tag>[,<tag>...]]` Lists all instances of the resource type. Optional filter narrows results. -MUST support for: rfc, adr, work, clause, release, guard +MUST support for: rfc, adr, work, clause, guard Behavior: - Returns tabular output by default - Supports filtering (exact match or substring) - Sorted by ID (lexicographic order) - MUST respect [[RFC-0002:C-OUTPUT-FORMAT]] flags -- With `--tag`: filters results to artifacts that carry ALL specified tags. MUST support comma-separated tag values. Applies to rfc, clause, adr, work, and guard (releases do not carry tags). +- With `--tag`: filters results to artifacts that carry ALL specified tags. MUST support comma-separated tag values. Applies to rfc, clause, adr, work, and guard. **3. `get` - Read Resource** @@ -87,26 +89,26 @@ MUST NOT support for: rfc, adr (use lifecycle verbs instead) **Deletion Safety Constraints:** For guards: -- MUST verify no work items reference the guard ID in `verification.required_guards` or `verification.waivers[*].guard` before deletion +- MUST verify no Work Items reference the guard ID in `verification.required_guards` or `verification.waivers[*].guard` before deletion - MUST verify the guard is not listed in `verification.default_guards` in `gov/config.toml` before deletion - MUST error with list of referencing artifacts/config if any references exist For clauses: - MUST only allow deletion if containing RFC is in `draft` status - MUST verify no other artifacts reference the clause ID before deletion -- MUST error with list of referencing artifacts if references exist +- MUST error with list of referencing artifacts if any references exist -For work items: +For Work Items: - MUST only allow deletion if status is `queue` -- MUST verify no other artifacts reference the work item ID before deletion -- MUST error with list of referencing artifacts if references exist +- MUST verify no other artifacts reference the Work Item ID before deletion +- MUST error with list of referencing artifacts if any references exist **General Deletion Behavior:** - SHOULD require confirmation unless `--force` flag provided - MUST be atomic (either fully succeeds or fully fails) **Rationale:** -Permanent deletion breaks referential integrity. These constraints ensure deletions are safe and don't leave dangling references in the governance graph. +Permanent deletion breaks referential integrity. These constraints ensure deletions are safe and do not leave dangling references in the governance graph. Release mutation is narrower than universal CRUD because only the newest local cut can be corrected. **Consistency Requirements:** @@ -119,4 +121,4 @@ Permanent deletion breaks referential integrity. These constraints ensure deleti - MUST NOT use different verb names for the same operation (e.g., "show" vs "get") - MUST NOT reorder arguments between resource types (ID always comes before field) -- MUST NOT have resource-specific flags for universal operations. Flags that filter by a cross-resource metadata field (e.g., `--tag`) are permitted on resources that carry that field and silently ignored or unavailable on resources that do not.""" +- MUST NOT have resource-specific flags for universal operations. Flags that filter by a cross-resource metadata field (e.g., `--tag`) are permitted on resources that carry that field.""" diff --git a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml index 56f7ca90..27b15ab4 100644 --- a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml +++ b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml @@ -65,13 +65,15 @@ Behavior: Generates markdown documentation from source-of-truth governance artifacts. -Syntax: `govctl render [targets...] [--output-dir PATH] [--dry-run]` +Syntax: `govctl render [targets...] [--dry-run] [--force]` Behavior: - Renders RFCs from TOML to markdown (published) - Renders ADRs from TOML to markdown (local only) - Renders work items from TOML to markdown (local only) - Generates CHANGELOG.md from releases +- For changelog rendering without `--force`: updates the unreleased section and adds missing releases while preserving existing released sections +- For changelog rendering with `--force`: regenerates the complete changelog from current canonical release data and Work Items - Default: renders RFCs only - With targets: `rfc`, `adr`, `work`, `changelog`, `all` - MUST validate before rendering diff --git a/gov/rfc/RFC-0002/clauses/C-LIFECYCLE-VERBS.toml b/gov/rfc/RFC-0002/clauses/C-LIFECYCLE-VERBS.toml index 476d70a3..3e61eb47 100644 --- a/gov/rfc/RFC-0002/clauses/C-LIFECYCLE-VERBS.toml +++ b/gov/rfc/RFC-0002/clauses/C-LIFECYCLE-VERBS.toml @@ -2,7 +2,7 @@ [govctl] id = "C-LIFECYCLE-VERBS" -title = "Resource-Specific Lifecycle Verbs" +title = "Resource-Specific Lifecycle Operations" kind = "normative" status = "active" since = "0.1.0" @@ -13,32 +13,39 @@ tags = [ [content] text = """ -Resource-specific lifecycle verbs implement state transitions defined in [[RFC-0001]]. These verbs MUST be scoped to their resource namespace. +Resource-specific lifecycle and constrained-mutation operations implement state transitions defined in [[RFC-0001]] or mutation defined by their resource contract. These operations MUST be scoped to their resource namespace. -**RFC Lifecycle Verbs:** +**RFC Lifecycle Operations:** -1. `govctl rfc finalize <id> <normative|deprecated>` - - Implements [[RFC-0001:C-RFC-STATUS]] transitions - - draft → normative or draft → deprecated +1. `govctl rfc finalize <id> normative` + - Implements the draft → normative transition in [[RFC-0001:C-RFC-STATUS]] -2. `govctl rfc advance <id> <spec|impl|test|stable>` +2. `govctl rfc deprecate <id>` + - Implements the normative → deprecated transition in [[RFC-0001:C-RFC-STATUS]] + +3. `govctl rfc advance <id> <spec|impl|test|stable>` - Implements [[RFC-0001:C-RFC-PHASE]] transitions - - Forward-only phase progression + - Forward-only phase progression within the current RFC version + - MUST reject phase progression while a stored amendment signature differs from current amendment content as defined by [[RFC-0000:C-PHASE-LIFECYCLE]] -3. `govctl rfc bump <id> <patch|minor|major> -m <message>` +4. `govctl rfc bump <id> <patch|minor|major> -m <message>` - Version bumping per semantic versioning - Updates changelog automatically - Optional: `--change <description>` for additional changelog entries - - MUST reject a version bump when the RFC has a stored amendment signature and no RFC or clause content has changed since that signature - - MUST treat version, changelog, and signature metadata as bump bookkeeping, not RFC content changes + - MUST reject a version bump when the RFC has a stored amendment signature and no amendment content has changed since that signature + - MUST exclude RFC `version`, `phase`, `changelog`, and `signature` fields from amendment comparison + - MUST set phase to `spec` when the bump releases a detected RFC or clause content amendment - MAY establish a baseline amendment signature for RFCs that do not yet have one - - `--change` alone MUST remain changelog-only and MUST NOT make a later version bump valid + - MUST preserve phase when establishing a baseline amendment signature without a detected content amendment + - `--change` alone MUST remain changelog-only + - `--change` alone MUST preserve phase + - `--change` alone MUST NOT make a later version bump valid -4. `govctl rfc supersede <id> --by <replacement-id>` +5. `govctl rfc supersede <id> --by <replacement-id>` - Marks RFC as superseded by another RFC - Both RFCs must exist -**ADR Lifecycle Verbs:** +**ADR Lifecycle Operations:** 1. `govctl adr accept <id>` - Implements [[RFC-0001:C-ADR-STATUS]] proposed → accepted @@ -50,14 +57,26 @@ Resource-specific lifecycle verbs implement state transitions defined in [[RFC-0 - Implements [[RFC-0001:C-ADR-STATUS]] accepted → superseded - Both ADRs must exist -**Work Lifecycle Verbs:** +**Work Lifecycle Operations:** 1. `govctl work move <id> <queue|active|done|cancelled>` - Implements [[RFC-0001:C-WORK-STATUS]] transitions - - Validates gate conditions (e.g., acceptance criteria for done) - - Updates timestamp fields automatically + - Validates gate conditions when entering `done` + - MUST reject done → active when any release references the Work Item + - queue → active MUST set `started` when it is absent + - active → done MUST set `completed` + - active → cancelled MUST set `completed` + - done → active MUST preserve `started` + - done → active MUST remove `completed` + - done → active MUST NOT mutate loop state or round artifacts governed by [[RFC-0006:C-WORK-ITEM-INTERACTION]] + +**Release Lifecycle Operations:** + +1. The CLI MUST support `govctl release <version> [--date <YYYY-MM-DD>]`. The operation MUST create the newest local release entry according to [[RFC-0000:C-RELEASE-DEF]]. + +2. The CLI MUST support `govctl release undo <expected-version>`. The operation MUST correct the newest local release entry according to [[RFC-0000:C-RELEASE-DEF]]. -**Clause Lifecycle Verbs:** +**Clause Lifecycle Operations:** 1. `govctl clause deprecate <id>` - Implements [[RFC-0001:C-CLAUSE-STATUS]] active → deprecated @@ -68,23 +87,24 @@ Resource-specific lifecycle verbs implement state transitions defined in [[RFC-0 **Consistency Requirements:** -1. All lifecycle verbs MUST validate transitions per [[RFC-0001]] -2. All lifecycle verbs MUST update timestamp fields where applicable +1. All lifecycle operations MUST validate transitions per [[RFC-0001]] or their resource definition +2. All lifecycle operations MUST update timestamp fields where applicable 3. Except for the rollback-failure path in requirement 4, a lifecycle invocation that handles an error and completes through govctl's normal error path with a non-zero exit status MUST restore every governed artifact changed by that invocation byte-for-byte before returning 4. If an I/O or storage error prevents complete restoration, the invocation MUST return a non-zero exit status 5. The rollback-failure diagnostic MUST contain the term `rollback` 6. The rollback-failure diagnostic MUST state that governed-artifact restoration may be incomplete 7. The restoration baseline is the artifact content observed after the invocation acquires its exclusive write scope under [[RFC-0004:C-SCOPE]] and before its first governed-artifact mutation 8. Unexpected process termination before normal error completion, host failure, and the rollback-failure path in requirement 4 are outside the lifecycle atomicity guarantee -9. All lifecycle verbs MUST support global `--dry-run` flag +9. All lifecycle operations MUST support global `--dry-run` flag 10. Invalid transitions MUST error with clear explanation of valid transitions **Rationale:** -Lifecycle verbs are resource-specific because: +Lifecycle operations are resource-specific because: - RFCs have status AND phase (two dimensions of state) - ADRs can be rejected (not applicable to RFCs) -- Work items have gate conditions (acceptance criteria) +- Work Items have gate conditions (acceptance criteria) +- Release correction has a newest-entry and expected-version boundary - Each resource has different valid transitions -Scoping these verbs to resources makes their applicability explicit and prevents confusion. Lifecycle atomicity defines the byte content observed when an invocation completes through its normal error path; it does not require a crash-recovery subsystem for the flat-file artifact store.""" +Scoping these operations to resources makes their applicability explicit and prevents confusion. Lifecycle atomicity defines the byte content observed when an invocation completes through its normal error path; it does not require a crash-recovery subsystem for the flat-file artifact store.""" diff --git a/gov/rfc/RFC-0002/clauses/C-RESOURCE-MODEL.toml b/gov/rfc/RFC-0002/clauses/C-RESOURCE-MODEL.toml index 933c941d..0639c54b 100644 --- a/gov/rfc/RFC-0002/clauses/C-RESOURCE-MODEL.toml +++ b/gov/rfc/RFC-0002/clauses/C-RESOURCE-MODEL.toml @@ -10,7 +10,7 @@ tags = ["cli"] [content] text = """ -The govctl CLI MUST use a resource-first command structure of the form: +The govctl CLI MUST use a resource-first command structure. Except for the stable release-creation form defined below, resource operations MUST use: ``` govctl <resource> <verb> [arguments] [flags] @@ -36,7 +36,13 @@ The resource-first structure (noun-first) provides several advantages over verb- Global command exceptions are governed by [[RFC-0002:C-GLOBAL-COMMANDS]]. A command MAY remain at the global namespace when it operates across multiple resource types, performs project-level or CLI-level work, or manages local execution state that coordinates governed resources without becoming a governed artifact resource. -Resource-specific CRUD, field-editing, and lifecycle operations MUST remain resource-first unless an RFC explicitly defines a global command exception. +Resource-specific CRUD, field-editing, and lifecycle operations MUST remain resource-first unless an RFC explicitly defines an alternative grammar. + +Release creation is a stable alternative grammar. Implementations MUST support `govctl release <version> [--date <YYYY-MM-DD>]`. + +Release correction is a resource-first operation. Implementations MUST support `govctl release undo <expected-version>`. + +Both release forms MUST coexist. The token `undo` selects correction because it is not a valid semantic version; a semantic-version token selects release creation. **Implementation Note:** diff --git a/gov/rfc/RFC-0002/clauses/C-RESOURCES.toml b/gov/rfc/RFC-0002/clauses/C-RESOURCES.toml index a68d54f9..7a04870c 100644 --- a/gov/rfc/RFC-0002/clauses/C-RESOURCES.toml +++ b/gov/rfc/RFC-0002/clauses/C-RESOURCES.toml @@ -20,29 +20,29 @@ The following resource types MUST be supported as top-level command namespaces: Manages RFC specifications (normative documents defining system behavior). - ID Format: `RFC-NNNN` (e.g., RFC-0001) -- Lifecycle: draft → normative → deprecated (per RFC-0001:C-RFC-STATUS) -- Phase: spec → impl → test → stable (per RFC-0001:C-RFC-PHASE) +- Lifecycle: draft → normative → deprecated (per [[RFC-0001:C-RFC-STATUS]]) +- Phase: spec → impl → test → stable (per [[RFC-0001:C-RFC-PHASE]]) **2. `adr` - Architecture Decision Record** Manages ADRs (records of architectural decisions). - ID Format: `ADR-NNNN` (e.g., ADR-NNNN-style IDs) -- Lifecycle: proposed → accepted → superseded, or proposed → rejected (per RFC-0001:C-ADR-STATUS) +- Lifecycle: proposed → accepted → superseded, or proposed → rejected (per [[RFC-0001:C-ADR-STATUS]]) **3. `work` - Work Item** -Manages work items (tasks, features, bugs). +Manages Work Items (tasks, features, bugs). - ID Format: `WI-YYYY-MM-DD-NNN` (e.g., WI-YYYY-MM-DD-NNN) -- Lifecycle: queue → active → done, with cancellation at any stage (per RFC-0001:C-WORK-STATUS) +- Lifecycle: queue → active → done, with cancellation at any stage (per [[RFC-0001:C-WORK-STATUS]]) **4. `clause` - RFC Clause** Manages individual clauses within RFCs. - ID Format: `RFC-NNNN:C-NAME` (e.g., RFC-0001:C-SUMMARY) -- Lifecycle: active → deprecated → superseded (per RFC-0001:C-CLAUSE-STATUS) +- Lifecycle: active → deprecated → superseded (per [[RFC-0001:C-CLAUSE-STATUS]]) **Clause Namespace vs Storage:** @@ -52,19 +52,20 @@ The CLI namespace is independent of filesystem layout. Implementations MAY store **5. `guard` - Verification Guard** -Manages reusable executable completion checks defined by RFC-0000:C-GUARD-DEF. +Manages reusable executable completion checks defined by [[RFC-0000:C-GUARD-DEF]]. - ID Format: `GUARD-NAME` (e.g., GUARD-CARGO-TEST) - Storage: `gov/guard/` as individual TOML files - No lifecycle (guards are either present or removed) -**6. `release` - Release Version** +**6. `release` - Release Cut** -Manages published versions and their included work item references. +Manages local version-cut records and their included Work Item references. - ID Format: Semantic version (e.g., 1.0.0) -- Storage: `gov/releases.toml` as defined by RFC-0000:C-RELEASE-DEF -- No lifecycle (immutable once created) +- Storage: `gov/releases.toml` as defined by [[RFC-0000:C-RELEASE-DEF]] +- Mutation boundary: the newest entry may be corrected through `release undo`; all other entries are immutable +- No persistent lifecycle status **Resource Identification:** @@ -81,8 +82,8 @@ All date-valued resource metadata fields (such as `created`, `updated`, `started **Tags:** -RFCs, clauses, ADRs, work items, and guards MAY include an optional `tags` array in the `[govctl]` section. Each tag MUST be a string from the project's controlled vocabulary defined in `gov/config.toml` under `[tags] allowed`. Tags MUST match the pattern `[a-z][a-z0-9-]*` (lowercase kebab-case). Releases do not carry tags. `govctl check` MUST reject any artifact that references a tag not present in the allowed set. +RFCs, clauses, ADRs, Work Items, and guards MAY include an optional `tags` array in the `[govctl]` section. Each tag MUST be a string from the project's controlled vocabulary defined in `gov/config.toml` under `[tags] allowed`. Tags MUST match the pattern `[a-z][a-z0-9-]*` (lowercase kebab-case). Releases do not carry tags. `govctl check` MUST reject any artifact that references a tag not present in the allowed set. **Future Extensions:** -Additional resource types MAY be added via RFC amendment. New resource types MUST follow the same structural patterns defined in RFC-0002:C-CRUD-VERBS.""" +Additional resource types MAY be added via RFC amendment. New resource types MUST follow the same structural patterns defined in [[RFC-0002:C-CRUD-VERBS]].""" diff --git a/gov/rfc/RFC-0002/rfc.toml b/gov/rfc/RFC-0002/rfc.toml index 22ebffc7..1932acae 100644 --- a/gov/rfc/RFC-0002/rfc.toml +++ b/gov/rfc/RFC-0002/rfc.toml @@ -3,12 +3,12 @@ [govctl] id = "RFC-0002" title = "CLI Resource Model and Command Architecture" -version = "0.10.4" +version = "0.12.1" status = "normative" -phase = "test" +phase = "stable" owners = ["@govctl-org"] created = "2026-01-19" -updated = "2026-06-28" +updated = "2026-07-15" tags = [ "cli", "editing", @@ -16,7 +16,7 @@ tags = [ "validation", "release", ] -signature = "6a1b4f4f096cd9cd0d9b260e77ad08daf0a0a04874eab8953b4b78a1e8e5a1b7" +signature = "373fa81463fff75133a749849c8cbb8038507b84e55294bb8e29ebe56607dc82" [[sections]] title = "Summary" @@ -36,6 +36,34 @@ clauses = [ "clauses/C-SEARCH-COMMAND.toml", ] +[[changelog]] +version = "0.12.1" +date = "2026-07-15" +notes = "Restore stable release creation syntax" +fixed = ["Preserve govctl release <version> alongside release undo"] + +[[changelog]] +version = "0.12.0" +date = "2026-07-15" +notes = "Make release commands consistently resource-first" +added = ["Add guarded release undo"] +changed = [ + "Replace the legacy no-verb release syntax with release cut", + "Exclude release records from universal CRUD verbs", +] + +[[changelog]] +version = "0.11.1" +date = "2026-07-15" +notes = "Align RFC lifecycle verb definitions" +changed = ["Separate RFC finalization from deprecation in the lifecycle command contract"] + +[[changelog]] +version = "0.11.0" +date = "2026-07-15" +notes = "Align lifecycle command semantics" +changed = ["Align lifecycle verbs with version-scoped phases and pre-release Work Item reopening"] + [[changelog]] version = "0.10.4" date = "2026-06-28" diff --git a/gov/rfc/RFC-0003/rfc.toml b/gov/rfc/RFC-0003/rfc.toml index fd034641..b02a7365 100644 --- a/gov/rfc/RFC-0003/rfc.toml +++ b/gov/rfc/RFC-0003/rfc.toml @@ -10,6 +10,7 @@ owners = ["@govctl-org"] created = "2026-02-07" updated = "2026-03-17" tags = ["tui"] +signature = "d69ba7390136f9dce9061b48457c15681b0e0114aebd4d422381ceae0f0e80b7" [[sections]] title = "Summary" diff --git a/gov/rfc/RFC-0004/rfc.toml b/gov/rfc/RFC-0004/rfc.toml index 40e7e29c..70164d11 100644 --- a/gov/rfc/RFC-0004/rfc.toml +++ b/gov/rfc/RFC-0004/rfc.toml @@ -10,6 +10,7 @@ owners = ["@govctl-org"] created = "2026-02-15" updated = "2026-03-17" tags = ["safety"] +signature = "1ca183a0fdad049793ebc144d7ef397a8a5cc5aa98819416cb2f43bd47ad8762" [[sections]] title = "Summary" diff --git a/gov/rfc/RFC-0006/rfc.toml b/gov/rfc/RFC-0006/rfc.toml index efe552fd..f417469a 100644 --- a/gov/rfc/RFC-0006/rfc.toml +++ b/gov/rfc/RFC-0006/rfc.toml @@ -5,11 +5,11 @@ id = "RFC-0006" title = "Loop Execution Model" version = "0.5.0" status = "normative" -phase = "impl" +phase = "stable" owners = ["@govctl-org"] created = "2026-05-31" updated = "2026-06-15" -signature = "68bcdf220d7340985bc16432930ac4600bd1254f2a6cd9af53f2a45b571533ab" +signature = "7a83e5455d89c552aaf6dc6b955dd52d1a82126eb8fe49eee7f670c3749772a0" [[sections]] title = "Summary" diff --git a/gov/rfc/RFC-0007/rfc.toml b/gov/rfc/RFC-0007/rfc.toml index d7ac46ff..ed5f2237 100644 --- a/gov/rfc/RFC-0007/rfc.toml +++ b/gov/rfc/RFC-0007/rfc.toml @@ -5,7 +5,7 @@ id = "RFC-0007" title = "TUI v2 read-only cockpit" version = "0.2.0" status = "normative" -phase = "test" +phase = "stable" owners = ["@govctl-org"] created = "2026-06-06" updated = "2026-06-07" @@ -15,7 +15,7 @@ refs = [ "RFC-0002", ] tags = ["tui"] -signature = "e281def7edbba823ed931af760b3830cef9aa2b77049ebc4f6d0faebb24f9491" +signature = "12693d8406d5289c411931c5e8737de238e9d727b6f313fc1ba94f72e45d97c0" [[sections]] title = "Summary" diff --git a/gov/schema/release.schema.json b/gov/schema/release.schema.json index b93ef2ce..05a42204 100644 --- a/gov/schema/release.schema.json +++ b/gov/schema/release.schema.json @@ -19,7 +19,7 @@ "type": "array", "items": { "type": "object", - "required": ["version", "date"], + "required": ["version", "date", "refs"], "properties": { "version": { "type": "string", diff --git a/gov/schema/work.schema.json b/gov/schema/work.schema.json index 2369f338..b74a0773 100644 --- a/gov/schema/work.schema.json +++ b/gov/schema/work.schema.json @@ -7,7 +7,7 @@ "properties": { "govctl": { "type": "object", - "required": ["id", "title", "status"], + "required": ["id", "title", "status", "created"], "properties": { "id": { "type": "string", diff --git a/gov/work/2026-07-15-address-pr-35-review-findings.toml b/gov/work/2026-07-15-address-pr-35-review-findings.toml new file mode 100644 index 00000000..2a2301e7 --- /dev/null +++ b/gov/work/2026-07-15-address-pr-35-review-findings.toml @@ -0,0 +1,42 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-15-006" +title = "Address PR 35 review findings" +status = "done" +created = "2026-07-15" +started = "2026-07-15" +completed = "2026-07-15" +refs = [ + "RFC-0000:C-ADR-PROJECTION-OWNERSHIP", + "ADR-0031", + "ADR-0034", + "ADR-0049", +] +tags = [ + "validation", + "cli", +] + +[content] +description = "Verify and address actionable CodeRabbit findings from PR 35 across validation diagnostics, ADR authoring boundaries, and release message formatting." + +[[content.acceptance_criteria]] +text = "ADR placeholder context emits a dedicated diagnostic code" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Reviewed ADRs retain decision rationale without task execution sections" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "Release messages avoid unnecessary count allocations" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "Review findings are verified against current code and valid changes pass focused validation" +status = "done" +category = "chore" diff --git a/gov/work/2026-07-15-enforce-adr-projection-ownership.toml b/gov/work/2026-07-15-enforce-adr-projection-ownership.toml new file mode 100644 index 00000000..4de22e22 --- /dev/null +++ b/gov/work/2026-07-15-enforce-adr-projection-ownership.toml @@ -0,0 +1,51 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-15-003" +title = "Enforce ADR projection ownership" +status = "done" +created = "2026-07-15" +started = "2026-07-15" +completed = "2026-07-15" +refs = [ + "ADR-0003", + "ADR-0024", + "ADR-0027", + "ADR-0042", + "RFC-0000:C-ADR-DEF", + "ADR-0052", + "RFC-0000:C-ADR-PROJECTION-OWNERSHIP", +] + +[content] +description = "Align ADR authoring and review guidance with rendered projection ownership, add deterministic project validation for conflicting headings, and update existing ADR sources to produce non-duplicated projections." + +[[content.acceptance_criteria]] +text = "ADR writer and reviewer guidance assign each rendered section to one authoring surface" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Proposed ADR checks and acceptance reject renderer-owned headings with field-and-heading diagnostics, including under `--force`" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "Projection validation ignores fenced-code headings and preserves historical terminal ADR compatibility" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "Existing ADR sources render without duplicated alternatives sections" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "`cargo test --test test_errors --test test_adr_gates`, `cargo run --quiet -- render all`, and `cargo run --quiet -- check` pass" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "ADR projection conflicts normalize formatted artifact titles by visible CommonMark text" +status = "done" +category = "fixed" diff --git a/gov/work/2026-07-15-reopen-unreleased-completed-work-items.toml b/gov/work/2026-07-15-reopen-unreleased-completed-work-items.toml new file mode 100644 index 00000000..587ef2b7 --- /dev/null +++ b/gov/work/2026-07-15-reopen-unreleased-completed-work-items.toml @@ -0,0 +1,49 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-15-002" +title = "Reopen unreleased completed work items" +status = "done" +created = "2026-07-15" +started = "2026-07-15" +completed = "2026-07-15" +refs = [ + "ADR-0051", + "RFC-0000:C-WORK-DEF", + "RFC-0000:C-RELEASE-DEF", + "RFC-0001:C-WORK-STATUS", + "RFC-0002:C-LIFECYCLE-VERBS", +] + +[content] +description = "Implement and test correction of unreleased Work Item completion through the existing lifecycle command surface, including release lookup and timestamp handling, following the amended lifecycle specifications." + +[[content.acceptance_criteria]] +text = "Unreleased `done` Work Items can return to `active`, while release-referenced items remain `done`" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "Reopening tests preserve `started`, remove `completed`, and reject release-referenced items" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "Reopening failure paths preserve the original Work Item file" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "`cargo test --test test_move` and `cargo run --quiet -- check` pass" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "Project validation rejects Work Item IDs referenced by multiple releases" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Reopening leaves existing loop-local terminal outcomes unchanged" +status = "done" +category = "chore" diff --git a/gov/work/2026-07-15-resolve-lifecycle-compliance-audit-gaps.toml b/gov/work/2026-07-15-resolve-lifecycle-compliance-audit-gaps.toml new file mode 100644 index 00000000..ab0d3c4e --- /dev/null +++ b/gov/work/2026-07-15-resolve-lifecycle-compliance-audit-gaps.toml @@ -0,0 +1,53 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-15-004" +title = "Resolve lifecycle compliance audit gaps" +status = "done" +created = "2026-07-15" +started = "2026-07-15" +completed = "2026-07-15" +refs = [ + "RFC-0000:C-PHASE-LIFECYCLE", + "RFC-0000:C-WORK-DEF", + "RFC-0000:C-RELEASE-DEF", + "RFC-0002:C-LIFECYCLE-VERBS", +] + +[content] +description = "Implement and test corrections for lifecycle, storage, and diagnostic conformance gaps identified during the lifecycle redesign audit." + +[[content.acceptance_criteria]] +text = "Lifecycle command write failures preserve governed artifact bytes" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Phase-only RFC advancement remains outside amendment detection after legacy signature normalization" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "RFC and Clause invalid-transition diagnostics identify valid targets or terminal states" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "RFC finalize exposes only the normative target while deprecation remains a separate verb" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "Release creation and schema validation reject invalid dates and missing refs" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Work Item schema and serialization checks match required scalars and omitted empty-list semantics" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "cargo test --all-features --quiet, cargo run --quiet -- check, and just pre-commit pass" +status = "done" +category = "chore" diff --git a/gov/work/2026-07-15-restart-rfc-phase-lifecycle-on-content-bumps.toml b/gov/work/2026-07-15-restart-rfc-phase-lifecycle-on-content-bumps.toml new file mode 100644 index 00000000..3e35123c --- /dev/null +++ b/gov/work/2026-07-15-restart-rfc-phase-lifecycle-on-content-bumps.toml @@ -0,0 +1,43 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-15-001" +title = "Restart RFC phase lifecycle on content bumps" +status = "done" +created = "2026-07-15" +started = "2026-07-15" +completed = "2026-07-15" +refs = [ + "ADR-0051", + "RFC-0000:C-PHASE-LIFECYCLE", + "RFC-0001:C-RFC-PHASE", + "RFC-0002:C-LIFECYCLE-VERBS", +] + +[content] +description = "Implement and test lifecycle handling for versioned RFC content amendments, including phase state and rollback behavior, following the amended lifecycle specifications." + +[[content.acceptance_criteria]] +text = "Detected content amendment bumps start the new RFC version at `spec`, while signature baselines and changelog-only updates preserve phase" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "RFC bump failure paths preserve the original RFC and clause files" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "`cargo test --test test_lifecycle` and `cargo run --quiet -- check` pass" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "RFC lifecycle commands reject forbidden deprecated and implementation phase combinations" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Schema migration converts legacy RFC signatures into content-only amendment baselines without changing RFC versions, phases, or changelogs" +status = "done" +category = "fixed" diff --git a/gov/work/2026-07-15-support-undoing-the-latest-release-cut.toml b/gov/work/2026-07-15-support-undoing-the-latest-release-cut.toml new file mode 100644 index 00000000..fd8206cd --- /dev/null +++ b/gov/work/2026-07-15-support-undoing-the-latest-release-cut.toml @@ -0,0 +1,50 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-07-15-005" +title = "Support undoing the latest release cut" +status = "done" +created = "2026-07-15" +started = "2026-07-15" +completed = "2026-07-15" +refs = [ + "RFC-0000:C-RELEASE-DEF", + "RFC-0001:C-WORK-STATUS", + "RFC-0002:C-LIFECYCLE-VERBS", + "ADR-0014", + "ADR-0051", +] +tags = [ + "release", + "lifecycle", + "cli", + "safety", +] + +[content] +description = "Amend the release governance model and implement a guarded recovery path for correcting an accidental latest local release cut, with CLI, atomic-write, changelog, and lifecycle coverage." + +[[content.acceptance_criteria]] +text = "Release governance artifacts define the correction boundary for the latest local release cut" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "CLI support implements the governed latest-release correction flow" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "Release correction failure paths preserve release and Work Item data" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "Focused regression tests cover release correction, lifecycle interaction, and concurrent-state rejection" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "Legacy release version syntax remains supported alongside latest-release undo" +status = "done" +category = "fixed" diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 039371a2..62d703e9 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -3,9 +3,37 @@ use super::{ AdrCommand, ClauseCommand, GuardCommand, ListTarget, LoopCommand, OutputFormat, RenderTarget, RfcCommand, SkillFormat, TagCommand, WorkCommand, }; -use clap::Subcommand; +use clap::{Args, Subcommand}; use std::path::PathBuf; +#[derive(Subcommand)] +pub(crate) enum ReleaseCommand { + /// Undo the newest local release cut + Undo { + /// Version expected at the head of the release history + expected_version: String, + }, +} + +#[derive(Args)] +#[command( + arg_required_else_help = true, + args_conflicts_with_subcommands = true, + subcommand_precedence_over_arg = true +)] +pub(crate) struct ReleaseArgs { + /// Version number (semver, e.g., 0.2.0) + #[arg(value_name = "VERSION")] + pub(crate) version: Option<String>, + + /// Release date (defaults to the host's local date) + #[arg(long, requires = "version")] + pub(crate) date: Option<String>, + + #[command(subcommand)] + pub(crate) command: Option<ReleaseCommand>, +} + #[derive(Subcommand)] pub(crate) enum Commands { /// Initialize govctl in the current directory @@ -171,15 +199,9 @@ pub(crate) enum Commands { command: GuardCommand, }, - /// Cut a release (collect unreleased work items into a version) + /// Manage local release cuts #[command(after_help = help::RELEASE)] - Release { - /// Version number (semver, e.g., 0.2.0) - version: String, - /// Release date (defaults to today) - #[arg(long)] - date: Option<String>, - }, + Release(ReleaseArgs), /// Output machine-readable CLI metadata for agents #[command(after_help = help::DESCRIBE)] diff --git a/src/cli/common/targets.rs b/src/cli/common/targets.rs index 4389011e..e8f85969 100644 --- a/src/cli/common/targets.rs +++ b/src/cli/common/targets.rs @@ -77,7 +77,6 @@ pub(crate) enum RenderTarget { #[derive(ValueEnum, Clone, Copy, Debug)] pub(crate) enum FinalizeStatus { Normative, - Deprecated, } /// Output format for agent definitions in `init-skills`. diff --git a/src/cli/help.rs b/src/cli/help.rs index 67ab57a0..9894a640 100644 --- a/src/cli/help.rs +++ b/src/cli/help.rs @@ -159,10 +159,12 @@ START HERE: pub(super) const RELEASE: &str = r#"EXAMPLES: govctl release 0.2.0 govctl release 0.2.0 --date 2026-04-07 + govctl release undo 0.2.0 NOTES: - - Collects unreleased completed work items into a versioned release. - - Use a semver version string. + - A version argument collects unreleased completed work items into a release. + - `undo` removes only the newest local release when its version matches. + - Undo does not modify CHANGELOG.md or external publication systems. "#; pub(super) const DESCRIBE: &str = r#"EXAMPLES: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index cd160d7e..22d37e43 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,7 +6,7 @@ mod help; mod loop_cmd; mod resources; -pub(crate) use commands::Commands; +pub(crate) use commands::{Commands, ReleaseArgs, ReleaseCommand}; pub(crate) use common::*; pub(crate) use loop_cmd::LoopCommand; pub(crate) use resources::*; diff --git a/src/cli/resources/rfc.rs b/src/cli/resources/rfc.rs index ae518ed6..280dea1b 100644 --- a/src/cli/resources/rfc.rs +++ b/src/cli/resources/rfc.rs @@ -147,20 +147,19 @@ NOTES: #[arg(short = 'c', long = "change")] changes: Vec<String>, }, - /// Finalize RFC status (draft → normative or deprecated) + /// Finalize RFC status (draft → normative) #[command(after_help = "\ EXAMPLES: govctl rfc finalize RFC-0001 normative - govctl rfc finalize RFC-0001 deprecated NOTES: - - Finalize is used once the RFC leaves draft state. + - Use `deprecate` for normative → deprecated. - Use `advance` to move phase after finalization. ")] Finalize { /// RFC ID id: String, - /// Target status + /// Target status (`normative`) #[arg(value_enum)] status: FinalizeStatus, }, diff --git a/src/cmd/describe/catalog.rs b/src/cmd/describe/catalog.rs index d823d3a4..c1fb0e16 100644 --- a/src/cmd/describe/catalog.rs +++ b/src/cmd/describe/catalog.rs @@ -189,7 +189,7 @@ pub(super) fn command_catalog() -> Vec<CommandInfo> { ), command( "rfc finalize", - "Transition RFC status to normative or deprecated", + "Transition a draft RFC to normative status", "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "govctl rfc finalize RFC-0001 normative", &["RFC must be in draft status"], @@ -282,11 +282,11 @@ pub(super) fn command_catalog() -> Vec<CommandInfo> { RFC_EXISTS, ), command( - "release", - "Cut a release (collect unreleased work items)", - "When releasing a new version. Collects done work items into changelog.", + "release / release undo", + "Create or correct the newest local release cut", + "To group done Work Items under a version or correct an accidental newest cut.", "govctl release 0.2.0", - &["Done work items exist"], + &["Cut requires unreleased done Work Items; undo requires a matching newest version"], ), command( "rfc deprecate / clause deprecate", diff --git a/src/cmd/lifecycle/adr.rs b/src/cmd/lifecycle/adr.rs index 99803709..147a0704 100644 --- a/src/cmd/lifecycle/adr.rs +++ b/src/cmd/lifecycle/adr.rs @@ -5,7 +5,7 @@ use crate::model::{AdrEntry, AdrStatus, AlternativeStatus}; use crate::parse::load_adrs; use crate::parse::write_adr; use crate::ui; -use crate::validate::is_valid_adr_transition; +use crate::validate::{is_valid_adr_transition, validate_adr_projection}; use crate::write::WriteOp; fn adr_not_found(adr_id: &str) -> Diagnostic { @@ -98,13 +98,25 @@ pub fn accept_adr( return Err(Diagnostic::new( DiagnosticCode::E0303AdrInvalidTransition, format!( - "Invalid ADR transition: {} -> accepted", - entry.spec.govctl.status.as_ref() + "Invalid ADR transition: {} -> accepted. Valid transitions from {}: {}", + entry.spec.govctl.status.as_ref(), + entry.spec.govctl.status.as_ref(), + valid_adr_targets(entry.spec.govctl.status) ), adr_id, )); } + if let Some(diagnostic) = validate_adr_projection( + &entry, + config.display_path(&entry.path).display().to_string(), + ) + .into_iter() + .next() + { + return Err(diagnostic); + } + // Implements [[ADR-0042]]: validate alternatives completeness before acceptance if !force { validate_adr_completeness(config, adr_id)?; @@ -126,8 +138,10 @@ pub fn reject_adr(config: &Config, adr_id: &str, op: WriteOp) -> DiagnosticResul return Err(Diagnostic::new( DiagnosticCode::E0303AdrInvalidTransition, format!( - "Invalid ADR transition: {} -> rejected", - entry.spec.govctl.status.as_ref() + "Invalid ADR transition: {} -> rejected. Valid transitions from {}: {}", + entry.spec.govctl.status.as_ref(), + entry.spec.govctl.status.as_ref(), + valid_adr_targets(entry.spec.govctl.status) ), adr_id, )); @@ -162,8 +176,10 @@ pub(super) fn supersede_adr( return Err(Diagnostic::new( DiagnosticCode::E0303AdrInvalidTransition, format!( - "Invalid ADR transition: {} -> superseded", - entry.spec.govctl.status.as_ref() + "Invalid ADR transition: {} -> superseded. Valid transitions from {}: {}", + entry.spec.govctl.status.as_ref(), + entry.spec.govctl.status.as_ref(), + valid_adr_targets(entry.spec.govctl.status) ), adr_id, )); @@ -183,3 +199,12 @@ pub(super) fn supersede_adr( } Ok(vec![]) } + +fn valid_adr_targets(status: AdrStatus) -> &'static str { + match status { + AdrStatus::Proposed => "accepted, rejected", + AdrStatus::Accepted => "superseded", + AdrStatus::Rejected => "none (rejected is terminal)", + AdrStatus::Superseded => "none (superseded is terminal)", + } +} diff --git a/src/cmd/lifecycle/clause.rs b/src/cmd/lifecycle/clause.rs index 3a7003ae..6607d934 100644 --- a/src/cmd/lifecycle/clause.rs +++ b/src/cmd/lifecycle/clause.rs @@ -18,14 +18,14 @@ pub(super) fn deprecate_clause( if clause.status == ClauseStatus::Deprecated { return Err(Diagnostic::new( DiagnosticCode::E0208ClauseAlreadyDeprecated, - "Clause is already deprecated", + "Clause is already deprecated. Valid transition from deprecated: superseded via `clause supersede`", clause_id, )); } if clause.status == ClauseStatus::Superseded { return Err(Diagnostic::new( DiagnosticCode::E0209ClauseAlreadySuperseded, - "Clause is superseded, cannot deprecate", + "Clause is superseded, cannot deprecate. Superseded is terminal; there are no valid transitions", clause_id, )); } @@ -53,7 +53,7 @@ pub(super) fn supersede_clause( if clause.status == ClauseStatus::Superseded { return Err(Diagnostic::new( DiagnosticCode::E0209ClauseAlreadySuperseded, - "Clause is already superseded", + "Clause is already superseded. Superseded is terminal; there are no valid transitions", clause_id, )); } diff --git a/src/cmd/lifecycle/mod.rs b/src/cmd/lifecycle/mod.rs index 76965ed6..c270b4e6 100644 --- a/src/cmd/lifecycle/mod.rs +++ b/src/cmd/lifecycle/mod.rs @@ -1,6 +1,5 @@ //! Lifecycle command implementations. -use crate::FinalizeStatus; use crate::cmd::confirmation::confirm_destructive_action; use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult, Diagnostics}; @@ -14,7 +13,7 @@ mod rfc; mod rfc_clause_versions; mod rfc_supersede; pub use adr::{accept_adr, reject_adr, validate_adr_completeness}; -pub use release::cut_release; +pub use release::{cut_release, undo_release}; pub use rfc::{advance, bump, finalize}; /// Deprecate an artifact @@ -38,7 +37,7 @@ pub fn deprecate( if id.contains(':') { clause::deprecate_clause(config, id, op) } else if id.starts_with("RFC-") { - finalize(config, id, FinalizeStatus::Deprecated, op) + rfc::deprecate_rfc(config, id, op) } else if id.starts_with("ADR-") { Err(Diagnostic::new( DiagnosticCode::E0305AdrCannotDeprecate, diff --git a/src/cmd/lifecycle/release.rs b/src/cmd/lifecycle/release.rs index 17bbc4d8..cd710e57 100644 --- a/src/cmd/lifecycle/release.rs +++ b/src/cmd/lifecycle/release.rs @@ -5,7 +5,8 @@ use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult, Diagnostic use crate::model::{Release, WorkItemStatus}; use crate::parse::{load_releases, load_work_items, validate_version, write_releases}; use crate::ui; -use crate::write::{WriteOp, today}; +use crate::write::{WriteOp, delete_file, today}; +use chrono::NaiveDate; /// Cut a release - collect unreleased work items into a version /// Per [[ADR-0014]], stores release info in gov/releases.toml @@ -60,6 +61,15 @@ pub fn cut_release( } let release_date = date.map(|d| d.to_string()).unwrap_or_else(today); + if NaiveDate::parse_from_str(&release_date, "%Y-%m-%d").is_err() { + return Err(Diagnostic::new( + DiagnosticCode::E0704ReleaseSchemaInvalid, + format!( + "Invalid release date: {release_date}. Expected a valid calendar date in YYYY-MM-DD format" + ), + &releases_path_str, + )); + } let mut refs: Vec<_> = unreleased .iter() .map(|w| w.spec.govctl.id.clone()) @@ -83,3 +93,61 @@ pub fn cut_release( Ok(vec![]) } + +/// Undo the newest local release cut. +/// +/// The expected version is an intent guard for [[RFC-0000:C-RELEASE-DEF]]. +pub fn undo_release( + config: &Config, + expected_version: &str, + op: WriteOp, +) -> DiagnosticResult<Diagnostics> { + let releases_path = config.releases_path(); + let releases_path_str = config.display_path(&releases_path).display().to_string(); + + validate_version(expected_version).map_err(|_| { + Diagnostic::new( + DiagnosticCode::E0701ReleaseInvalidSemver, + format!("Invalid semver version: {expected_version}"), + &releases_path_str, + ) + })?; + + let mut releases_file = load_releases(config)?; + let newest = releases_file.releases.first().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0708ReleaseHistoryEmpty, + "Cannot undo a release because the release history is empty", + &releases_path_str, + ) + })?; + + if newest.version != expected_version { + return Err(Diagnostic::new( + DiagnosticCode::E0709ReleaseLatestMismatch, + format!( + "Cannot undo release {expected_version}: newest release is {}", + newest.version + ), + &releases_path_str, + )); + } + + let removed = releases_file.releases.remove(0); + if releases_file.releases.is_empty() { + delete_file( + &releases_path, + op, + Some(&config.display_path(&releases_path)), + )?; + } else { + write_releases(config, &releases_file, op)?; + } + + if !op.is_preview() { + ui::release_undone(&removed.version, removed.refs.len()); + ui::sub_info("Run `govctl render changelog --force` to rebuild CHANGELOG.md"); + } + + Ok(vec![]) +} diff --git a/src/cmd/lifecycle/rfc.rs b/src/cmd/lifecycle/rfc.rs index d58a3a9f..7713ffc0 100644 --- a/src/cmd/lifecycle/rfc.rs +++ b/src/cmd/lifecycle/rfc.rs @@ -21,14 +21,19 @@ pub fn bump( changes: &[String], op: WriteOp, ) -> DiagnosticResult<Diagnostics> { + require_rfc_content_signature_schema(config, rfc_id)?; let rfc_path = require_rfc_toml_path(config, rfc_id)?; let mut rfc = read_rfc(config, &rfc_path)?; let refresh_signature_after_write = match (level, summary, changes.is_empty()) { (Some(lvl), Some(sum), _) => { - ensure_rfc_has_content_amendment(config, &rfc_path, rfc_id)?; + let releases_content_amendment = + ensure_rfc_has_content_amendment(config, &rfc_path, rfc_id)?; let new_version = bump_rfc_version(&mut rfc, lvl, sum)?; + if releases_content_amendment { + rfc.phase = RfcPhase::Spec; + } for change in changes { add_changelog_change(&mut rfc, change)?; @@ -108,23 +113,54 @@ pub fn finalize( rfc_id: &str, status: FinalizeStatus, op: WriteOp, +) -> DiagnosticResult<Diagnostics> { + match status { + FinalizeStatus::Normative => { + transition_rfc_status(config, rfc_id, RfcStatus::Normative, op) + } + } +} + +pub(super) fn deprecate_rfc( + config: &Config, + rfc_id: &str, + op: WriteOp, +) -> DiagnosticResult<Diagnostics> { + transition_rfc_status(config, rfc_id, RfcStatus::Deprecated, op) +} + +fn transition_rfc_status( + config: &Config, + rfc_id: &str, + target_status: RfcStatus, + op: WriteOp, ) -> DiagnosticResult<Diagnostics> { let rfc_path = require_rfc_toml_path(config, rfc_id)?; let rfc = read_rfc(config, &rfc_path)?; - let target_status = match status { - FinalizeStatus::Normative => RfcStatus::Normative, - FinalizeStatus::Deprecated => RfcStatus::Deprecated, - }; - if !is_valid_status_transition(rfc.status, target_status) { return Err(Diagnostic::new( DiagnosticCode::E0104RfcInvalidTransition, format!( - "Invalid status transition: {} -> {}", + "Invalid status transition: {} -> {}. Valid transition from {}: {}", + rfc.status.as_ref(), + target_status.as_ref(), rfc.status.as_ref(), - target_status.as_ref() + valid_rfc_status_targets(rfc.status) + ), + rfc_id, + )); + } + + if target_status == RfcStatus::Deprecated + && matches!(rfc.phase, RfcPhase::Impl | RfcPhase::Test) + { + return Err(Diagnostic::new( + DiagnosticCode::E0104RfcInvalidTransition, + format!( + "Cannot deprecate an RFC while phase is {}. Advance the current version to stable first.", + rfc.phase.as_ref() ), rfc_id, )); @@ -153,17 +189,19 @@ pub fn advance( phase: RfcPhase, op: WriteOp, ) -> DiagnosticResult<Diagnostics> { + require_rfc_content_signature_schema(config, rfc_id)?; let rfc_path = require_rfc_toml_path(config, rfc_id)?; let rfc = read_rfc(config, &rfc_path)?; - // Check status constraint: cannot advance to impl+ without normative status - if rfc.status == RfcStatus::Draft && phase != RfcPhase::Spec { + // Phase/status combinations are constrained by [[RFC-0000:C-PHASE-LIFECYCLE]]. + if rfc.status != RfcStatus::Normative && phase != RfcPhase::Spec { return Err(Diagnostic::new( DiagnosticCode::E0104RfcInvalidTransition, format!( - "Cannot advance to {} while status is draft. Finalize to normative first.", - phase.as_ref() + "Cannot advance to {} while status is {}. Only normative RFCs can enter implementation phases.", + phase.as_ref(), + rfc.status.as_ref() ), rfc_id, )); @@ -173,15 +211,42 @@ pub fn advance( return Err(Diagnostic::new( DiagnosticCode::E0104RfcInvalidTransition, format!( - "Invalid phase transition: {} -> {}", + "Invalid phase transition: {} -> {}. Valid transition from {}: {}", rfc.phase.as_ref(), - phase.as_ref() + phase.as_ref(), + rfc.phase.as_ref(), + valid_rfc_phase_targets(rfc.phase) ), rfc_id, )); } - edit::set_field_direct(config, rfc_id, "phase", phase.as_ref(), op)?; + let rfc_index = crate::load::load_rfc(config, &rfc_path)?; + let migrated_signature = if let Some(stored_signature) = &rfc_index.rfc.signature { + let current_signature = crate::signature::compute_rfc_content_signature(&rfc_index)?; + if stored_signature == ¤t_signature { + None + } else if crate::signature::compute_rfc_signature(&rfc_index) + .is_ok_and(|legacy| stored_signature == &legacy) + { + Some(current_signature) + } else { + return Err(Diagnostic::new( + DiagnosticCode::E0114RfcPendingAmendment, + "Cannot advance phase while RFC or clause content has an unversioned amendment. Release it with `govctl rfc bump` first.", + rfc_id, + )); + } + } else { + None + }; + + let mut updated_rfc = rfc; + if let Some(signature) = migrated_signature { + updated_rfc.signature = Some(signature); + } + updated_rfc.phase = phase; + write_lifecycle_rfc(config, &rfc_path, &updated_rfc, op)?; if !op.is_preview() { ui::phase_advanced(rfc_id, phase.as_ref()); @@ -189,6 +254,40 @@ pub fn advance( Ok(vec![]) } +fn valid_rfc_status_targets(status: RfcStatus) -> &'static str { + match status { + RfcStatus::Draft => "normative", + RfcStatus::Normative => "deprecated", + RfcStatus::Deprecated => "none (deprecated is terminal)", + } +} + +fn valid_rfc_phase_targets(phase: RfcPhase) -> &'static str { + match phase { + RfcPhase::Spec => "impl", + RfcPhase::Impl => "test", + RfcPhase::Test => "stable", + RfcPhase::Stable => "none (stable is terminal for the current version)", + } +} + +fn require_rfc_content_signature_schema(config: &Config, rfc_id: &str) -> DiagnosticResult<()> { + // Older schemas require the explicit migration path in [[RFC-0002:C-GLOBAL-COMMANDS]]. + let required = crate::cmd::migrate::RFC_CONTENT_SIGNATURE_SCHEMA_VERSION; + if config.schema.version >= required { + return Ok(()); + } + + Err(Diagnostic::new( + DiagnosticCode::E0505MigrationRequired, + format!( + "RFC amendment signatures require schema version {required} (found {}). Run `govctl migrate` before bumping or advancing RFCs.", + config.schema.version + ), + rfc_id, + )) +} + fn write_lifecycle_rfc( config: &Config, rfc_path: &Path, @@ -217,10 +316,13 @@ fn ensure_rfc_has_content_amendment( config: &Config, rfc_path: &Path, rfc_id: &str, -) -> DiagnosticResult<()> { +) -> DiagnosticResult<bool> { let rfc_index = crate::load::load_rfc(config, rfc_path)?; - if rfc_index.rfc.signature.is_none() || crate::signature::is_rfc_amended(&rfc_index) { - return Ok(()); + if rfc_index.rfc.signature.is_none() { + return Ok(false); + } + if crate::signature::is_rfc_amended(&rfc_index) { + return Ok(true); } Err(Diagnostic::new( diff --git a/src/cmd/lifecycle/rfc_supersede.rs b/src/cmd/lifecycle/rfc_supersede.rs index 2c8004c4..01cad52d 100644 --- a/src/cmd/lifecycle/rfc_supersede.rs +++ b/src/cmd/lifecycle/rfc_supersede.rs @@ -1,7 +1,7 @@ use super::paths::{require_replacement_rfc_toml_path, require_rfc_toml_path}; use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult, Diagnostics}; -use crate::model::RfcStatus; +use crate::model::{RfcPhase, RfcStatus}; use crate::ui; use crate::validate::is_valid_status_transition; use crate::write::{WriteOp, read_rfc, today, with_file_transaction, write_rfc}; @@ -68,8 +68,20 @@ fn validate_supersede_transition( return Err(Diagnostic::new( DiagnosticCode::E0104RfcInvalidTransition, format!( - "Invalid RFC transition: {} -> deprecated", - source.status.as_ref() + "Invalid RFC transition: {} -> deprecated. Valid transition from {}: {}", + source.status.as_ref(), + source.status.as_ref(), + valid_rfc_status_targets(source.status) + ), + rfc_id, + )); + } + if matches!(source.phase, RfcPhase::Impl | RfcPhase::Test) { + return Err(Diagnostic::new( + DiagnosticCode::E0104RfcInvalidTransition, + format!( + "Cannot supersede an RFC while phase is {}. Advance the current version to stable first.", + source.phase.as_ref() ), rfc_id, )); @@ -77,7 +89,9 @@ fn validate_supersede_transition( if replacement.status == RfcStatus::Deprecated { return Err(Diagnostic::new( DiagnosticCode::E0104RfcInvalidTransition, - format!("Replacement RFC is deprecated: {by}"), + format!( + "Replacement RFC is deprecated: {by}. Valid replacement statuses: draft, normative" + ), by, )); } @@ -89,7 +103,7 @@ fn validate_supersede_transition( return Err(Diagnostic::new( DiagnosticCode::E0104RfcInvalidTransition, format!( - "Replacement RFC already supersedes {}", + "Replacement RFC already supersedes {}. Use a replacement RFC without another supersedes relationship", replacement.supersedes.as_deref().unwrap_or_default() ), by, @@ -98,3 +112,11 @@ fn validate_supersede_transition( Ok(()) } + +fn valid_rfc_status_targets(status: RfcStatus) -> &'static str { + match status { + RfcStatus::Draft => "normative via `rfc finalize`", + RfcStatus::Normative => "deprecated via `rfc deprecate` or `rfc supersede`", + RfcStatus::Deprecated => "none (deprecated is terminal)", + } +} diff --git a/src/cmd/migrate/mod.rs b/src/cmd/migrate/mod.rs index 7c029773..363a6f9d 100644 --- a/src/cmd/migrate/mod.rs +++ b/src/cmd/migrate/mod.rs @@ -13,13 +13,18 @@ use std::fs; mod ops; mod releases; mod rewrite; +mod rfc_signatures; use ops::{FileOp, execute_ops, preview_ops}; use releases::plan_release_upgrade; use rewrite::plan_toml_rewrites; +use rfc_signatures::plan_rfc_signature_upgrade; /// Latest schema version. Bump when adding a new migration step. -pub const CURRENT_SCHEMA_VERSION: u32 = 2; +pub const CURRENT_SCHEMA_VERSION: u32 = 3; + +/// First schema version whose RFC signatures are content-only amendment baselines. +pub const RFC_CONTENT_SIGNATURE_SCHEMA_VERSION: u32 = 3; /// A versioned migration step. struct MigrationStep { @@ -30,12 +35,20 @@ struct MigrationStep { } /// All registered migrations, ordered by version. -const MIGRATIONS: &[MigrationStep] = &[MigrationStep { - from: 1, - to: 2, - name: "structured wire format and schema headers", - plan_fn: plan_v1_to_v2, -}]; +const MIGRATIONS: &[MigrationStep] = &[ + MigrationStep { + from: 1, + to: 2, + name: "structured wire format and schema headers", + plan_fn: plan_v1_to_v2, + }, + MigrationStep { + from: 2, + to: 3, + name: "RFC amendment content signatures", + plan_fn: plan_v2_to_v3, + }, +]; // ============================================================================= // Public API @@ -214,3 +227,11 @@ fn plan_v1_to_v2(config: &Config) -> DiagnosticResult<Vec<FileOp>> { Ok(ops) } + +// ============================================================================= +// v2 -> v3: RFC amendment content signatures +// ============================================================================= + +fn plan_v2_to_v3(config: &Config) -> DiagnosticResult<Vec<FileOp>> { + plan_rfc_signature_upgrade(config) +} diff --git a/src/cmd/migrate/rfc_signatures.rs b/src/cmd/migrate/rfc_signatures.rs new file mode 100644 index 00000000..910314f8 --- /dev/null +++ b/src/cmd/migrate/rfc_signatures.rs @@ -0,0 +1,37 @@ +use super::ops::FileOp; +use crate::config::Config; +use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult}; +use crate::model::RfcWire; +use crate::schema::{ArtifactSchema, with_schema_header}; + +/// Rebaseline legacy projection hashes as content-only amendment signatures. +/// Implements [[RFC-0000:C-PHASE-LIFECYCLE]] through the versioned migration +/// pipeline required by [[RFC-0002:C-GLOBAL-COMMANDS]]. +pub(super) fn plan_rfc_signature_upgrade(config: &Config) -> DiagnosticResult<Vec<FileOp>> { + let rfcs = crate::load::load_rfcs(config).map_err(Diagnostic::from)?; + let mut ops = Vec::new(); + + for mut rfc in rfcs { + let signature = crate::signature::compute_rfc_content_signature(&rfc)?; + if rfc.rfc.signature.as_deref() == Some(signature.as_str()) { + continue; + } + + rfc.rfc.signature = Some(signature); + let path = rfc.path; + let wire: RfcWire = rfc.rfc.into(); + let body = toml::to_string_pretty(&wire).map_err(|err| { + Diagnostic::new( + DiagnosticCode::E0101RfcSchemaInvalid, + format!("Failed to serialize RFC TOML during signature migration: {err}"), + config.display_path(&path).display().to_string(), + ) + })?; + ops.push(FileOp::Write { + path, + content: with_schema_header(ArtifactSchema::Rfc, &body), + }); + } + + Ok(ops) +} diff --git a/src/cmd/move_.rs b/src/cmd/move_.rs index 9e7a92af..fc25d9e4 100644 --- a/src/cmd/move_.rs +++ b/src/cmd/move_.rs @@ -4,7 +4,7 @@ use crate::cmd::verify; use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult, Diagnostics}; use crate::model::{ChecklistStatus, WorkItemStatus}; -use crate::parse::{load_work_item, write_work_item}; +use crate::parse::{load_releases, load_work_item, write_work_item}; use crate::ui; use crate::validate::is_valid_work_transition; use crate::write::{WriteOp, today}; @@ -34,18 +34,36 @@ pub fn move_item( let mut entry = load_work_item(config, &work_path)?; let work_id = &entry.spec.govctl.id; - if !is_valid_work_transition(entry.spec.govctl.status, status) { + let previous_status = entry.spec.govctl.status; + if !is_valid_work_transition(previous_status, status) { return Err(Diagnostic::new( DiagnosticCode::E0403WorkInvalidTransition, format!( - "Invalid transition: {} -> {}", + "Invalid transition: {} -> {}. Valid transitions from {}: {}", entry.spec.govctl.status.as_ref(), - status.as_ref() + status.as_ref(), + entry.spec.govctl.status.as_ref(), + valid_work_targets(previous_status) ), work_id, )); } + if previous_status == WorkItemStatus::Done && status == WorkItemStatus::Active { + let releases = load_releases(config)?; + if releases + .releases + .iter() + .any(|release| release.refs.iter().any(|id| id == work_id)) + { + return Err(Diagnostic::new( + DiagnosticCode::E0403WorkInvalidTransition, + "Cannot reopen a Work Item referenced by a release. Released Work Items have no valid transition away from done.", + work_id, + )); + } + } + // Validate acceptance criteria before marking done if status == WorkItemStatus::Done { // Must have at least one acceptance criterion @@ -94,16 +112,19 @@ pub fn move_item( entry.spec.govctl.status = status; // Update dates - match status { - WorkItemStatus::Active => { + match (previous_status, status) { + (WorkItemStatus::Queue, WorkItemStatus::Active) => { if entry.spec.govctl.started.is_none() { entry.spec.govctl.started = Some(today()); } } - WorkItemStatus::Done | WorkItemStatus::Cancelled => { + (WorkItemStatus::Done, WorkItemStatus::Active) => { + entry.spec.govctl.completed = None; + } + (_, WorkItemStatus::Done | WorkItemStatus::Cancelled) => { entry.spec.govctl.completed = Some(today()); } - WorkItemStatus::Queue => {} + _ => {} } write_work_item( @@ -124,6 +145,15 @@ pub fn move_item( Ok(vec![]) } +fn valid_work_targets(status: WorkItemStatus) -> &'static str { + match status { + WorkItemStatus::Queue => "active, cancelled", + WorkItemStatus::Active => "done, cancelled", + WorkItemStatus::Done => "active when no release references the Work Item", + WorkItemStatus::Cancelled => "none (cancelled is terminal)", + } +} + /// Find work item by partial name or ID fn find_work_item_by_name(config: &Config, name: &str) -> DiagnosticResult<std::path::PathBuf> { if name.starts_with("WI-") { diff --git a/src/command_router/execute/builtin.rs b/src/command_router/execute/builtin.rs index 37a9edcc..262e56fc 100644 --- a/src/command_router/execute/builtin.rs +++ b/src/command_router/execute/builtin.rs @@ -45,6 +45,9 @@ pub(super) fn execute_builtin(config: &Config, builtin: &BuiltinOp, op: WriteOp) BuiltinOp::ReleaseCut { version, date } => { cmd::lifecycle::cut_release(config, version, date.as_deref(), op) } + BuiltinOp::ReleaseUndo { expected_version } => { + cmd::lifecycle::undo_release(config, expected_version, op) + } BuiltinOp::TagNew { tag } => cmd::tag::tag_new(config, tag, op), BuiltinOp::TagDelete { tag } => cmd::tag::tag_delete(config, tag, op), BuiltinOp::TagList { output } => cmd::tag::tag_list(config, *output), diff --git a/src/command_router/parsed.rs b/src/command_router/parsed.rs index 35137df4..66874342 100644 --- a/src/command_router/parsed.rs +++ b/src/command_router/parsed.rs @@ -1,6 +1,6 @@ use super::{BuiltinOp, CommandPlan, Op, global}; -use crate::diagnostic::DiagnosticResult; -use crate::{Commands, LoopCommand, TagCommand}; +use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult}; +use crate::{Commands, LoopCommand, ReleaseArgs, ReleaseCommand, TagCommand}; impl CommandPlan { pub fn from_parsed(cmd: &Commands, global_dry_run: bool) -> DiagnosticResult<Self> { @@ -65,15 +65,31 @@ impl CommandPlan { Commands::Work { command } => command.to_plan(), Commands::Guard { command } => command.to_plan(), Commands::Loop { command } => Ok(plan_loop_command(command)), - Commands::Release { version, date } => Ok(global(Op::Builtin(BuiltinOp::ReleaseCut { - version: version.clone(), - date: date.clone(), - }))), + Commands::Release(args) => plan_release_command(args), Commands::Tag { command } => Ok(plan_tag_command(command)), } } } +fn plan_release_command(args: &ReleaseArgs) -> DiagnosticResult<CommandPlan> { + let op = match &args.command { + Some(ReleaseCommand::Undo { expected_version }) => BuiltinOp::ReleaseUndo { + expected_version: expected_version.clone(), + }, + None => BuiltinOp::ReleaseCut { + version: args.version.clone().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0801MissingRequiredArg, + "release requires a version or the undo command", + "release command", + ) + })?, + date: args.date.clone(), + }, + }; + Ok(global(Op::Builtin(op))) +} + fn plan_loop_command(command: &LoopCommand) -> CommandPlan { let op = match command { LoopCommand::List { diff --git a/src/command_router/plan.rs b/src/command_router/plan.rs index 138fcdc8..f960c6bc 100644 --- a/src/command_router/plan.rs +++ b/src/command_router/plan.rs @@ -80,6 +80,9 @@ pub enum BuiltinOp { version: String, date: Option<String>, }, + ReleaseUndo { + expected_version: String, + }, TagNew { tag: String, }, diff --git a/src/command_router/tests/help.rs b/src/command_router/tests/help.rs index ae387bb0..665eef28 100644 --- a/src/command_router/tests/help.rs +++ b/src/command_router/tests/help.rs @@ -29,3 +29,17 @@ fn test_work_get_help_restores_resource_specific_examples() { "help: {help}" ); } + +#[test] +fn test_release_help_exposes_creation_and_undo() { + let err = match crate::Cli::try_parse_from(["govctl", "release", "--help"]) { + Ok(_) => unreachable!("help should exit"), + Err(err) => err, + }; + assert_eq!(err.kind(), ErrorKind::DisplayHelp); + let help = err.to_string(); + assert!(help.contains("[VERSION]"), "help: {help}"); + assert!(help.contains("undo"), "help: {help}"); + assert!(help.contains("govctl release 0.2.0"), "help: {help}"); + assert!(help.contains("govctl release undo 0.2.0"), "help: {help}"); +} diff --git a/src/command_router/tests/lock_disposition.rs b/src/command_router/tests/lock_disposition.rs index 2c72ffb8..8727f6e7 100644 --- a/src/command_router/tests/lock_disposition.rs +++ b/src/command_router/tests/lock_disposition.rs @@ -123,6 +123,13 @@ fn test_lock_disposition_is_lock_free_for_inspect_commands() #[test] fn test_lock_disposition_requires_lock_for_mutating_commands() -> Result<(), Box<dyn std::error::Error>> { + assert_eq!( + global(Op::Builtin(BuiltinOp::ReleaseUndo { + expected_version: "0.2.0".to_string(), + })) + .lock_disposition(), + LockDisposition::GovRootExclusive + ); assert_eq!( global(Op::Builtin(BuiltinOp::Init { force: false })).lock_disposition(), LockDisposition::GovRootExclusive diff --git a/src/command_router/tests/mod.rs b/src/command_router/tests/mod.rs index 03022960..ade266f1 100644 --- a/src/command_router/tests/mod.rs +++ b/src/command_router/tests/mod.rs @@ -2,7 +2,10 @@ use super::*; use crate::diagnostic::DiagnosticCode; use crate::model::WorkItemStatus; use crate::resource_plan::ToPlan; -use crate::{ClauseCommand, Commands, EditActionArgs, TickStatus, WorkTickStatus}; +use crate::{ + ClauseCommand, Commands, EditActionArgs, ReleaseArgs, ReleaseCommand, TickStatus, + WorkTickStatus, +}; use clap::{Parser, error::ErrorKind}; mod clause_edit; diff --git a/src/command_router/tests/routing.rs b/src/command_router/tests/routing.rs index a143cfd9..8db8b88c 100644 --- a/src/command_router/tests/routing.rs +++ b/src/command_router/tests/routing.rs @@ -18,6 +18,50 @@ fn test_self_update_routes_to_builtin_op() -> Result<(), Box<dyn std::error::Err Ok(()) } +#[test] +fn test_release_commands_route_to_builtin_ops() -> Result<(), Box<dyn std::error::Error>> { + let cut = CommandPlan::from_parsed( + &Commands::Release(ReleaseArgs { + version: Some("0.2.0".to_string()), + date: Some("2026-07-15".to_string()), + command: None, + }), + false, + )?; + assert!(matches!( + cut.op, + Op::Builtin(BuiltinOp::ReleaseCut { + ref version, + date: Some(ref date), + }) if version == "0.2.0" && date == "2026-07-15" + )); + + let undo = CommandPlan::from_parsed( + &Commands::Release(ReleaseArgs { + version: None, + date: None, + command: Some(ReleaseCommand::Undo { + expected_version: "0.2.0".to_string(), + }), + }), + false, + )?; + assert!(matches!( + undo.op, + Op::Builtin(BuiltinOp::ReleaseUndo { + ref expected_version, + }) if expected_version == "0.2.0" + )); + Ok(()) +} + +#[test] +fn test_release_creation_and_undo_syntaxes_parse() { + assert!(crate::Cli::try_parse_from(["govctl", "release", "0.2.0"]).is_ok()); + assert!(crate::Cli::try_parse_from(["govctl", "release", "undo", "0.2.0"]).is_ok()); + assert!(crate::Cli::try_parse_from(["govctl", "release", "cut", "0.2.0"]).is_err()); +} + #[test] fn test_target_resolves_get_and_edit_to_same_field_target() -> Result<(), Box<dyn std::error::Error>> { diff --git a/src/diagnostic/code/metadata.rs b/src/diagnostic/code/metadata.rs index 61d956a3..2bd44a7c 100644 --- a/src/diagnostic/code/metadata.rs +++ b/src/diagnostic/code/metadata.rs @@ -11,7 +11,8 @@ pub(super) fn level(code: &DiagnosticCode) -> DiagnosticLevel { | DiagnosticCode::W0109WorkNoActive | DiagnosticCode::W0110SchemaOutdated | DiagnosticCode::W0111ProjectSupportOutdated - | DiagnosticCode::W0112BareArtifactReference => DiagnosticLevel::Warning, + | DiagnosticCode::W0112BareArtifactReference + | DiagnosticCode::W0113AdrPlaceholderContext => DiagnosticLevel::Warning, DiagnosticCode::I0401WorkLegacyInlineHistory => DiagnosticLevel::Info, _ => DiagnosticLevel::Error, } @@ -33,6 +34,7 @@ pub(super) fn code(code: &DiagnosticCode) -> &'static str { DiagnosticCode::E0111RfcNoChangelog => "E0111", DiagnosticCode::E0112RfcReferenceHierarchy => "E0112", DiagnosticCode::E0113RfcBumpNoAmendment => "E0113", + DiagnosticCode::E0114RfcPendingAmendment => "E0114", // E02xx - Clause DiagnosticCode::E0201ClauseSchemaInvalid => "E0201", DiagnosticCode::E0202ClauseNotFound => "E0202", @@ -53,6 +55,7 @@ pub(super) fn code(code: &DiagnosticCode) -> &'static str { DiagnosticCode::E0304AdrRefNotFound => "E0304", DiagnosticCode::E0305AdrCannotDeprecate => "E0305", DiagnosticCode::E0306AdrReferenceHierarchy => "E0306", + DiagnosticCode::E0307AdrProjectionConflict => "E0307", // E04xx - Work Item DiagnosticCode::E0401WorkSchemaInvalid => "E0401", DiagnosticCode::E0402WorkNotFound => "E0402", @@ -80,6 +83,10 @@ pub(super) fn code(code: &DiagnosticCode) -> &'static str { DiagnosticCode::E0703ReleaseNoUnreleasedItems => "E0703", DiagnosticCode::E0704ReleaseSchemaInvalid => "E0704", DiagnosticCode::E0705ReleaseRefNotFound => "E0705", + DiagnosticCode::E0706ReleaseWorkNotDone => "E0706", + DiagnosticCode::E0707ReleaseWorkDuplicate => "E0707", + DiagnosticCode::E0708ReleaseHistoryEmpty => "E0708", + DiagnosticCode::E0709ReleaseLatestMismatch => "E0709", // E10xx - Verification Guard DiagnosticCode::E1001GuardSchemaInvalid => "E1001", DiagnosticCode::E1002GuardNotFound => "E1002", @@ -142,6 +149,7 @@ pub(super) fn code(code: &DiagnosticCode) -> &'static str { DiagnosticCode::W0110SchemaOutdated => "W0110", DiagnosticCode::W0111ProjectSupportOutdated => "W0111", DiagnosticCode::W0112BareArtifactReference => "W0112", + DiagnosticCode::W0113AdrPlaceholderContext => "W0113", // I04xx - Work Item info DiagnosticCode::I0401WorkLegacyInlineHistory => "I0401", } diff --git a/src/diagnostic/code/mod.rs b/src/diagnostic/code/mod.rs index 540de33a..af714f29 100644 --- a/src/diagnostic/code/mod.rs +++ b/src/diagnostic/code/mod.rs @@ -28,6 +28,7 @@ pub enum DiagnosticCode { /// RFC refs or [[...]] targets ADR/WI — violates [[RFC-0000:C-REFERENCE-HIERARCHY]] E0112RfcReferenceHierarchy, E0113RfcBumpNoAmendment, + E0114RfcPendingAmendment, // Clause errors (E02xx) E0201ClauseSchemaInvalid, @@ -51,6 +52,7 @@ pub enum DiagnosticCode { E0305AdrCannotDeprecate, /// ADR refs or [[...]] targets WI-* — violates [[RFC-0000:C-REFERENCE-HIERARCHY]] E0306AdrReferenceHierarchy, + E0307AdrProjectionConflict, // Work Item errors (E04xx) E0401WorkSchemaInvalid, @@ -82,6 +84,10 @@ pub enum DiagnosticCode { E0703ReleaseNoUnreleasedItems, E0704ReleaseSchemaInvalid, E0705ReleaseRefNotFound, + E0706ReleaseWorkNotDone, + E0707ReleaseWorkDuplicate, + E0708ReleaseHistoryEmpty, + E0709ReleaseLatestMismatch, // Verification Guard errors (E10xx) E1001GuardSchemaInvalid, @@ -156,6 +162,8 @@ pub enum DiagnosticCode { W0111ProjectSupportOutdated, /// Known artifact ID appears in governed prose without [[...]] syntax. W0112BareArtifactReference, + /// ADR context still contains the generated placeholder text. + W0113AdrPlaceholderContext, // Informational diagnostics (I04xx) I0401WorkLegacyInlineHistory, @@ -183,6 +191,7 @@ mod tests { assert_eq!(DiagnosticCode::E0401WorkSchemaInvalid.code(), "E0401"); assert_eq!(DiagnosticCode::E0501ConfigInvalid.code(), "E0501"); assert_eq!(DiagnosticCode::E0701ReleaseInvalidSemver.code(), "E0701"); + assert_eq!(DiagnosticCode::E0709ReleaseLatestMismatch.code(), "E0709"); assert_eq!(DiagnosticCode::E0801MissingRequiredArg.code(), "E0801"); assert_eq!(DiagnosticCode::E0901IoError.code(), "E0901"); assert_eq!(DiagnosticCode::E1001GuardSchemaInvalid.code(), "E1001"); @@ -191,6 +200,7 @@ mod tests { assert_eq!(DiagnosticCode::W0101RfcNoChangelog.code(), "W0101"); assert_eq!(DiagnosticCode::W0111ProjectSupportOutdated.code(), "W0111"); assert_eq!(DiagnosticCode::W0112BareArtifactReference.code(), "W0112"); + assert_eq!(DiagnosticCode::W0113AdrPlaceholderContext.code(), "W0113"); assert_eq!(DiagnosticCode::I0401WorkLegacyInlineHistory.code(), "I0401"); } diff --git a/src/model/release.rs b/src/model/release.rs index d6a5a7cb..fa97661e 100644 --- a/src/model/release.rs +++ b/src/model/release.rs @@ -29,7 +29,6 @@ impl Default for ReleasesMeta { pub struct Release { pub version: String, pub date: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec<String>, } diff --git a/src/parse/toml_io.rs b/src/parse/toml_io.rs index d4cab0bd..d6a750c8 100644 --- a/src/parse/toml_io.rs +++ b/src/parse/toml_io.rs @@ -2,8 +2,7 @@ use super::LoadResult; use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode}; use crate::schema::{ArtifactSchema, validate_toml_value, with_schema_header}; -use crate::ui; -use crate::write::WriteOp; +use crate::write::{WriteOp, write_file}; use serde::Serialize; use serde::de::DeserializeOwned; use std::path::Path; @@ -94,16 +93,5 @@ where })?; let content = with_schema_header(schema, &body); - match op { - WriteOp::Execute => { - std::fs::write(path, &content).map_err(|e| { - Diagnostic::io_error("write TOML file", e, diagnostic_path.display().to_string()) - })?; - } - WriteOp::Preview => { - ui::dry_run_file_preview(diagnostic_path, &content); - } - } - - Ok(()) + write_file(path, &content, op, Some(diagnostic_path)) } diff --git a/src/signature/mod.rs b/src/signature/mod.rs index 54f42ac2..01fc6755 100644 --- a/src/signature/mod.rs +++ b/src/signature/mod.rs @@ -47,6 +47,7 @@ pub fn compute_rfc_content_signature(rfc: &RfcIndex) -> Result<String, Diagnosti compute_rfc_signature_with_filter(rfc, |map| { map.remove("signature"); map.remove("version"); + map.remove("phase"); map.remove("changelog"); }) } diff --git a/src/signature/tests.rs b/src/signature/tests.rs index f1437914..ebee3714 100644 --- a/src/signature/tests.rs +++ b/src/signature/tests.rs @@ -49,6 +49,7 @@ fn test_rfc_content_signature_ignores_bump_bookkeeping() -> Result<(), Diagnosti let baseline = compute_rfc_content_signature(&rfc)?; rfc.rfc.version = "0.1.1".to_string(); + rfc.rfc.phase = RfcPhase::Stable; rfc.rfc.signature = Some("legacy-or-current-signature".to_string()); rfc.rfc.changelog.push(ChangelogEntry { version: "0.1.1".to_string(), @@ -87,6 +88,19 @@ fn test_rfc_amended_accepts_legacy_full_signature_baseline() -> Result<(), Diagn Ok(()) } +#[test] +fn test_migrated_legacy_signature_ignores_later_phase_changes() -> Result<(), Diagnostic> { + let mut rfc = test_rfc_index(); + rfc.rfc.signature = Some(compute_rfc_signature(&rfc)?); + assert!(!is_rfc_amended(&rfc)); + + rfc.rfc.signature = Some(compute_rfc_content_signature(&rfc)?); + rfc.rfc.phase = RfcPhase::Test; + + assert!(!is_rfc_amended(&rfc)); + Ok(()) +} + fn test_rfc_index() -> RfcIndex { RfcIndex { rfc: RfcSpec { diff --git a/src/ui/messages/artifact.rs b/src/ui/messages/artifact.rs index 12140956..b2041aec 100644 --- a/src/ui/messages/artifact.rs +++ b/src/ui/messages/artifact.rs @@ -154,7 +154,7 @@ pub fn release_created(version: &str, date: &str, work_item_count: usize) { "Created release {} ({}) with {} work items", version.cyan().bold(), date, - work_item_count.to_string().green() + work_item_count.green() ); } else { eprintln!( @@ -163,3 +163,18 @@ pub fn release_created(version: &str, date: &str, work_item_count: usize) { ); } } + +pub fn release_undone(version: &str, work_item_count: usize) { + if use_colors() { + eprintln!( + "Undid release {} ({} work items are now unreleased)", + version.cyan().bold(), + work_item_count.green() + ); + } else { + eprintln!( + "Undid release {} ({} work items are now unreleased)", + version, work_item_count + ); + } +} diff --git a/src/validate/adr_projection.rs b/src/validate/adr_projection.rs new file mode 100644 index 00000000..051fded2 --- /dev/null +++ b/src/validate/adr_projection.rs @@ -0,0 +1,114 @@ +use crate::diagnostic::{Diagnostic, DiagnosticCode}; +use crate::model::{AdrEntry, AlternativeStatus}; +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; + +const FIXED_RENDER_HEADINGS: &[&str] = &[ + "Context", + "Decision", + "Consequences", + "References", + "Alternatives Considered", + "Options Considered", +]; + +pub(crate) fn validate_adr_projection_ownership(adr: &AdrEntry, file: String) -> Vec<Diagnostic> { + let reserved = reserved_headings(adr); + let fields = [ + ("content.context", adr.spec.content.context.as_str()), + ("content.decision", adr.spec.content.decision.as_str()), + ( + "content.consequences", + adr.spec.content.consequences.as_str(), + ), + ]; + + let mut diagnostics = Vec::new(); + for (field, markdown) in fields { + for heading in heading_texts(markdown) { + if reserved + .iter() + .any(|reserved| heading.eq_ignore_ascii_case(reserved)) + { + diagnostics.push(Diagnostic::new( + DiagnosticCode::E0307AdrProjectionConflict, + format!( + "ADR {field} heading '{heading}' conflicts with renderer-owned projection structure" + ), + file.clone(), + )); + } + } + } + diagnostics +} + +fn reserved_headings(adr: &AdrEntry) -> Vec<String> { + let mut headings = FIXED_RENDER_HEADINGS + .iter() + .map(|heading| (*heading).to_string()) + .collect::<Vec<_>>(); + headings.extend(heading_texts(&format!( + "# {}: {}", + adr.meta().id, + adr.meta().title + ))); + + for alternative in &adr.spec.content.alternatives { + let suffix = match alternative.status { + AlternativeStatus::Considered => "", + AlternativeStatus::Accepted => " (accepted)", + AlternativeStatus::Rejected => " (rejected)", + }; + headings.extend(heading_texts(&format!("# {}{suffix}", alternative.text))); + } + + headings +} + +fn heading_texts(markdown: &str) -> Vec<String> { + let mut headings = Vec::new(); + let mut current = None; + + for event in Parser::new(markdown) { + match event { + Event::Start(Tag::Heading { .. }) => current = Some(String::new()), + Event::End(TagEnd::Heading(_)) => { + if let Some(heading) = current.take() { + headings.push(heading.trim().to_string()); + } + } + Event::Text(text) | Event::Code(text) => { + if let Some(heading) = current.as_mut() { + heading.push_str(&text); + } + } + Event::SoftBreak | Event::HardBreak => { + if let Some(heading) = current.as_mut() { + heading.push(' '); + } + } + _ => {} + } + } + + headings +} + +#[cfg(test)] +mod tests { + use super::heading_texts; + + #[test] + fn extracts_visible_commonmark_heading_text() { + assert_eq!( + heading_texts("### **Options** `Considered`\n"), + vec!["Options Considered"] + ); + assert_eq!(heading_texts("Decision\n--------\n"), vec!["Decision"]); + } + + #[test] + fn ignores_heading_syntax_inside_fenced_code() { + assert!(heading_texts("```markdown\n## Decision\n```\n").is_empty()); + } +} diff --git a/src/validate/lifecycle/mod.rs b/src/validate/lifecycle/mod.rs index cdf40a9a..ac7be722 100644 --- a/src/validate/lifecycle/mod.rs +++ b/src/validate/lifecycle/mod.rs @@ -38,6 +38,7 @@ pub fn is_valid_work_transition(from: WorkItemStatus, to: WorkItemStatus) -> boo (from, to), (WorkItemStatus::Queue, WorkItemStatus::Active) | (WorkItemStatus::Active, WorkItemStatus::Done) + | (WorkItemStatus::Done, WorkItemStatus::Active) | (WorkItemStatus::Queue, WorkItemStatus::Cancelled) | (WorkItemStatus::Active, WorkItemStatus::Cancelled) ) diff --git a/src/validate/lifecycle/tests.rs b/src/validate/lifecycle/tests.rs index 8cea5dca..07229845 100644 --- a/src/validate/lifecycle/tests.rs +++ b/src/validate/lifecycle/tests.rs @@ -197,12 +197,15 @@ fn test_work_status_invalid_queue_to_done() { } #[test] -fn test_work_status_invalid_done_transitions() { - // Done is terminal. - assert!(!is_valid_work_transition( +fn test_work_status_done_to_active() { + assert!(is_valid_work_transition( WorkItemStatus::Done, WorkItemStatus::Active )); +} + +#[test] +fn test_work_status_invalid_done_transitions() { assert!(!is_valid_work_transition( WorkItemStatus::Done, WorkItemStatus::Queue diff --git a/src/validate/mod.rs b/src/validate/mod.rs index f7337461..444e1df1 100644 --- a/src/validate/mod.rs +++ b/src/validate/mod.rs @@ -7,8 +7,9 @@ use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode}; -use crate::model::ProjectIndex; +use crate::model::{AdrStatus, ProjectIndex}; +mod adr_projection; mod artifact_refs; mod bracket_refs; mod fields; @@ -21,6 +22,7 @@ mod tags; mod work_dependencies; mod work_items; +use adr_projection::validate_adr_projection_ownership; use artifact_refs::validate_artifact_refs; use bracket_refs::validate_bracket_reference_hierarchy; use rfc::{validate_clause_references, validate_rfc}; @@ -28,6 +30,7 @@ use signatures::validate_rfc_signatures; use tags::validate_artifact_tags; use work_items::{validate_work_item_descriptions, validate_work_item_legacy_inline_history}; +pub(crate) use adr_projection::validate_adr_projection_ownership as validate_adr_projection; pub use artifact_refs::validate_artifact_ref_edit; pub(crate) use fields::normalize_clause_supersession_target; pub use fields::{ArtifactKind, validate_field}; @@ -86,14 +89,20 @@ pub fn validate_project(index: &ProjectIndex, config: &Config) -> ValidationResu // Validate content is not placeholder if adr.spec.content.context.contains("Describe the context") { result.diagnostics.push(Diagnostic::new( - DiagnosticCode::W0103AdrNoRefs, + DiagnosticCode::W0113AdrPlaceholderContext, format!( "ADR has placeholder context (hint: `govctl adr set {} context \"...\"`)", adr.meta().id ), - adr_path_display, + adr_path_display.clone(), )); } + + if adr.meta().status == AdrStatus::Proposed { + result + .diagnostics + .extend(validate_adr_projection_ownership(adr, adr_path_display)); + } } // Validate artifact references (refs fields) diff --git a/src/validate/releases.rs b/src/validate/releases.rs index aecee51c..b86e901e 100644 --- a/src/validate/releases.rs +++ b/src/validate/releases.rs @@ -1,7 +1,7 @@ use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode}; -use crate::model::{ProjectIndex, ReleasesFile}; -use std::collections::HashSet; +use crate::model::{ProjectIndex, ReleasesFile, WorkItemStatus}; +use std::collections::{HashMap, HashSet}; pub fn validate_releases( releases: &ReleasesFile, @@ -15,6 +15,12 @@ pub fn validate_releases( .iter() .map(|work| work.meta().id.as_str()) .collect(); + let work_statuses: HashMap<&str, WorkItemStatus> = index + .work_items + .iter() + .map(|work| (work.meta().id.as_str(), work.meta().status)) + .collect(); + let mut released_work = HashMap::new(); let releases_display = config .display_path(&config.releases_path()) .display() @@ -39,6 +45,31 @@ pub fn validate_releases( ), releases_display.clone(), )); + continue; + } + + if work_statuses.get(work_id.as_str()) != Some(&WorkItemStatus::Done) { + diagnostics.push(Diagnostic::new( + DiagnosticCode::E0706ReleaseWorkNotDone, + format!( + "Release '{}' references Work Item '{}' with status other than done", + release.version, work_id + ), + releases_display.clone(), + )); + } + + if let Some(first_version) = released_work.insert(work_id.as_str(), &release.version) + && first_version != &release.version + { + diagnostics.push(Diagnostic::new( + DiagnosticCode::E0707ReleaseWorkDuplicate, + format!( + "Work Item '{}' is referenced by releases '{}' and '{}'", + work_id, first_version, release.version + ), + releases_display.clone(), + )); } } } diff --git a/tests/adr_gate_tests/accept.rs b/tests/adr_gate_tests/accept.rs index dbff0b84..3998f406 100644 --- a/tests/adr_gate_tests/accept.rs +++ b/tests/adr_gate_tests/accept.rs @@ -139,3 +139,31 @@ fn test_accept_succeeds_with_complete_adr() -> common::TestResult { assert_adr_gate_snapshot!(normalized); Ok(()) } + +#[test] +fn test_accept_force_does_not_bypass_projection_ownership() -> common::TestResult { + let normalized = run_gate_commands(&[ + &["adr", "new", "Test ADR"], + &[ + "adr", + "set", + "ADR-0001", + "context", + "### Options Considered", + ], + &["adr", "accept", "ADR-0001", "--force"], + &["adr", "get", "ADR-0001", "status"], + ])?; + + assert!(normalized.contains("error[E0307]"), "{normalized}"); + assert!(normalized.contains("content.context"), "{normalized}"); + assert!( + normalized.contains("heading 'Options Considered'"), + "{normalized}" + ); + assert!( + normalized.contains("$ govctl adr get ADR-0001 status\nproposed"), + "{normalized}" + ); + Ok(()) +} diff --git a/tests/changelog_tests/release_workflow.rs b/tests/changelog_tests/release_workflow.rs index e5190a5b..9c0b2346 100644 --- a/tests/changelog_tests/release_workflow.rs +++ b/tests/changelog_tests/release_workflow.rs @@ -91,3 +91,111 @@ fn test_changelog_release_workflow() -> common::TestResult { ); Ok(()) } + +#[test] +fn test_release_undo_restores_unreleased_projection_and_reopen_boundary() -> common::TestResult { + let temp_dir = TempDir::new()?; + let dir = temp_dir.path(); + let date = today(); + let wi1 = format!("WI-{}-001", date); + let wi2 = format!("WI-{}-002", date); + + let setup = vec![ + command(&["init"]), + work_new_active("First release work"), + work_add_acceptance(&wi1, "add: First capability"), + work_tick_acceptance_done(&wi1, "First capability"), + work_move_done(&wi1), + command(&["release", "0.1.0", "--date", "2026-01-01"]), + work_new_active("Second release work"), + work_add_acceptance(&wi2, "fixed: Second correction"), + work_tick_acceptance_done(&wi2, "Second correction"), + work_move_done(&wi2), + command(&["release", "0.2.0", "--date", "2026-01-02"]), + command(&["render", "changelog", "--force"]), + ]; + run_dynamic_commands(dir, &setup)?; + + let releases_path = dir.join("gov/releases.toml"); + let changelog_path = dir.join("CHANGELOG.md"); + let wi2_path = std::fs::read_dir(dir.join("gov/work"))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + std::fs::read_to_string(path) + .is_ok_and(|content| content.contains(&format!("id = \"{wi2}\""))) + }) + .ok_or("second Work Item file not found")?; + let releases_before = std::fs::read(&releases_path)?; + let changelog_before = std::fs::read(&changelog_path)?; + let work_before = std::fs::read(&wi2_path)?; + + let dry_run = run_commands(dir, &[&["release", "undo", "0.2.0", "--dry-run"]])?; + assert!(dry_run.contains("exit: 0"), "output: {dry_run}"); + assert_eq!(std::fs::read(&releases_path)?, releases_before); + + let undo = run_commands(dir, &[&["release", "undo", "0.2.0"]])?; + assert!( + undo.contains("Undid release 0.2.0 (1 work items are now unreleased)"), + "output: {undo}" + ); + assert!( + undo.contains("govctl render changelog --force"), + "output: {undo}" + ); + + let releases = std::fs::read_to_string(&releases_path)?; + assert!(releases.contains("version = \"0.1.0\"")); + assert!(!releases.contains("version = \"0.2.0\"")); + assert_eq!(std::fs::read(&wi2_path)?, work_before); + assert_eq!(std::fs::read(&changelog_path)?, changelog_before); + + run_commands(dir, &[&["render", "changelog", "--force"]])?; + let changelog = std::fs::read_to_string(&changelog_path)?; + assert!(changelog.contains("## [0.1.0] - 2026-01-01")); + assert!(!changelog.contains("## [0.2.0]")); + let unreleased = changelog + .split("## [0.1.0]") + .next() + .ok_or("missing Unreleased projection")?; + assert!(unreleased.contains("Second correction")); + + let reopen = run_commands( + dir, + &[ + &["work", "move", &wi2, "active"], + &["work", "get", &wi2, "status"], + ], + )?; + assert!( + reopen.contains(&format!("$ govctl work get {wi2} status\nactive")), + "output: {reopen}" + ); + Ok(()) +} + +#[test] +fn test_release_undo_removes_file_when_history_becomes_empty() -> common::TestResult { + let temp_dir = TempDir::new()?; + let dir = temp_dir.path(); + let date = today(); + let wi = format!("WI-{}-001", date); + let setup = vec![ + command(&["init"]), + work_new_active("Only release work"), + work_add_acceptance(&wi, "add: Only capability"), + work_tick_acceptance_done(&wi, "Only capability"), + work_move_done(&wi), + command(&["release", "0.1.0"]), + ]; + run_dynamic_commands(dir, &setup)?; + + let releases_path = dir.join("gov/releases.toml"); + assert!(releases_path.exists()); + run_commands(dir, &[&["release", "undo", "0.1.0"]])?; + assert!(!releases_path.exists()); + + let status = run_commands(dir, &[&["work", "get", &wi, "status"]])?; + assert!(status.contains(&format!("$ govctl work get {wi} status\ndone"))); + Ok(()) +} diff --git a/tests/error_tests/adr_projection.rs b/tests/error_tests/adr_projection.rs new file mode 100644 index 00000000..fd76d379 --- /dev/null +++ b/tests/error_tests/adr_projection.rs @@ -0,0 +1,92 @@ +use super::*; + +fn write_adr_with_title( + dir: &std::path::Path, + status: &str, + title: &str, + context: &str, +) -> common::TestResult { + fs::write( + dir.join("gov/adr/ADR-0001-projection.toml"), + format!( + r#"[govctl] +id = "ADR-0001" +title = "{title}" +status = "{status}" +date = "2026-01-01" + +[content] +context = '''{context}''' +decision = "Use structured alternatives." +consequences = "Projection remains deterministic." + +[[content.alternatives]] +text = "Option A" +status = "accepted" +"# + ), + )?; + Ok(()) +} + +fn write_adr(dir: &std::path::Path, status: &str, context: &str) -> common::TestResult { + write_adr_with_title(dir, status, "Projection Test", context) +} + +#[test] +fn test_check_rejects_proposed_adr_projection_headings() -> common::TestResult { + let temp_dir = init_project()?; + write_adr( + temp_dir.path(), + "proposed", + "### **Options** `Considered`\n\n### Option A (accepted)", + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert_eq!(output.matches("error[E0307]").count(), 2, "{output}"); + assert!(output.contains("content.context"), "{output}"); + assert!(output.contains("heading 'Options Considered'"), "{output}"); + assert!(output.contains("heading 'Option A (accepted)'"), "{output}"); + Ok(()) +} + +#[test] +fn test_check_ignores_projection_heading_inside_fenced_code() -> common::TestResult { + let temp_dir = init_project()?; + write_adr(temp_dir.path(), "proposed", "```markdown\n## Decision\n```")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(!output.contains("error[E0307]"), "{output}"); + assert!(output.contains("exit: 0"), "{output}"); + Ok(()) +} + +#[test] +fn test_check_compares_formatted_title_by_visible_text() -> common::TestResult { + let temp_dir = init_project()?; + write_adr_with_title( + temp_dir.path(), + "proposed", + "**Projection** `Test`", + "# ADR-0001: Projection Test", + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0307]"), "{output}"); + assert!( + output.contains("heading 'ADR-0001: Projection Test'"), + "{output}" + ); + Ok(()) +} + +#[test] +fn test_check_preserves_historical_adr_compatibility() -> common::TestResult { + let temp_dir = init_project()?; + write_adr(temp_dir.path(), "accepted", "### Options Considered")?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(!output.contains("error[E0307]"), "{output}"); + assert!(output.contains("exit: 0"), "{output}"); + Ok(()) +} diff --git a/tests/error_tests/mod.rs b/tests/error_tests/mod.rs index 892cd384..96238dad 100644 --- a/tests/error_tests/mod.rs +++ b/tests/error_tests/mod.rs @@ -1,5 +1,7 @@ use super::*; +mod adr_projection; +mod release; mod rfc_clause_cases; mod schema; mod work; diff --git a/tests/error_tests/release.rs b/tests/error_tests/release.rs new file mode 100644 index 00000000..95a7dcbc --- /dev/null +++ b/tests/error_tests/release.rs @@ -0,0 +1,234 @@ +use super::*; + +fn write_done_work_item(dir: &std::path::Path) -> common::TestResult { + fs::write( + dir.join("gov/work/2026-01-01-done.toml"), + r#"[govctl] +id = "WI-2026-01-01-001" +title = "Done work" +status = "done" +created = "2026-01-01" +started = "2026-01-01" +completed = "2026-01-01" + +[content] +description = "Completed work" + +[[content.acceptance_criteria]] +text = "Done" +status = "done" +category = "chore" +"#, + )?; + Ok(()) +} + +#[test] +fn test_check_rejects_work_item_referenced_by_multiple_releases() -> common::TestResult { + let temp_dir = init_project()?; + write_done_work_item(temp_dir.path())?; + fs::write( + temp_dir.path().join("gov/releases.toml"), + r#"[[releases]] +version = "0.2.0" +date = "2026-01-02" +refs = ["WI-2026-01-01-001"] + +[[releases]] +version = "0.1.0" +date = "2026-01-01" +refs = ["WI-2026-01-01-001"] +"#, + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0707]"), "output: {output}"); + assert!( + output.contains("referenced by releases '0.2.0' and '0.1.0'"), + "output: {output}" + ); + Ok(()) +} + +#[test] +fn test_check_rejects_released_work_item_that_is_not_done() -> common::TestResult { + let temp_dir = init_project()?; + fs::write( + temp_dir.path().join("gov/work/2026-01-01-active.toml"), + r#"[govctl] +id = "WI-2026-01-01-001" +title = "Active work" +status = "active" +created = "2026-01-01" +started = "2026-01-01" + +[content] +description = "Active work" +"#, + )?; + fs::write( + temp_dir.path().join("gov/releases.toml"), + r#"[[releases]] +version = "0.1.0" +date = "2026-01-01" +refs = ["WI-2026-01-01-001"] +"#, + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0706]"), "output: {output}"); + assert!( + output.contains("status other than done"), + "output: {output}" + ); + Ok(()) +} + +#[test] +fn test_release_rejects_invalid_calendar_dates_without_writing() -> common::TestResult { + let temp_dir = init_project()?; + write_done_work_item(temp_dir.path())?; + let releases_path = temp_dir.path().join("gov/releases.toml"); + + let output = run_commands( + temp_dir.path(), + &[ + &["release", "0.1.0", "--date", "2026/01/01"], + &["release", "0.1.0", "--date", "2026-02-30"], + ], + )?; + + assert_eq!(output.matches("error[E0704]").count(), 2, "{output}"); + assert!(!releases_path.exists(), "invalid release created a file"); + Ok(()) +} + +#[test] +fn test_check_rejects_release_entry_without_refs() -> common::TestResult { + let temp_dir = init_project()?; + fs::write( + temp_dir.path().join("gov/releases.toml"), + r#"[[releases]] +version = "0.1.0" +date = "2026-01-01" +"#, + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error[E0704]"), "output: {output}"); + assert!( + output.contains("\"refs\" is a required property"), + "{output}" + ); + Ok(()) +} + +#[test] +fn test_release_undo_rejects_empty_history() -> common::TestResult { + let temp_dir = init_project()?; + + let output = run_commands(temp_dir.path(), &[&["release", "undo", "0.1.0"]])?; + + assert!(output.contains("error[E0708]"), "output: {output}"); + assert!( + output.contains("release history is empty"), + "output: {output}" + ); + assert!(!temp_dir.path().join("gov/releases.toml").exists()); + Ok(()) +} + +#[test] +fn test_release_undo_rejects_invalid_expected_version() -> common::TestResult { + let temp_dir = init_project()?; + + let output = run_commands(temp_dir.path(), &[&["release", "undo", "not-semver"]])?; + + assert!(output.contains("error[E0701]"), "output: {output}"); + assert!(!temp_dir.path().join("gov/releases.toml").exists()); + Ok(()) +} + +#[test] +fn test_release_undo_rejects_stale_expected_version_without_writing() -> common::TestResult { + let temp_dir = init_project()?; + let releases_path = temp_dir.path().join("gov/releases.toml"); + let releases = r#"[[releases]] +version = "0.2.0" +date = "2026-01-02" +refs = ["WI-2026-01-02-001"] + +[[releases]] +version = "0.1.0" +date = "2026-01-01" +refs = ["WI-2026-01-01-001"] +"#; + fs::write(&releases_path, releases)?; + + let output = run_commands(temp_dir.path(), &[&["release", "undo", "0.1.0"]])?; + + assert!(output.contains("error[E0709]"), "output: {output}"); + assert!( + output.contains("newest release is 0.2.0"), + "output: {output}" + ); + assert_eq!(fs::read_to_string(releases_path)?, releases); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn test_release_undo_write_failure_preserves_release_bytes() -> common::TestResult { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = init_project()?; + let gov_dir = temp_dir.path().join("gov"); + let releases_path = gov_dir.join("releases.toml"); + let releases = r#"[[releases]] +version = "0.2.0" +date = "2026-01-02" +refs = ["WI-2026-01-02-001"] + +[[releases]] +version = "0.1.0" +date = "2026-01-01" +refs = ["WI-2026-01-01-001"] +"#; + fs::write(&releases_path, releases)?; + + let original_mode = fs::metadata(&gov_dir)?.permissions().mode(); + fs::set_permissions(&gov_dir, fs::Permissions::from_mode(0o555))?; + let output = run_commands(temp_dir.path(), &[&["release", "undo", "0.2.0"]]); + fs::set_permissions(&gov_dir, fs::Permissions::from_mode(original_mode))?; + let output = output?; + + assert!(output.contains("error[E0901]"), "output: {output}"); + assert_eq!(fs::read_to_string(releases_path)?, releases); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn test_release_undo_delete_failure_preserves_only_release() -> common::TestResult { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = init_project()?; + let gov_dir = temp_dir.path().join("gov"); + let releases_path = gov_dir.join("releases.toml"); + let releases = r#"[[releases]] +version = "0.1.0" +date = "2026-01-01" +refs = ["WI-2026-01-01-001"] +"#; + fs::write(&releases_path, releases)?; + + let original_mode = fs::metadata(&gov_dir)?.permissions().mode(); + fs::set_permissions(&gov_dir, fs::Permissions::from_mode(0o555))?; + let output = run_commands(temp_dir.path(), &[&["release", "undo", "0.1.0"]]); + fs::set_permissions(&gov_dir, fs::Permissions::from_mode(original_mode))?; + let output = output?; + + assert!(output.contains("error[E0901]"), "output: {output}"); + assert_eq!(fs::read_to_string(releases_path)?, releases); + Ok(()) +} diff --git a/tests/lifecycle_tests/adr.rs b/tests/lifecycle_tests/adr.rs index 9d69278a..893926a7 100644 --- a/tests/lifecycle_tests/adr.rs +++ b/tests/lifecycle_tests/adr.rs @@ -1,5 +1,8 @@ use super::*; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + // ============================================================================ // ADR Accept/Reject Tests // ============================================================================ @@ -239,3 +242,29 @@ fn test_accept_nonexistent_adr() -> common::TestResult { assert_lifecycle_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); Ok(()) } + +#[cfg(unix)] +#[test] +fn test_reject_adr_write_failure_preserves_source_bytes() -> common::TestResult { + let temp_dir = init_project()?; + run_commands(temp_dir.path(), &[&["adr", "new", "Atomic ADR"]])?; + + let adr_dir = temp_dir.path().join("gov/adr"); + let adr_path = std::fs::read_dir(&adr_dir)? + .next() + .ok_or("missing ADR")?? + .path(); + let original = std::fs::read(&adr_path)?; + let original_permissions = std::fs::metadata(&adr_dir)?.permissions(); + let mut unwritable = original_permissions.clone(); + unwritable.set_mode(original_permissions.mode() & !0o222); + std::fs::set_permissions(&adr_dir, unwritable)?; + + let output = run_commands(temp_dir.path(), &[&["adr", "reject", "ADR-0001"]]); + std::fs::set_permissions(&adr_dir, original_permissions)?; + let output = output?; + + assert!(output.contains("error[E0901]"), "output: {output}"); + assert_eq!(std::fs::read(adr_path)?, original); + Ok(()) +} diff --git a/tests/lifecycle_tests/clause.rs b/tests/lifecycle_tests/clause.rs index bd265a56..5d046c7a 100644 --- a/tests/lifecycle_tests/clause.rs +++ b/tests/lifecycle_tests/clause.rs @@ -620,11 +620,13 @@ fn test_supersede_clause_rejects_repeated_transition() -> common::TestResult { )?; assert!( - output.contains("Clause is already superseded"), + output.contains( + "Clause is already superseded. Superseded is terminal; there are no valid transitions" + ), "output: {output}" ); assert!( - output.contains("error[E0209]: Clause is already superseded (RFC-0001:C-OLD)\nexit: 1"), + output.contains("error[E0209]: Clause is already superseded. Superseded is terminal; there are no valid transitions (RFC-0001:C-OLD)\nexit: 1"), "output: {output}" ); assert!( diff --git a/tests/lifecycle_tests/rfc_cases/advance.rs b/tests/lifecycle_tests/rfc_cases/advance.rs index 488748ea..5f1938cb 100644 --- a/tests/lifecycle_tests/rfc_cases/advance.rs +++ b/tests/lifecycle_tests/rfc_cases/advance.rs @@ -118,6 +118,135 @@ fn test_advance_nonexistent_rfc() -> common::TestResult { Ok(()) } +#[test] +fn test_advance_rejects_unversioned_content_amendment() -> common::TestResult { + let temp_dir = init_project()?; + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Test RFC"], + &[ + "clause", + "new", + "RFC-0001:C-TEST", + "Test Clause", + "-s", + "Specification", + "-k", + "normative", + ], + &[ + "clause", + "edit", + "RFC-0001:C-TEST", + "--text", + "Original normative behavior.", + ], + &["rfc", "finalize", "RFC-0001", "normative"], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Establish baseline", + ], + &[ + "clause", + "edit", + "RFC-0001:C-TEST", + "--text", + "Amended normative behavior.", + ], + &["rfc", "advance", "RFC-0001", "impl"], + &["rfc", "get", "RFC-0001", "phase"], + ], + )?; + + assert!(output.contains("error[E0114]"), "output: {output}"); + assert!(output.contains("unversioned amendment"), "output: {output}"); + assert!( + output.contains("$ govctl rfc get RFC-0001 phase\nspec"), + "output: {output}" + ); + Ok(()) +} + +#[test] +fn test_advance_migrates_legacy_signature_before_phase_changes() -> common::TestResult { + let temp_dir = init_project()?; + run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Legacy signature RFC"], + &["rfc", "finalize", "RFC-0001", "normative"], + &["rfc", "render", "RFC-0001"], + ], + )?; + + let rendered = fs::read_to_string(temp_dir.path().join("docs/rfc/RFC-0001.md"))?; + let signature = rendered + .lines() + .find_map(|line| { + line.trim() + .strip_prefix("<!-- SIGNATURE: sha256:") + .and_then(|value| value.strip_suffix(" -->")) + }) + .ok_or("missing rendered RFC signature")?; + let rfc_path = temp_dir.path().join("gov/rfc/RFC-0001/rfc.toml"); + let mut rfc: toml::Value = toml::from_str(&fs::read_to_string(&rfc_path)?)?; + rfc.get_mut("govctl") + .and_then(toml::Value::as_table_mut) + .ok_or("RFC govctl section is not a table")? + .insert( + "signature".to_string(), + toml::Value::String(signature.to_string()), + ); + fs::write(&rfc_path, toml::to_string_pretty(&rfc)?)?; + + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "advance", "RFC-0001", "impl"], + &["rfc", "advance", "RFC-0001", "test"], + &["rfc", "get", "RFC-0001", "phase"], + ], + )?; + + assert!(!output.contains("error[E0114]"), "output: {output}"); + assert!( + output.contains("$ govctl rfc get RFC-0001 phase\ntest"), + "output: {output}" + ); + Ok(()) +} + +#[test] +fn test_advance_rejects_deprecated_rfc_entering_impl() -> common::TestResult { + let temp_dir = init_project()?; + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Deprecated RFC"], + &["rfc", "finalize", "RFC-0001", "normative"], + &["rfc", "deprecate", "RFC-0001", "--force"], + &["rfc", "advance", "RFC-0001", "impl"], + &["rfc", "get", "RFC-0001", "phase"], + ], + )?; + + assert!(output.contains("error[E0104]"), "output: {output}"); + assert!( + output.contains("Only normative RFCs can enter implementation phases"), + "output: {output}" + ); + assert!( + output.contains("$ govctl rfc get RFC-0001 phase\nspec"), + "output: {output}" + ); + Ok(()) +} + #[test] fn test_finalize_sets_updated_field() -> common::TestResult { let (temp_dir, date) = init_project_with_date()?; diff --git a/tests/lifecycle_tests/rfc_cases/bump.rs b/tests/lifecycle_tests/rfc_cases/bump.rs index 8955072c..99101312 100644 --- a/tests/lifecycle_tests/rfc_cases/bump.rs +++ b/tests/lifecycle_tests/rfc_cases/bump.rs @@ -76,6 +76,111 @@ fn test_bump_major_version() -> common::TestResult { Ok(()) } +#[test] +fn test_content_bump_restarts_stable_rfc_at_spec() -> common::TestResult { + let temp_dir = init_project()?; + + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Test RFC"], + &[ + "clause", + "new", + "RFC-0001:C-TEST", + "Test Clause", + "-s", + "Specification", + "-k", + "normative", + ], + &[ + "clause", + "edit", + "RFC-0001:C-TEST", + "--text", + "Original normative behavior.", + ], + &["rfc", "finalize", "RFC-0001", "normative"], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Establish baseline", + ], + &["rfc", "advance", "RFC-0001", "impl"], + &["rfc", "advance", "RFC-0001", "test"], + &["rfc", "advance", "RFC-0001", "stable"], + &[ + "clause", + "edit", + "RFC-0001:C-TEST", + "--text", + "Updated normative behavior.", + ], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Release amendment", + ], + &["rfc", "get", "RFC-0001", "phase"], + ], + )?; + + assert!( + output.contains("$ govctl rfc get RFC-0001 phase\nspec"), + "output: {output}" + ); + Ok(()) +} + +#[test] +fn test_signature_baseline_and_changelog_only_update_preserve_stable_phase() -> common::TestResult { + let temp_dir = init_project()?; + + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Test RFC"], + &["rfc", "finalize", "RFC-0001", "normative"], + &["rfc", "advance", "RFC-0001", "impl"], + &["rfc", "advance", "RFC-0001", "test"], + &["rfc", "advance", "RFC-0001", "stable"], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Establish baseline", + ], + &["rfc", "get", "RFC-0001", "phase"], + &[ + "rfc", + "bump", + "RFC-0001", + "--change", + "fixed: Add changelog note", + ], + &["rfc", "get", "RFC-0001", "phase"], + ], + )?; + + assert_eq!( + output + .matches("$ govctl rfc get RFC-0001 phase\nstable") + .count(), + 2, + "output: {output}" + ); + Ok(()) +} + #[test] fn test_bump_requires_summary() -> common::TestResult { let (temp_dir, date) = init_project_with_date()?; diff --git a/tests/lifecycle_tests/rfc_cases/deprecation.rs b/tests/lifecycle_tests/rfc_cases/deprecation.rs index 6fc37678..18052350 100644 --- a/tests/lifecycle_tests/rfc_cases/deprecation.rs +++ b/tests/lifecycle_tests/rfc_cases/deprecation.rs @@ -54,6 +54,46 @@ fn test_supersede_rfc() -> common::TestResult { Ok(()) } +#[test] +fn test_supersede_rejects_impl_phase_without_mutation() -> common::TestResult { + let temp_dir = init_project()?; + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Old RFC"], + &["rfc", "finalize", "RFC-0001", "normative"], + &["rfc", "advance", "RFC-0001", "impl"], + &["rfc", "new", "New RFC"], + &["rfc", "finalize", "RFC-0002", "normative"], + &[ + "rfc", + "supersede", + "RFC-0001", + "--by", + "RFC-0002", + "--force", + ], + &["rfc", "get", "RFC-0001", "status"], + &["rfc", "get", "RFC-0002", "supersedes"], + ], + )?; + + assert!(output.contains("error[E0104]"), "output: {output}"); + assert!( + output.contains("Advance the current version to stable first"), + "output: {output}" + ); + assert!( + output.contains("$ govctl rfc get RFC-0001 status\nnormative"), + "output: {output}" + ); + assert!( + output.contains("$ govctl rfc get RFC-0002 supersedes\n\nexit: 0"), + "output: {output}" + ); + Ok(()) +} + #[test] fn test_supersede_nonexistent_rfc() -> common::TestResult { let temp_dir = init_project()?; diff --git a/tests/lifecycle_tests/rfc_cases/finalize.rs b/tests/lifecycle_tests/rfc_cases/finalize.rs index 9266bc0d..de439cf5 100644 --- a/tests/lifecycle_tests/rfc_cases/finalize.rs +++ b/tests/lifecycle_tests/rfc_cases/finalize.rs @@ -37,6 +37,28 @@ fn test_finalize_draft_to_deprecated() -> common::TestResult { Ok(()) } +#[test] +fn test_finalize_rejects_deprecated_target_for_normative_rfc() -> common::TestResult { + let temp_dir = init_project()?; + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Normative RFC"], + &["rfc", "finalize", "RFC-0001", "normative"], + &["rfc", "finalize", "RFC-0001", "deprecated"], + &["rfc", "get", "RFC-0001", "status"], + ], + )?; + + assert!(output.contains("invalid value 'deprecated'"), "{output}"); + assert!(output.contains("[possible values: normative]"), "{output}"); + assert!( + output.contains("$ govctl rfc get RFC-0001 status\nnormative"), + "{output}" + ); + Ok(()) +} + #[test] fn test_finalize_normative_to_deprecated() -> common::TestResult { let (temp_dir, date) = init_project_with_date()?; @@ -46,7 +68,7 @@ fn test_finalize_normative_to_deprecated() -> common::TestResult { &[ &["rfc", "new", "Old RFC"], &["rfc", "finalize", "RFC-0001", "normative"], - &["rfc", "deprecate", "RFC-0001"], + &["rfc", "deprecate", "RFC-0001", "--force"], &["rfc", "list"], ], )?; @@ -54,6 +76,32 @@ fn test_finalize_normative_to_deprecated() -> common::TestResult { Ok(()) } +#[test] +fn test_deprecate_rejects_impl_phase_without_mutation() -> common::TestResult { + let temp_dir = init_project()?; + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "In-progress RFC"], + &["rfc", "finalize", "RFC-0001", "normative"], + &["rfc", "advance", "RFC-0001", "impl"], + &["rfc", "deprecate", "RFC-0001", "--force"], + &["rfc", "get", "RFC-0001", "status"], + ], + )?; + + assert!(output.contains("error[E0104]"), "output: {output}"); + assert!( + output.contains("Advance the current version to stable first"), + "output: {output}" + ); + assert!( + output.contains("$ govctl rfc get RFC-0001 status\nnormative"), + "output: {output}" + ); + Ok(()) +} + #[test] fn test_finalize_already_normative_fails() -> common::TestResult { let (temp_dir, date) = init_project_with_date()?; diff --git a/tests/lock_tests/concurrency.rs b/tests/lock_tests/concurrency.rs index 50e4ba16..c8255083 100644 --- a/tests/lock_tests/concurrency.rs +++ b/tests/lock_tests/concurrency.rs @@ -203,3 +203,59 @@ fn test_concurrent_tick_commands_persist_all_acceptance_criteria_updates() -> co ); Ok(()) } + +#[test] +fn test_release_undo_reads_head_after_waiting_for_lock() -> common::TestResult { + let temp_dir = init_project()?; + create_config_with_timeout(temp_dir.path(), 5)?; + + let releases_path = temp_dir.path().join("gov/releases.toml"); + fs::write( + &releases_path, + r#"[[releases]] +version = "0.1.0" +date = "2026-01-01" +refs = ["WI-2026-01-01-001"] +"#, + )?; + + let lock = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(temp_dir.path().join("gov/.govctl.lock"))?; + lock.lock_exclusive()?; + + let child = Command::new(env!("CARGO_BIN_EXE_govctl")) + .args(["release", "undo", "0.1.0"]) + .current_dir(temp_dir.path()) + .env("NO_COLOR", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + thread::sleep(Duration::from_millis(100)); + let updated = r#"[[releases]] +version = "0.2.0" +date = "2026-01-02" +refs = ["WI-2026-01-02-001"] + +[[releases]] +version = "0.1.0" +date = "2026-01-01" +refs = ["WI-2026-01-01-001"] +"#; + fs::write(&releases_path, updated)?; + FileExt::unlock(&lock)?; + + let output = child.wait_with_output()?; + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("error[E0709]"), "stderr: {stderr}"); + assert!( + stderr.contains("newest release is 0.2.0"), + "stderr: {stderr}" + ); + assert_eq!(fs::read_to_string(releases_path)?, updated); + Ok(()) +} diff --git a/tests/snapshots/test_changelog__changelog_errors.snap b/tests/snapshots/test_changelog__changelog_errors.snap index b9c343c3..cf39ca25 100644 --- a/tests/snapshots/test_changelog__changelog_errors.snap +++ b/tests/snapshots/test_changelog__changelog_errors.snap @@ -1,7 +1,6 @@ --- -source: tests/test_changelog.rs -assertion_line: 264 -expression: "normalize_output(&error_output, dir, &date)" +source: tests/changelog_tests/release_workflow.rs +expression: value --- $ govctl release invalid-version error[E0701]: Invalid semver version: invalid-version (gov/releases.toml) diff --git a/tests/snapshots/test_changelog__changelog_render.snap b/tests/snapshots/test_changelog__changelog_render.snap index 304f5b83..cb3b45e8 100644 --- a/tests/snapshots/test_changelog__changelog_render.snap +++ b/tests/snapshots/test_changelog__changelog_render.snap @@ -1,6 +1,6 @@ --- -source: tests/test_changelog.rs -expression: "normalize_output(&changelog_output, dir, &date)" +source: tests/changelog_tests/release_workflow.rs +expression: value --- $ govctl status govctl status diff --git a/tests/snapshots/test_changelog__changelog_setup.snap b/tests/snapshots/test_changelog__changelog_setup.snap index 810476fd..9518a737 100644 --- a/tests/snapshots/test_changelog__changelog_setup.snap +++ b/tests/snapshots/test_changelog__changelog_setup.snap @@ -1,6 +1,6 @@ --- -source: tests/test_changelog.rs -expression: "normalize_output(&setup_output, dir, &date)" +source: tests/changelog_tests/release_workflow.rs +expression: value --- $ govctl init Created: gov diff --git a/tests/snapshots/test_describe__describe_basic.snap b/tests/snapshots/test_describe__describe_basic.snap index 3160307d..7689c4fd 100644 --- a/tests/snapshots/test_describe__describe_basic.snap +++ b/tests/snapshots/test_describe__describe_basic.snap @@ -201,7 +201,7 @@ $ govctl describe }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -319,12 +319,12 @@ $ govctl describe ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_describe__describe_in_initialized_project.snap b/tests/snapshots/test_describe__describe_in_initialized_project.snap index 3160307d..7689c4fd 100644 --- a/tests/snapshots/test_describe__describe_in_initialized_project.snap +++ b/tests/snapshots/test_describe__describe_in_initialized_project.snap @@ -201,7 +201,7 @@ $ govctl describe }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -319,12 +319,12 @@ $ govctl describe ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_describe__describe_with_context_active_work_item.snap b/tests/snapshots/test_describe__describe_with_context_active_work_item.snap index 3bfe85b9..7f9602df 100644 --- a/tests/snapshots/test_describe__describe_with_context_active_work_item.snap +++ b/tests/snapshots/test_describe__describe_with_context_active_work_item.snap @@ -206,7 +206,7 @@ $ govctl describe --context }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -324,12 +324,12 @@ $ govctl describe --context ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_describe__describe_with_context_draft_rfc.snap b/tests/snapshots/test_describe__describe_with_context_draft_rfc.snap index cd20b3d8..f164a3c7 100644 --- a/tests/snapshots/test_describe__describe_with_context_draft_rfc.snap +++ b/tests/snapshots/test_describe__describe_with_context_draft_rfc.snap @@ -206,7 +206,7 @@ $ govctl describe --context }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -324,12 +324,12 @@ $ govctl describe --context ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_describe__describe_with_context_empty_project.snap b/tests/snapshots/test_describe__describe_with_context_empty_project.snap index cba17323..55d5d304 100644 --- a/tests/snapshots/test_describe__describe_with_context_empty_project.snap +++ b/tests/snapshots/test_describe__describe_with_context_empty_project.snap @@ -201,7 +201,7 @@ $ govctl describe --context }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -319,12 +319,12 @@ $ govctl describe --context ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_describe__describe_with_context_normative_impl_phase_rfc.snap b/tests/snapshots/test_describe__describe_with_context_normative_impl_phase_rfc.snap index dc732cdf..46855847 100644 --- a/tests/snapshots/test_describe__describe_with_context_normative_impl_phase_rfc.snap +++ b/tests/snapshots/test_describe__describe_with_context_normative_impl_phase_rfc.snap @@ -214,7 +214,7 @@ $ govctl describe --context }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -332,12 +332,12 @@ $ govctl describe --context ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_describe__describe_with_context_normative_spec_phase_rfc.snap b/tests/snapshots/test_describe__describe_with_context_normative_spec_phase_rfc.snap index cb5a9368..06540331 100644 --- a/tests/snapshots/test_describe__describe_with_context_normative_spec_phase_rfc.snap +++ b/tests/snapshots/test_describe__describe_with_context_normative_spec_phase_rfc.snap @@ -210,7 +210,7 @@ $ govctl describe --context }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -328,12 +328,12 @@ $ govctl describe --context ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_describe__describe_with_context_normative_test_phase_rfc.snap b/tests/snapshots/test_describe__describe_with_context_normative_test_phase_rfc.snap index 26f2d572..a6d55c3f 100644 --- a/tests/snapshots/test_describe__describe_with_context_normative_test_phase_rfc.snap +++ b/tests/snapshots/test_describe__describe_with_context_normative_test_phase_rfc.snap @@ -218,7 +218,7 @@ $ govctl describe --context }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -336,12 +336,12 @@ $ govctl describe --context ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_describe__describe_with_context_proposed_adr.snap b/tests/snapshots/test_describe__describe_with_context_proposed_adr.snap index 49500e32..c46f09c2 100644 --- a/tests/snapshots/test_describe__describe_with_context_proposed_adr.snap +++ b/tests/snapshots/test_describe__describe_with_context_proposed_adr.snap @@ -205,7 +205,7 @@ $ govctl describe --context }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -323,12 +323,12 @@ $ govctl describe --context ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_describe__describe_with_context_queued_work_items.snap b/tests/snapshots/test_describe__describe_with_context_queued_work_items.snap index 9491b44f..41c5eca6 100644 --- a/tests/snapshots/test_describe__describe_with_context_queued_work_items.snap +++ b/tests/snapshots/test_describe__describe_with_context_queued_work_items.snap @@ -211,7 +211,7 @@ $ govctl describe --context }, { "name": "rfc finalize", - "purpose": "Transition RFC status to normative or deprecated", + "purpose": "Transition a draft RFC to normative status", "when_to_use": "When an RFC spec is complete and ready for implementation. 'normative' makes it binding law.", "example": "govctl rfc finalize RFC-0001 normative", "prerequisites": [ @@ -329,12 +329,12 @@ $ govctl describe --context ] }, { - "name": "release", - "purpose": "Cut a release (collect unreleased work items)", - "when_to_use": "When releasing a new version. Collects done work items into changelog.", + "name": "release / release undo", + "purpose": "Create or correct the newest local release cut", + "when_to_use": "To group done Work Items under a version or correct an accidental newest cut.", "example": "govctl release 0.2.0", "prerequisites": [ - "Done work items exist" + "Cut requires unreleased done Work Items; undo requires a matching newest version" ] }, { diff --git a/tests/snapshots/test_help__rfc_root_help.snap b/tests/snapshots/test_help__rfc_root_help.snap index cf76894c..3a4bccb8 100644 --- a/tests/snapshots/test_help__rfc_root_help.snap +++ b/tests/snapshots/test_help__rfc_root_help.snap @@ -1,7 +1,6 @@ --- source: tests/test_help.rs -assertion_line: 22 -expression: "normalize_output(&output, temp_dir.path(), &date)" +expression: normalized --- $ govctl rfc --help RFC operations @@ -18,7 +17,7 @@ Commands: add Add value to RFC array field remove Remove value from RFC array field bump Bump RFC version - finalize Finalize RFC status (draft → normative or deprecated) + finalize Finalize RFC status (draft → normative) advance Advance RFC phase deprecate Deprecate RFC supersede Supersede RFC diff --git a/tests/snapshots/test_lifecycle__accept_already_accepted_fails.snap b/tests/snapshots/test_lifecycle__accept_already_accepted_fails.snap index 7456cabf..0f59742a 100644 --- a/tests/snapshots/test_lifecycle__accept_already_accepted_fails.snap +++ b/tests/snapshots/test_lifecycle__accept_already_accepted_fails.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)?" +source: tests/lifecycle_tests/adr.rs +expression: snapshot --- $ govctl adr new Test Decision Created ADR: gov/adr/ADR-XXXX-test-decision.toml @@ -27,5 +27,5 @@ Accepted ADR: ADR-0001 exit: 0 $ govctl adr accept ADR-0001 -error[E0303]: Invalid ADR transition: accepted -> accepted (ADR-0001) +error[E0303]: Invalid ADR transition: accepted -> accepted. Valid transitions from accepted: superseded (ADR-0001) exit: 1 diff --git a/tests/snapshots/test_lifecycle__accept_rejected_adr_fails.snap b/tests/snapshots/test_lifecycle__accept_rejected_adr_fails.snap index bc41327a..522d33b8 100644 --- a/tests/snapshots/test_lifecycle__accept_rejected_adr_fails.snap +++ b/tests/snapshots/test_lifecycle__accept_rejected_adr_fails.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)" +source: tests/lifecycle_tests/adr.rs +expression: snapshot --- $ govctl adr new Bad Decision Created ADR: gov/adr/ADR-XXXX-bad-decision.toml @@ -11,5 +11,5 @@ Rejected ADR: ADR-0001 exit: 0 $ govctl adr accept ADR-0001 -error[E0303]: Invalid ADR transition: rejected -> accepted (ADR-0001) +error[E0303]: Invalid ADR transition: rejected -> accepted. Valid transitions from rejected: none (rejected is terminal) (ADR-0001) exit: 1 diff --git a/tests/snapshots/test_lifecycle__advance_backwards_fails.snap b/tests/snapshots/test_lifecycle__advance_backwards_fails.snap index 82b7ced2..5792ce63 100644 --- a/tests/snapshots/test_lifecycle__advance_backwards_fails.snap +++ b/tests/snapshots/test_lifecycle__advance_backwards_fails.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)" +source: tests/lifecycle_tests/rfc_cases/advance.rs +expression: snapshot --- $ govctl rfc new Test RFC Created RFC: gov/rfc/RFC-0001/rfc.toml @@ -16,5 +16,5 @@ Advanced RFC-0001 to phase: impl exit: 0 $ govctl rfc advance RFC-0001 spec -error[E0104]: Invalid phase transition: impl -> spec (RFC-0001) +error[E0104]: Invalid phase transition: impl -> spec. Valid transition from impl: test (RFC-0001) exit: 1 diff --git a/tests/snapshots/test_lifecycle__advance_draft_to_impl_fails.snap b/tests/snapshots/test_lifecycle__advance_draft_to_impl_fails.snap index 578be893..dc00f471 100644 --- a/tests/snapshots/test_lifecycle__advance_draft_to_impl_fails.snap +++ b/tests/snapshots/test_lifecycle__advance_draft_to_impl_fails.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)" +source: tests/lifecycle_tests/rfc_cases/advance.rs +expression: snapshot --- $ govctl rfc new Test RFC Created RFC: gov/rfc/RFC-0001/rfc.toml @@ -8,5 +8,5 @@ Created RFC: gov/rfc/RFC-0001/rfc.toml exit: 0 $ govctl rfc advance RFC-0001 impl -error[E0104]: Cannot advance to impl while status is draft. Finalize to normative first. (RFC-0001) +error[E0104]: Cannot advance to impl while status is draft. Only normative RFCs can enter implementation phases. (RFC-0001) exit: 1 diff --git a/tests/snapshots/test_lifecycle__advance_skip_phase_fails.snap b/tests/snapshots/test_lifecycle__advance_skip_phase_fails.snap index ada89e31..501f7004 100644 --- a/tests/snapshots/test_lifecycle__advance_skip_phase_fails.snap +++ b/tests/snapshots/test_lifecycle__advance_skip_phase_fails.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)" +source: tests/lifecycle_tests/rfc_cases/advance.rs +expression: snapshot --- $ govctl rfc new Test RFC Created RFC: gov/rfc/RFC-0001/rfc.toml @@ -12,5 +12,5 @@ Finalized RFC-0001 to status: normative exit: 0 $ govctl rfc advance RFC-0001 test -error[E0104]: Invalid phase transition: spec -> test (RFC-0001) +error[E0104]: Invalid phase transition: spec -> test. Valid transition from spec: impl (RFC-0001) exit: 1 diff --git a/tests/snapshots/test_lifecycle__deprecate_clause_already_deprecated_fails.snap b/tests/snapshots/test_lifecycle__deprecate_clause_already_deprecated_fails.snap index 0e5ceb13..25513a15 100644 --- a/tests/snapshots/test_lifecycle__deprecate_clause_already_deprecated_fails.snap +++ b/tests/snapshots/test_lifecycle__deprecate_clause_already_deprecated_fails.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)" +source: tests/lifecycle_tests/clause.rs +expression: snapshot --- $ govctl rfc new Deprecate Twice RFC Created RFC: gov/rfc/RFC-0001/rfc.toml @@ -17,5 +17,5 @@ Deprecated clause: RFC-0001:C-ONE exit: 0 $ govctl clause deprecate RFC-0001:C-ONE --force -error[E0208]: Clause is already deprecated (RFC-0001:C-ONE) +error[E0208]: Clause is already deprecated. Valid transition from deprecated: superseded via `clause supersede` (RFC-0001:C-ONE) exit: 1 diff --git a/tests/snapshots/test_lifecycle__deprecate_clause_superseded_fails.snap b/tests/snapshots/test_lifecycle__deprecate_clause_superseded_fails.snap index 92d88d0f..ff1ceba1 100644 --- a/tests/snapshots/test_lifecycle__deprecate_clause_superseded_fails.snap +++ b/tests/snapshots/test_lifecycle__deprecate_clause_superseded_fails.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)" +source: tests/lifecycle_tests/clause.rs +expression: snapshot --- $ govctl rfc new Supersede Then Deprecate RFC Created RFC: gov/rfc/RFC-0001/rfc.toml @@ -23,5 +23,5 @@ Superseded clause: RFC-0001:C-OLD exit: 0 $ govctl clause deprecate RFC-0001:C-OLD --force -error[E0209]: Clause is superseded, cannot deprecate (RFC-0001:C-OLD) +error[E0209]: Clause is superseded, cannot deprecate. Superseded is terminal; there are no valid transitions (RFC-0001:C-OLD) exit: 1 diff --git a/tests/snapshots/test_lifecycle__finalize_already_normative_fails.snap b/tests/snapshots/test_lifecycle__finalize_already_normative_fails.snap index f8c892d9..79d169ce 100644 --- a/tests/snapshots/test_lifecycle__finalize_already_normative_fails.snap +++ b/tests/snapshots/test_lifecycle__finalize_already_normative_fails.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)" +source: tests/lifecycle_tests/rfc_cases/finalize.rs +expression: snapshot --- $ govctl rfc new Test RFC Created RFC: gov/rfc/RFC-0001/rfc.toml @@ -12,5 +12,5 @@ Finalized RFC-0001 to status: normative exit: 0 $ govctl rfc finalize RFC-0001 normative -error[E0104]: Invalid status transition: normative -> normative (RFC-0001) +error[E0104]: Invalid status transition: normative -> normative. Valid transition from normative: deprecated (RFC-0001) exit: 1 diff --git a/tests/snapshots/test_lifecycle__finalize_draft_to_deprecated.snap b/tests/snapshots/test_lifecycle__finalize_draft_to_deprecated.snap index 860d42bc..176693ee 100644 --- a/tests/snapshots/test_lifecycle__finalize_draft_to_deprecated.snap +++ b/tests/snapshots/test_lifecycle__finalize_draft_to_deprecated.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)" +source: tests/lifecycle_tests/rfc_cases/finalize.rs +expression: snapshot --- $ govctl rfc new Obsolete RFC Created RFC: gov/rfc/RFC-0001/rfc.toml @@ -8,8 +8,11 @@ Created RFC: gov/rfc/RFC-0001/rfc.toml exit: 0 $ govctl rfc finalize RFC-0001 deprecated -error[E0104]: Invalid status transition: draft -> deprecated (RFC-0001) -exit: 1 +error: invalid value 'deprecated' for '<STATUS>' + [possible values: normative] + +For more information, try '--help'. +exit: 2 $ govctl rfc list ┌──────────┬─────────┬────────┬───────┬──────────────┐ diff --git a/tests/snapshots/test_lifecycle__finalize_normative_to_deprecated.snap b/tests/snapshots/test_lifecycle__finalize_normative_to_deprecated.snap index 6f818bfc..e0a701e0 100644 --- a/tests/snapshots/test_lifecycle__finalize_normative_to_deprecated.snap +++ b/tests/snapshots/test_lifecycle__finalize_normative_to_deprecated.snap @@ -1,6 +1,6 @@ --- -source: tests/test_lifecycle.rs -expression: "normalize_output(&output, temp_dir.path(), &date)" +source: tests/lifecycle_tests/rfc_cases/finalize.rs +expression: snapshot --- $ govctl rfc new Old RFC Created RFC: gov/rfc/RFC-0001/rfc.toml @@ -11,15 +11,14 @@ $ govctl rfc finalize RFC-0001 normative Finalized RFC-0001 to status: normative exit: 0 -$ govctl rfc deprecate RFC-0001 -Deprecate RFC-0001? [y/N] -Deprecation cancelled +$ govctl rfc deprecate RFC-0001 --force +Finalized RFC-0001 to status: deprecated exit: 0 $ govctl rfc list -┌──────────┬─────────┬───────────┬───────┬─────────┐ -│ RFC ┆ Version ┆ Status ┆ Phase ┆ Title │ -╞══════════╪═════════╪═══════════╪═══════╪═════════╡ -│ RFC-0001 ┆ 0.1.0 ┆ normative ┆ spec ┆ Old RFC │ -└──────────┴─────────┴───────────┴───────┴─────────┘ +┌──────────┬─────────┬────────────┬───────┬─────────┐ +│ RFC ┆ Version ┆ Status ┆ Phase ┆ Title │ +╞══════════╪═════════╪════════════╪═══════╪═════════╡ +│ RFC-0001 ┆ 0.1.0 ┆ deprecated ┆ spec ┆ Old RFC │ +└──────────┴─────────┴────────────┴───────┴─────────┘ exit: 0 diff --git a/tests/snapshots/test_scan__scan_valid_adr_reference.snap b/tests/snapshots/test_scan__scan_valid_adr_reference.snap index 70887726..f7fa419c 100644 --- a/tests/snapshots/test_scan__scan_valid_adr_reference.snap +++ b/tests/snapshots/test_scan__scan_valid_adr_reference.snap @@ -13,5 +13,5 @@ Checked: 1 references found warning[W0103]: ADR has no artifact references (hint: `govctl adr add ADR-0001 refs RFC-XXXX`) (gov/adr/ADR-XXXX-test-decision.toml) -warning[W0103]: ADR has placeholder context (hint: `govctl adr set ADR-0001 context "..."`) (gov/adr/ADR-XXXX-test-decision.toml) +warning[W0113]: ADR has placeholder context (hint: `govctl adr set ADR-0001 context "..."`) (gov/adr/ADR-XXXX-test-decision.toml) exit: 0 diff --git a/tests/snapshots/test_tags__check_accepts_registered_tag.snap b/tests/snapshots/test_tags__check_accepts_registered_tag.snap index b5bb3475..e909742c 100644 --- a/tests/snapshots/test_tags__check_accepts_registered_tag.snap +++ b/tests/snapshots/test_tags__check_accepts_registered_tag.snap @@ -19,5 +19,5 @@ Checked: 0 verification guards warning[W0103]: ADR has no artifact references (hint: `govctl adr add ADR-0001 refs RFC-XXXX`) (gov/adr/ADR-XXXX-test-decision.toml) -warning[W0103]: ADR has placeholder context (hint: `govctl adr set ADR-0001 context "..."`) (gov/adr/ADR-XXXX-test-decision.toml) +warning[W0113]: ADR has placeholder context (hint: `govctl adr set ADR-0001 context "..."`) (gov/adr/ADR-XXXX-test-decision.toml) exit: 0 diff --git a/tests/snapshots/test_tags__check_rejects_unknown_tag.snap b/tests/snapshots/test_tags__check_rejects_unknown_tag.snap index 82b0a9b5..6b048343 100644 --- a/tests/snapshots/test_tags__check_rejects_unknown_tag.snap +++ b/tests/snapshots/test_tags__check_rejects_unknown_tag.snap @@ -11,6 +11,6 @@ Checked: 0 verification guards warning[W0103]: ADR has no artifact references (hint: `govctl adr add ADR-0001 refs RFC-XXXX`) (gov/adr/ADR-XXXX-test-decision.toml) -warning[W0103]: ADR has placeholder context (hint: `govctl adr set ADR-0001 context "..."`) (gov/adr/ADR-XXXX-test-decision.toml) +warning[W0113]: ADR has placeholder context (hint: `govctl adr set ADR-0001 context "..."`) (gov/adr/ADR-XXXX-test-decision.toml) error[E1105]: Artifact 'ADR-0001' uses unknown tag 'unknown-tag' (not in config.toml [tags] allowed) (gov/adr/ADR-XXXX-test-decision.toml) exit: 1 diff --git a/tests/test_lock.rs b/tests/test_lock.rs index 97e15023..370784df 100644 --- a/tests/test_lock.rs +++ b/tests/test_lock.rs @@ -5,8 +5,9 @@ mod common; use common::{init_project, run_commands, today}; +use fs2::FileExt; use serde_json::Value; -use std::fs; +use std::fs::{self, OpenOptions}; use std::process::Command; use std::sync::{Arc, Barrier}; use std::thread; diff --git a/tests/test_migrate.rs b/tests/test_migrate.rs index 9da8be23..e92c037f 100644 --- a/tests/test_migrate.rs +++ b/tests/test_migrate.rs @@ -276,10 +276,7 @@ fn test_migrate_bumps_version_even_without_file_changes() -> TestResult { let temp_dir = init_project_v1()?; let expected_version = latest_schema_version()?; - // Create artifacts using govctl (already in new format with headers) - run_commands(temp_dir.path(), &[&["rfc", "new", "New Format RFC"]])?; - - // Config says version = 1, but files are already in v2 format + // An empty project has no artifact rewrites, but still advances its schema version. let config = fs::read_to_string(temp_dir.path().join("gov/config.toml"))?; assert!(config.contains("version = 1")); @@ -300,6 +297,100 @@ fn test_migrate_bumps_version_even_without_file_changes() -> TestResult { Ok(()) } +#[test] +fn test_migrate_rebaselines_legacy_rfc_signatures_without_bump() -> TestResult { + let temp_dir = init_project()?; + run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Legacy signature RFC"], + &[ + "clause", + "new", + "RFC-0001:C-TEST", + "Test Clause", + "-s", + "Specification", + "-k", + "normative", + ], + &[ + "clause", + "edit", + "RFC-0001:C-TEST", + "--text", + "Stable normative behavior.", + ], + &["rfc", "finalize", "RFC-0001", "normative"], + &["rfc", "advance", "RFC-0001", "impl"], + &["rfc", "advance", "RFC-0001", "test"], + ], + )?; + + let rfc_path = temp_dir.path().join("gov/rfc/RFC-0001/rfc.toml"); + let mut rfc: toml::Value = toml::from_str(&fs::read_to_string(&rfc_path)?)?; + let original_version = rfc["govctl"]["version"].clone(); + let original_phase = rfc["govctl"]["phase"].clone(); + let original_changelog = rfc.get("changelog").cloned(); + rfc.get_mut("govctl") + .and_then(toml::Value::as_table_mut) + .ok_or("RFC govctl section is not a table")? + .insert("signature".to_string(), toml::Value::String("0".repeat(64))); + fs::write(&rfc_path, toml::to_string_pretty(&rfc)?)?; + + let config_path = temp_dir.path().join("gov/config.toml"); + let mut config: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?; + config["schema"]["version"] = toml::Value::Integer(2); + fs::write(&config_path, toml::to_string_pretty(&config)?)?; + + let blocked = run_commands( + temp_dir.path(), + &[ + &["rfc", "advance", "RFC-0001", "stable"], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Must migrate first", + ], + ], + )?; + assert_eq!(blocked.matches("error[E0505]").count(), 2, "{blocked}"); + assert!(blocked.contains("Run `govctl migrate`"), "{blocked}"); + + let migrated = run_commands(temp_dir.path(), &[&["migrate"]])?; + assert!( + migrated.contains("v2 -> v3: RFC amendment content signatures"), + "{migrated}" + ); + + let migrated_rfc: toml::Value = toml::from_str(&fs::read_to_string(&rfc_path)?)?; + assert_eq!(migrated_rfc["govctl"]["version"], original_version); + assert_eq!(migrated_rfc["govctl"]["phase"], original_phase); + assert_eq!(migrated_rfc.get("changelog").cloned(), original_changelog); + assert_ne!( + migrated_rfc["govctl"]["signature"].as_str(), + Some("0000000000000000000000000000000000000000000000000000000000000000") + ); + assert_eq!(current_schema_version(temp_dir.path())?, 3); + + let advanced = run_commands( + temp_dir.path(), + &[ + &["rfc", "advance", "RFC-0001", "stable"], + &["rfc", "get", "RFC-0001", "phase"], + ], + )?; + assert!( + advanced.contains("$ govctl rfc get RFC-0001 phase\nstable"), + "{advanced}" + ); + + Ok(()) +} + #[test] fn test_check_rejects_legacy_json_storage() -> TestResult { let temp_dir = init_project_v1()?; diff --git a/tests/test_move.rs b/tests/test_move.rs index bda6aaec..6ddceac6 100644 --- a/tests/test_move.rs +++ b/tests/test_move.rs @@ -258,3 +258,189 @@ fn test_move_sets_completed_date() -> common::TestResult { )?; assert_move_snapshot!(temp_dir, &date, &output) } + +#[test] +fn test_move_reopens_unreleased_done_item_and_updates_timestamps() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + let work_id = first_work_id(&date); + + setup_active_work_item_with_criteria(temp_dir.path(), &date, "Task", "add: Done")?; + let output = run_commands( + temp_dir.path(), + &[ + &[ + "work", + "tick", + &work_id, + "acceptance_criteria", + "Done", + "-s", + "done", + ], + &["work", "move", &work_id, "done"], + &["work", "move", &work_id, "active"], + &["work", "get", &work_id, "status"], + &["work", "get", &work_id, "started"], + ], + )?; + + assert!( + output.contains(&format!("$ govctl work get {work_id} status\nactive")), + "output: {output}" + ); + assert!( + output.contains(&format!("$ govctl work get {work_id} started\n{date}")), + "output: {output}" + ); + + let work_path = fs::read_dir(temp_dir.path().join("gov/work"))? + .next() + .ok_or("missing work item")?? + .path(); + let work: toml::Value = toml::from_str(&fs::read_to_string(work_path)?)?; + assert!(work["govctl"].get("completed").is_none()); + Ok(()) +} + +#[test] +fn test_move_rejects_reopening_released_done_item() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + let work_id = first_work_id(&date); + + setup_active_work_item_with_criteria(temp_dir.path(), &date, "Task", "add: Done")?; + let output = run_commands( + temp_dir.path(), + &[ + &[ + "work", + "tick", + &work_id, + "acceptance_criteria", + "Done", + "-s", + "done", + ], + &["work", "move", &work_id, "done"], + &["release", "0.1.0"], + &["work", "move", &work_id, "active"], + &["work", "get", &work_id, "status"], + ], + )?; + + assert!(output.contains("error[E0403]"), "output: {output}"); + assert!( + output.contains("Cannot reopen a Work Item referenced by a release"), + "output: {output}" + ); + assert!( + output.contains(&format!("$ govctl work get {work_id} status\ndone")), + "output: {output}" + ); + Ok(()) +} + +#[test] +fn test_move_reopen_release_load_failure_preserves_work_item() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + let work_id = first_work_id(&date); + + setup_active_work_item_with_criteria(temp_dir.path(), &date, "Task", "add: Done")?; + run_commands( + temp_dir.path(), + &[ + &[ + "work", + "tick", + &work_id, + "acceptance_criteria", + "Done", + "-s", + "done", + ], + &["work", "move", &work_id, "done"], + ], + )?; + + let work_path = fs::read_dir(temp_dir.path().join("gov/work"))? + .next() + .ok_or("missing work item")?? + .path(); + let original = fs::read_to_string(&work_path)?; + fs::write( + temp_dir.path().join("gov/releases.toml"), + "not valid TOML [", + )?; + + let output = run_commands(temp_dir.path(), &[&["work", "move", &work_id, "active"]])?; + + assert!(output.contains("error[E0704]"), "output: {output}"); + assert_eq!(fs::read_to_string(work_path)?, original); + Ok(()) +} + +#[test] +fn test_move_reopen_does_not_mutate_existing_loop_files() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + let work_id = first_work_id(&date); + + setup_active_work_item_with_criteria(temp_dir.path(), &date, "Task", "add: Done")?; + run_commands( + temp_dir.path(), + &[ + &[ + "work", + "tick", + &work_id, + "acceptance_criteria", + "Done", + "-s", + "done", + ], + &["work", "move", &work_id, "done"], + ], + )?; + + let loop_dir = temp_dir.path().join(".govctl/loops/LOOP-2026-01-01-001"); + fs::create_dir_all(loop_dir.join("rounds"))?; + let state_path = loop_dir.join("state.toml"); + let round_path = loop_dir.join("rounds/round-001.toml"); + let state = format!("work = [\"{work_id}\"]\nstatus = \"done\"\n"); + let round = format!("work_item = \"{work_id}\"\nstatus = \"done\"\n"); + fs::write(&state_path, &state)?; + fs::write(&round_path, &round)?; + + run_commands(temp_dir.path(), &[&["work", "move", &work_id, "active"]])?; + + assert_eq!(fs::read_to_string(state_path)?, state); + assert_eq!(fs::read_to_string(round_path)?, round); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn test_move_write_failure_preserves_work_item_bytes() -> common::TestResult { + use std::os::unix::fs::PermissionsExt; + + let (temp_dir, date) = init_project_with_date()?; + let work_id = first_work_id(&date); + run_commands(temp_dir.path(), &[&["work", "new", "Atomic move"]])?; + + let work_dir = temp_dir.path().join("gov/work"); + let work_path = fs::read_dir(&work_dir)? + .next() + .ok_or("missing work item")?? + .path(); + let original = fs::read(&work_path)?; + let original_permissions = fs::metadata(&work_dir)?.permissions(); + let mut unwritable = original_permissions.clone(); + unwritable.set_mode(original_permissions.mode() & !0o222); + fs::set_permissions(&work_dir, unwritable)?; + + let output = run_commands(temp_dir.path(), &[&["work", "move", &work_id, "active"]]); + fs::set_permissions(&work_dir, original_permissions)?; + let output = output?; + + assert!(output.contains("error[E0901]"), "output: {output}"); + assert_eq!(fs::read(work_path)?, original); + Ok(()) +}