diff --git a/CHANGELOG.md b/CHANGELOG.md index 875bd4ed..f0befd26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ Release entries are curated summaries for readers. Work item traceability remain ## [Unreleased] +### Changed + +- loop run does not fail loops or work items from round_count alone (WI-2026-06-15-001) + +### Removed + +- [RFC-0006](docs/rfc/RFC-0006.md) removes max-rounds as a loop run argument and failure condition (WI-2026-06-15-001) +- loop run no longer accepts --max-rounds or stores max_rounds in new round artifacts (WI-2026-06-15-001) + +### Fixed + +- rfc bump rejects a version bump when no RFC or clause content changed (WI-2026-06-15-002) +- changelog-only updates do not make a later version bump valid (WI-2026-06-15-002) +- RFC or clause content amendments still allow the next version bump (WI-2026-06-15-002) +- W0112 bare reference warnings include the scanned artifact field and line (WI-2026-06-15-003) +- W0112 diagnostics include a short matched-text context for quick root-cause lookup (WI-2026-06-15-003) + ## [0.9.5] - 2026-06-12 0.9.5 is a reviewer-evidence and compatibility patch. It gives reviewer agents diff --git a/docs/rfc/RFC-0002.md b/docs/rfc/RFC-0002.md index 8f691079..5305d81b 100644 --- a/docs/rfc/RFC-0002.md +++ b/docs/rfc/RFC-0002.md @@ -1,9 +1,9 @@ - + # RFC-0002: CLI Resource Model and Command Architecture -> **Version:** 0.10.2 | **Status:** normative | **Phase:** test +> **Version:** 0.10.3 | **Status:** normative | **Phase:** test --- @@ -281,6 +281,10 @@ Resource-specific lifecycle verbs implement state transitions defined in [RFC-00 - Version bumping per semantic versioning - Updates changelog automatically - Optional: `--change ` 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 + - 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 4. `govctl rfc supersede --by ` - Marks RFC as superseded by another RFC @@ -732,6 +736,14 @@ Search is discovery across the governance corpus, not a resource-specific CRUD o ## Changelog +### v0.10.3 (2026-06-15) + +Reject empty RFC version bumps + +#### Fixed + +- reject RFC version bumps without RFC or clause content changes + ### v0.10.2 (2026-06-08) Clarify init-skills skill bundle installation diff --git a/docs/rfc/RFC-0006.md b/docs/rfc/RFC-0006.md index 2fad64e8..7532ec41 100644 --- a/docs/rfc/RFC-0006.md +++ b/docs/rfc/RFC-0006.md @@ -1,9 +1,9 @@ - + # RFC-0006: Loop Execution Model -> **Version:** 0.4.0 | **Status:** normative | **Phase:** impl +> **Version:** 0.5.0 | **Status:** normative | **Phase:** impl --- @@ -71,7 +71,7 @@ A loop MUST have exactly one of the following lifecycle states: 2. **active** — The loop is currently executing rounds on work items. 3. **paused** — Execution stopped before a terminal outcome and may be resumed. 4. **completed** — All work items in the resolved loop set reached non-failed terminal loop outcomes (`done` or `cancelled`) without any `failed` or `blocked` loop outcomes. -5. **failed** — A failure condition was met (max rounds exceeded, critical error, failed work item, or blocked dependency chain). +5. **failed** — An unrecoverable failure condition was met, such as a critical local-state error, an explicit failed work item, or a blocked dependency chain. Valid transitions: - pending → active (loop execution begins) @@ -88,7 +88,9 @@ Invalid transitions (MUST be rejected): - pending → failed (cannot fail without being active) - paused → completed (completion requires active execution) -Rationale: The loop lifecycle provides clear semantics for tracking execution progress and enables resumption after interruption. A distinct `paused` state avoids overloading `pending`, which means execution has not started. Terminal states (completed, failed) indicate the loop has finished and cannot be restarted. +Round count is audit metadata. Implementations MUST NOT infer loop failure, Work Item failure, or dependency blockage from round count alone. + +Rationale: The loop lifecycle provides clear semantics for tracking execution progress and enables resumption after interruption. A distinct `paused` state avoids overloading `pending`, which means execution has not started. Terminal states (completed, failed) indicate the loop has finished and cannot be restarted. Keeping round count out of failure semantics preserves the separation between govctl's local round protocol and caller-level retry policy. > **Tags:** `lifecycle`, `core` @@ -140,7 +142,7 @@ Each round MUST have one of these local round states: 2. **submitted** — the round summary evidence has been provided but has not yet been incorporated into authoritative loop state. 3. **closed** — govctl has validated the round evidence and updated loop state. -`govctl loop run LOOP-ID` MUST advance the local round protocol. It MUST NOT implement repository changes itself. It MUST NOT mark a Work Item `done` directly merely because loop-local criteria appear satisfied. Work Item lifecycle transitions remain owned by `govctl work move` per [RFC-0002:C-LIFECYCLE-VERBS](../rfc/RFC-0002.md#rfc-0002c-lifecycle-verbs). +`govctl loop run LOOP-ID` MUST advance exactly one local round-protocol step. It MUST NOT implement repository changes itself. It MUST NOT mark a Work Item `done` directly merely because loop-local criteria appear satisfied. Work Item lifecycle transitions remain owned by `govctl work move` per [RFC-0002:C-LIFECYCLE-VERBS](../rfc/RFC-0002.md#rfc-0002c-lifecycle-verbs). **Opening a round:** @@ -153,7 +155,7 @@ When no open round exists, `loop run` MUST: 5. Write a summary skeleton that records where the agent MUST provide actions, changed paths, verification evidence, blockers, and note candidates. 6. Display the round artifact path and the next required agent action. -Opening a round MAY update loop-local item statuses and round counters, but MUST NOT write transient execution trace into Work Item files. +Opening a round MAY update loop-local item statuses and round counters, but MUST NOT write transient execution trace into Work Item files. Round counters are audit metadata only and MUST NOT control selection, failure, or dependency propagation. **Closing a round:** @@ -171,7 +173,9 @@ If blockers prevent progress, `loop run` MUST keep the loop non-terminal and rec If the relevant Work Items have already reached terminal Work Item lifecycle states through `govctl work move`, `loop run` MUST reflect those lifecycle states in loop-local item status and MAY mark the loop completed when every current resolved item is terminal without failed or blocked loop outcomes. -Rationale: govctl can coordinate local execution state, Work Item metadata, dependency readiness, and verification evidence, but the agent performs implementation work. This keeps loop execution trace in local loop artifacts, preserves Work Items as durable outcome artifacts, and reuses existing `notes`, `depends_on`, `verify`, and `work move` mechanisms instead of inventing parallel systems. +`loop run` MUST NOT infer loop failure, Work Item failure, or dependency blockage from the number of rounds opened or closed for a Work Item. + +Rationale: govctl can coordinate local execution state, Work Item metadata, dependency readiness, and verification evidence, but the agent performs implementation work. This keeps loop execution trace in local loop artifacts, preserves Work Items as durable outcome artifacts, and reuses existing `notes`, `depends_on`, `verify`, and `work move` mechanisms instead of inventing parallel systems. Retry budgets and repeated-invocation limits belong to callers such as skills, scripts, or humans because they decide how much autonomous execution is appropriate. > **Tags:** `core`, `validation` @@ -229,10 +233,10 @@ A loop MAY be paused and resumed across CLI invocations or agent sessions. If a loop implementation supports resumption, it MUST: -1. Persist loop state using the storage contract defined by [RFC-0006:C-LOOP-STATE-STORAGE](../rfc/RFC-0006.md#rfc-0006c-loop-state-storage) -2. Resume existing loop operations by positional `LOOP-ID` as defined by [RFC-0006:C-LOOP-COMMAND-SURFACE](../rfc/RFC-0006.md#rfc-0006c-loop-command-surface) -3. Resume from the last recorded state and open round artifact rather than starting fresh when the requested loop state exists and is non-terminal -4. Reject operations that require a non-terminal loop when the loop is terminal +1. Persist loop state using the storage contract defined by [RFC-0006:C-LOOP-STATE-STORAGE](../rfc/RFC-0006.md#rfc-0006c-loop-state-storage). +2. Resume existing loop operations by positional `LOOP-ID` as defined by [RFC-0006:C-LOOP-COMMAND-SURFACE](../rfc/RFC-0006.md#rfc-0006c-loop-command-surface). +3. Resume from the last recorded state and open round artifact rather than starting fresh when the requested loop state exists and is non-terminal. +4. Reject operations that require a non-terminal loop when the loop is terminal. **Discovery semantics:** @@ -246,18 +250,19 @@ A stored loop matches a requested work set when its current editable `work` fiel When resuming a loop: -- Work items in `done` loop state MUST NOT be selected for new round work -- Work items in `active` loop state MAY be selected again only through the local round protocol -- Work items in `blocked`, `failed`, or `cancelled` loop state MUST remain terminal for dependency planning unless an explicit scope mutation recomputes a dependency-derived `blocked` outcome -- Work items in `pending` loop state MAY be selected in dependency order when opening a round unless an explicit run selector narrows execution per [RFC-0006:C-LOOP-COMMAND-SURFACE](../rfc/RFC-0006.md#rfc-0006c-loop-command-surface) -- If `loop.current_round` points at an open round artifact, `loop run` MUST validate or reject that artifact before opening another round +- Work items in `done` loop state MUST NOT be selected for new round work. +- Work items in `active` loop state MAY be selected again only through the local round protocol. +- Work items in `blocked`, `failed`, or `cancelled` loop state MUST remain terminal for dependency planning unless an explicit scope mutation recomputes a dependency-derived `blocked` outcome. +- Work items in `pending` loop state MAY be selected in dependency order when opening a round unless an explicit run selector narrows execution per [RFC-0006:C-LOOP-COMMAND-SURFACE](../rfc/RFC-0006.md#rfc-0006c-loop-command-surface). +- If `loop.current_round` points at an open round artifact, `loop run` MUST validate or reject that artifact before opening another round. +- Prior round counts MUST NOT prevent a non-terminal work item from being selected again when it is otherwise ready. **State preservation:** The loop state MUST preserve: - Execution status of each current work item (pending, active, done, failed, blocked, cancelled) -- Round count for each current work item +- Round count for each current work item as audit metadata - Last selected round for each current work item when known - Current editable `work` field values - Dependency graph at last planning time @@ -270,7 +275,7 @@ The loop state MAY preserve: - Guard execution result summaries - Agent context and decision history inside local round artifacts -Rationale: Resumption enables long-running multi-WI loops to survive agent session boundaries without losing progress. Positional loop IDs provide precise lookup that matches the CLI's noun/verb/object shape. Work-set matching remains a discovery convenience for start/list workflows, not an execution command mode. By persisting state independently of work item files, we maintain the separation between execution state and governed artifacts. Scope mutation makes the stored work set current rather than historical, so discovery continues to match the loop the user intends to resume. +Rationale: Resumption enables long-running multi-WI loops to survive agent session boundaries without losing progress. Positional loop IDs provide precise lookup that matches the CLI's noun/verb/object shape. Work-set matching remains a discovery convenience for start/list workflows, not an execution command mode. By persisting state independently of work item files, govctl maintains the separation between execution state and governed artifacts. Scope mutation makes the stored work set current rather than historical, so discovery continues to match the loop the user intends to resume. Round counts remain useful for audit and display without becoming hidden retry policy. > **Tags:** `lifecycle` @@ -308,17 +313,17 @@ The optional `loop.current_round` value records the latest loop-level round numb The `[dependencies]` table MUST contain one entry for each work item in `loop.resolved`. Each dependency entry MUST be an array of Work Item IDs. Each dependency ID MUST also appear in `loop.resolved`. Dependency arrays MUST NOT contain duplicate Work Item IDs. -The `[items.]` table MUST contain one entry for each work item in `loop.resolved`. Each item `status` MUST be one of the loop-level work item statuses defined by [RFC-0006:C-WORK-ITEM-INTERACTION](../rfc/RFC-0006.md#rfc-0006c-work-item-interaction). Each `round_count` MUST be a non-negative integer. The optional `last_round` value records the last loop-level round that selected or updated the item. +The `[items.]` table MUST contain one entry for each work item in `loop.resolved`. Each item `status` MUST be one of the loop-level work item statuses defined by [RFC-0006:C-WORK-ITEM-INTERACTION](../rfc/RFC-0006.md#rfc-0006c-work-item-interaction). Each `round_count` MUST be a non-negative integer. The optional `last_round` value records the last loop-level round that selected or updated the item. `round_count` and `last_round` are audit metadata and MUST NOT encode retry budgets or failure policy. Loop state storage MUST be keyed by loop ID, not by work item ID. A multi-work-item loop MUST have one shared loop state root so dependency planning, failure propagation, and resumption use the same authoritative state. -Loop round artifacts MUST be stored under `.govctl/loops//rounds/round-NNN.toml`, where `NNN` is the three-digit loop-level round number. A round artifact MUST identify the loop ID, round number, selected work item IDs, round state, summary evidence, blockers, and note candidates. Round artifacts are local execution trace and MUST NOT be written to Work Item fields. +Loop round artifacts MUST be stored under `.govctl/loops//rounds/round-NNN.toml`, where `NNN` is the three-digit loop-level round number. A round artifact MUST identify the loop ID, round number, selected work item IDs, round state, summary evidence, blockers, and note candidates. Round artifacts MUST NOT encode maximum round limits or caller retry budgets. Round artifacts are local execution trace and MUST NOT be written to Work Item fields. Round artifacts MAY mention Work Item IDs when evidence applies to specific work, but the storage root is loop-level. Implementations MUST NOT require per-work-item round directories for the canonical state model. Loop state is local execution state, not a governed artifact. Deleting `.govctl/loops/` MUST NOT invalidate RFCs, ADRs, Work Items, Guards, or rendered governance projections, but MAY remove resumability and local execution trace. -Rationale: A loop can drive multiple work items, so a per-work-item state root cannot represent the loop lifecycle, dependency graph, aggregate outcome, or round evidence. A single loop directory keeps execution state separate from Work Item files while preserving enough protocol state for resumption. Canonical generated loop IDs follow the existing artifact style of a type prefix, date, and sequence while avoiding collision-prone plain-text IDs. +Rationale: A loop can drive multiple work items, so a per-work-item state root cannot represent the loop lifecycle, dependency graph, aggregate outcome, or round evidence. A single loop directory keeps execution state separate from Work Item files while preserving enough protocol state for resumption. Canonical generated loop IDs follow the existing artifact style of a type prefix, date, and sequence while avoiding collision-prone plain-text IDs. Keeping retry budgets out of persisted loop state prevents caller policy from becoming hidden local-state semantics. > **Tags:** `core` @@ -382,11 +387,13 @@ The canonical loop subcommands are: 2. `loop show LOOP-ID`: read one persisted loop state by loop ID. This command MUST be read-only. 3. `loop start [--id LOOP-ID] WI-ID...`: create or reuse a loop for an explicit `work` field set. 4. `loop resume LOOP-ID`: select and display an existing non-terminal loop by loop ID. This command MUST be read-only and MUST NOT advance rounds. -5. `loop run LOOP-ID [--work WI-ID ...] [--max-rounds N]`: advance the local round protocol for an existing loop state. +5. `loop run LOOP-ID [--work WI-ID ...]`: advance the local round protocol for an existing loop state. 6. `loop replan LOOP-ID`: recompute the dependency closure for the current explicit `work` field set. 7. `loop add LOOP-ID work WI-ID`: add a Work Item ID to the loop's editable `work` field and replan. 8. `loop remove LOOP-ID work WI-ID`: remove a Work Item ID from the loop's editable `work` field and replan. +`loop run` MUST NOT accept a maximum-rounds argument or any other caller retry-budget argument. Repeated execution limits belong to callers and MUST NOT be persisted in loop state or round artifacts. + The `work` field is the only canonical user-facing field name for the loop's explicit work set. Implementations MUST accept `wi` as a shorthand alias for `work`. User-facing help SHOULD prefer `loop add LOOP-ID work WI-ID` and `loop remove LOOP-ID work WI-ID` while documenting the alias. **Discovery semantics:** @@ -411,7 +418,7 @@ Targeted run selection MUST NOT replace, shrink, expand, or otherwise mutate the **Rationale:** -The loop command namespace coordinates several governed resources but stores its own local execution state, so it is neither a governed artifact resource nor a simple single global command. Existing-loop operations use positional `LOOP-ID` arguments to match the rest of the CLI's noun/verb/object shape. Stable argument roles prevent hidden mode switches: positional work item IDs in `add` and `remove` are field values for the loop's `work` field, while `--work` is the explicit work-item execution selector. Keeping the field position visible preserves the CLI edit model while making loop-specific replanning a domain side effect of changing the field. Reusing `run` as the round-protocol advancement command preserves existing skill guidance while removing the misleading interpretation that govctl itself implements code. +The loop command namespace coordinates several governed resources but stores its own local execution state, so it is neither a governed artifact resource nor a simple single global command. Existing-loop operations use positional `LOOP-ID` arguments to match the rest of the CLI's noun/verb/object shape. Stable argument roles prevent hidden mode switches: positional work item IDs in `add` and `remove` are field values for the loop's `work` field, while `--work` is the explicit work-item execution selector. Keeping the field position visible preserves the CLI edit model while making loop-specific replanning a domain side effect of changing the field. Reusing `run` as the round-protocol advancement command preserves existing skill guidance while removing the misleading interpretation that govctl itself implements code. Excluding retry-budget flags keeps the command surface small and leaves autonomous execution policy to callers. *Since: v0.4.0* @@ -471,6 +478,19 @@ Conflating these would force the loop to guess whether a ref is a hard dependenc ## Changelog +### v0.5.0 (2026-06-15) + +Remove loop max-rounds budget from the round protocol + +#### Changed + +- round_count is audit metadata and cannot drive failure semantics + +#### Removed + +- loop run --max-rounds retry-budget argument +- max_rounds from new loop round artifacts + ### v0.4.0 (2026-06-03) Define loop command surface diff --git a/gov/rfc/RFC-0002/clauses/C-LIFECYCLE-VERBS.toml b/gov/rfc/RFC-0002/clauses/C-LIFECYCLE-VERBS.toml index 16aa18dd..b79a0dd3 100644 --- a/gov/rfc/RFC-0002/clauses/C-LIFECYCLE-VERBS.toml +++ b/gov/rfc/RFC-0002/clauses/C-LIFECYCLE-VERBS.toml @@ -6,7 +6,10 @@ title = "Resource-Specific Lifecycle Verbs" kind = "normative" status = "active" since = "0.1.0" -tags = ["cli", "lifecycle"] +tags = [ + "cli", + "lifecycle", +] [content] text = """ @@ -26,6 +29,10 @@ Resource-specific lifecycle verbs implement state transitions defined in [[RFC-0 - Version bumping per semantic versioning - Updates changelog automatically - Optional: `--change ` 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 + - 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 4. `govctl rfc supersede --by ` - Marks RFC as superseded by another RFC diff --git a/gov/rfc/RFC-0002/rfc.toml b/gov/rfc/RFC-0002/rfc.toml index 863c84c5..53847f8e 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.2" +version = "0.10.3" status = "normative" phase = "test" owners = ["@govctl-org"] created = "2026-01-19" -updated = "2026-06-08" +updated = "2026-06-15" tags = [ "cli", "editing", @@ -16,7 +16,7 @@ tags = [ "validation", "release", ] -signature = "dd126a9195850f89dbb0ba90abee032ce072eb83017a465fd798276b9a7dbd3f" +signature = "10d3e6d4fb3468159ccb4d1bb48e79bac70dee20a76c213fd8e5f1647cd502f6" [[sections]] title = "Summary" @@ -36,6 +36,12 @@ clauses = [ "clauses/C-SEARCH-COMMAND.toml", ] +[[changelog]] +version = "0.10.3" +date = "2026-06-15" +notes = "Reject empty RFC version bumps" +fixed = ["reject RFC version bumps without RFC or clause content changes"] + [[changelog]] version = "0.10.2" date = "2026-06-08" diff --git a/gov/rfc/RFC-0006/clauses/C-LOOP-COMMAND-SURFACE.toml b/gov/rfc/RFC-0006/clauses/C-LOOP-COMMAND-SURFACE.toml index 3122499f..0532d697 100644 --- a/gov/rfc/RFC-0006/clauses/C-LOOP-COMMAND-SURFACE.toml +++ b/gov/rfc/RFC-0006/clauses/C-LOOP-COMMAND-SURFACE.toml @@ -30,11 +30,13 @@ The canonical loop subcommands are: 2. `loop show LOOP-ID`: read one persisted loop state by loop ID. This command MUST be read-only. 3. `loop start [--id LOOP-ID] WI-ID...`: create or reuse a loop for an explicit `work` field set. 4. `loop resume LOOP-ID`: select and display an existing non-terminal loop by loop ID. This command MUST be read-only and MUST NOT advance rounds. -5. `loop run LOOP-ID [--work WI-ID ...] [--max-rounds N]`: advance the local round protocol for an existing loop state. +5. `loop run LOOP-ID [--work WI-ID ...]`: advance the local round protocol for an existing loop state. 6. `loop replan LOOP-ID`: recompute the dependency closure for the current explicit `work` field set. 7. `loop add LOOP-ID work WI-ID`: add a Work Item ID to the loop's editable `work` field and replan. 8. `loop remove LOOP-ID work WI-ID`: remove a Work Item ID from the loop's editable `work` field and replan. +`loop run` MUST NOT accept a maximum-rounds argument or any other caller retry-budget argument. Repeated execution limits belong to callers and MUST NOT be persisted in loop state or round artifacts. + The `work` field is the only canonical user-facing field name for the loop's explicit work set. Implementations MUST accept `wi` as a shorthand alias for `work`. User-facing help SHOULD prefer `loop add LOOP-ID work WI-ID` and `loop remove LOOP-ID work WI-ID` while documenting the alias. **Discovery semantics:** @@ -59,4 +61,4 @@ Targeted run selection MUST NOT replace, shrink, expand, or otherwise mutate the **Rationale:** -The loop command namespace coordinates several governed resources but stores its own local execution state, so it is neither a governed artifact resource nor a simple single global command. Existing-loop operations use positional `LOOP-ID` arguments to match the rest of the CLI's noun/verb/object shape. Stable argument roles prevent hidden mode switches: positional work item IDs in `add` and `remove` are field values for the loop's `work` field, while `--work` is the explicit work-item execution selector. Keeping the field position visible preserves the CLI edit model while making loop-specific replanning a domain side effect of changing the field. Reusing `run` as the round-protocol advancement command preserves existing skill guidance while removing the misleading interpretation that govctl itself implements code.""" +The loop command namespace coordinates several governed resources but stores its own local execution state, so it is neither a governed artifact resource nor a simple single global command. Existing-loop operations use positional `LOOP-ID` arguments to match the rest of the CLI's noun/verb/object shape. Stable argument roles prevent hidden mode switches: positional work item IDs in `add` and `remove` are field values for the loop's `work` field, while `--work` is the explicit work-item execution selector. Keeping the field position visible preserves the CLI edit model while making loop-specific replanning a domain side effect of changing the field. Reusing `run` as the round-protocol advancement command preserves existing skill guidance while removing the misleading interpretation that govctl itself implements code. Excluding retry-budget flags keeps the command surface small and leaves autonomous execution policy to callers.""" diff --git a/gov/rfc/RFC-0006/clauses/C-LOOP-LIFECYCLE.toml b/gov/rfc/RFC-0006/clauses/C-LOOP-LIFECYCLE.toml index 222367e3..161b38b4 100644 --- a/gov/rfc/RFC-0006/clauses/C-LOOP-LIFECYCLE.toml +++ b/gov/rfc/RFC-0006/clauses/C-LOOP-LIFECYCLE.toml @@ -19,7 +19,7 @@ A loop MUST have exactly one of the following lifecycle states: 2. **active** — The loop is currently executing rounds on work items. 3. **paused** — Execution stopped before a terminal outcome and may be resumed. 4. **completed** — All work items in the resolved loop set reached non-failed terminal loop outcomes (`done` or `cancelled`) without any `failed` or `blocked` loop outcomes. -5. **failed** — A failure condition was met (max rounds exceeded, critical error, failed work item, or blocked dependency chain). +5. **failed** — An unrecoverable failure condition was met, such as a critical local-state error, an explicit failed work item, or a blocked dependency chain. Valid transitions: - pending → active (loop execution begins) @@ -36,4 +36,6 @@ Invalid transitions (MUST be rejected): - pending → failed (cannot fail without being active) - paused → completed (completion requires active execution) -Rationale: The loop lifecycle provides clear semantics for tracking execution progress and enables resumption after interruption. A distinct `paused` state avoids overloading `pending`, which means execution has not started. Terminal states (completed, failed) indicate the loop has finished and cannot be restarted.""" +Round count is audit metadata. Implementations MUST NOT infer loop failure, Work Item failure, or dependency blockage from round count alone. + +Rationale: The loop lifecycle provides clear semantics for tracking execution progress and enables resumption after interruption. A distinct `paused` state avoids overloading `pending`, which means execution has not started. Terminal states (completed, failed) indicate the loop has finished and cannot be restarted. Keeping round count out of failure semantics preserves the separation between govctl's local round protocol and caller-level retry policy.""" diff --git a/gov/rfc/RFC-0006/clauses/C-LOOP-RESUMPTION.toml b/gov/rfc/RFC-0006/clauses/C-LOOP-RESUMPTION.toml index 8eee4d4a..45fcc3a2 100644 --- a/gov/rfc/RFC-0006/clauses/C-LOOP-RESUMPTION.toml +++ b/gov/rfc/RFC-0006/clauses/C-LOOP-RESUMPTION.toml @@ -14,10 +14,10 @@ A loop MAY be paused and resumed across CLI invocations or agent sessions. If a loop implementation supports resumption, it MUST: -1. Persist loop state using the storage contract defined by [[RFC-0006:C-LOOP-STATE-STORAGE]] -2. Resume existing loop operations by positional `LOOP-ID` as defined by [[RFC-0006:C-LOOP-COMMAND-SURFACE]] -3. Resume from the last recorded state and open round artifact rather than starting fresh when the requested loop state exists and is non-terminal -4. Reject operations that require a non-terminal loop when the loop is terminal +1. Persist loop state using the storage contract defined by [[RFC-0006:C-LOOP-STATE-STORAGE]]. +2. Resume existing loop operations by positional `LOOP-ID` as defined by [[RFC-0006:C-LOOP-COMMAND-SURFACE]]. +3. Resume from the last recorded state and open round artifact rather than starting fresh when the requested loop state exists and is non-terminal. +4. Reject operations that require a non-terminal loop when the loop is terminal. **Discovery semantics:** @@ -31,18 +31,19 @@ A stored loop matches a requested work set when its current editable `work` fiel When resuming a loop: -- Work items in `done` loop state MUST NOT be selected for new round work -- Work items in `active` loop state MAY be selected again only through the local round protocol -- Work items in `blocked`, `failed`, or `cancelled` loop state MUST remain terminal for dependency planning unless an explicit scope mutation recomputes a dependency-derived `blocked` outcome -- Work items in `pending` loop state MAY be selected in dependency order when opening a round unless an explicit run selector narrows execution per [[RFC-0006:C-LOOP-COMMAND-SURFACE]] -- If `loop.current_round` points at an open round artifact, `loop run` MUST validate or reject that artifact before opening another round +- Work items in `done` loop state MUST NOT be selected for new round work. +- Work items in `active` loop state MAY be selected again only through the local round protocol. +- Work items in `blocked`, `failed`, or `cancelled` loop state MUST remain terminal for dependency planning unless an explicit scope mutation recomputes a dependency-derived `blocked` outcome. +- Work items in `pending` loop state MAY be selected in dependency order when opening a round unless an explicit run selector narrows execution per [[RFC-0006:C-LOOP-COMMAND-SURFACE]]. +- If `loop.current_round` points at an open round artifact, `loop run` MUST validate or reject that artifact before opening another round. +- Prior round counts MUST NOT prevent a non-terminal work item from being selected again when it is otherwise ready. **State preservation:** The loop state MUST preserve: - Execution status of each current work item (pending, active, done, failed, blocked, cancelled) -- Round count for each current work item +- Round count for each current work item as audit metadata - Last selected round for each current work item when known - Current editable `work` field values - Dependency graph at last planning time @@ -55,4 +56,4 @@ The loop state MAY preserve: - Guard execution result summaries - Agent context and decision history inside local round artifacts -Rationale: Resumption enables long-running multi-WI loops to survive agent session boundaries without losing progress. Positional loop IDs provide precise lookup that matches the CLI's noun/verb/object shape. Work-set matching remains a discovery convenience for start/list workflows, not an execution command mode. By persisting state independently of work item files, we maintain the separation between execution state and governed artifacts. Scope mutation makes the stored work set current rather than historical, so discovery continues to match the loop the user intends to resume.""" +Rationale: Resumption enables long-running multi-WI loops to survive agent session boundaries without losing progress. Positional loop IDs provide precise lookup that matches the CLI's noun/verb/object shape. Work-set matching remains a discovery convenience for start/list workflows, not an execution command mode. By persisting state independently of work item files, govctl maintains the separation between execution state and governed artifacts. Scope mutation makes the stored work set current rather than historical, so discovery continues to match the loop the user intends to resume. Round counts remain useful for audit and display without becoming hidden retry policy.""" diff --git a/gov/rfc/RFC-0006/clauses/C-LOOP-STATE-STORAGE.toml b/gov/rfc/RFC-0006/clauses/C-LOOP-STATE-STORAGE.toml index ab43524a..435d049d 100644 --- a/gov/rfc/RFC-0006/clauses/C-LOOP-STATE-STORAGE.toml +++ b/gov/rfc/RFC-0006/clauses/C-LOOP-STATE-STORAGE.toml @@ -40,14 +40,14 @@ The optional `loop.current_round` value records the latest loop-level round numb The `[dependencies]` table MUST contain one entry for each work item in `loop.resolved`. Each dependency entry MUST be an array of Work Item IDs. Each dependency ID MUST also appear in `loop.resolved`. Dependency arrays MUST NOT contain duplicate Work Item IDs. -The `[items.]` table MUST contain one entry for each work item in `loop.resolved`. Each item `status` MUST be one of the loop-level work item statuses defined by [[RFC-0006:C-WORK-ITEM-INTERACTION]]. Each `round_count` MUST be a non-negative integer. The optional `last_round` value records the last loop-level round that selected or updated the item. +The `[items.]` table MUST contain one entry for each work item in `loop.resolved`. Each item `status` MUST be one of the loop-level work item statuses defined by [[RFC-0006:C-WORK-ITEM-INTERACTION]]. Each `round_count` MUST be a non-negative integer. The optional `last_round` value records the last loop-level round that selected or updated the item. `round_count` and `last_round` are audit metadata and MUST NOT encode retry budgets or failure policy. Loop state storage MUST be keyed by loop ID, not by work item ID. A multi-work-item loop MUST have one shared loop state root so dependency planning, failure propagation, and resumption use the same authoritative state. -Loop round artifacts MUST be stored under `.govctl/loops//rounds/round-NNN.toml`, where `NNN` is the three-digit loop-level round number. A round artifact MUST identify the loop ID, round number, selected work item IDs, round state, summary evidence, blockers, and note candidates. Round artifacts are local execution trace and MUST NOT be written to Work Item fields. +Loop round artifacts MUST be stored under `.govctl/loops//rounds/round-NNN.toml`, where `NNN` is the three-digit loop-level round number. A round artifact MUST identify the loop ID, round number, selected work item IDs, round state, summary evidence, blockers, and note candidates. Round artifacts MUST NOT encode maximum round limits or caller retry budgets. Round artifacts are local execution trace and MUST NOT be written to Work Item fields. Round artifacts MAY mention Work Item IDs when evidence applies to specific work, but the storage root is loop-level. Implementations MUST NOT require per-work-item round directories for the canonical state model. Loop state is local execution state, not a governed artifact. Deleting `.govctl/loops/` MUST NOT invalidate RFCs, ADRs, Work Items, Guards, or rendered governance projections, but MAY remove resumability and local execution trace. -Rationale: A loop can drive multiple work items, so a per-work-item state root cannot represent the loop lifecycle, dependency graph, aggregate outcome, or round evidence. A single loop directory keeps execution state separate from Work Item files while preserving enough protocol state for resumption. Canonical generated loop IDs follow the existing artifact style of a type prefix, date, and sequence while avoiding collision-prone plain-text IDs.''' +Rationale: A loop can drive multiple work items, so a per-work-item state root cannot represent the loop lifecycle, dependency graph, aggregate outcome, or round evidence. A single loop directory keeps execution state separate from Work Item files while preserving enough protocol state for resumption. Canonical generated loop IDs follow the existing artifact style of a type prefix, date, and sequence while avoiding collision-prone plain-text IDs. Keeping retry budgets out of persisted loop state prevents caller policy from becoming hidden local-state semantics.''' diff --git a/gov/rfc/RFC-0006/clauses/C-ROUND-EXECUTION.toml b/gov/rfc/RFC-0006/clauses/C-ROUND-EXECUTION.toml index 2c5befbe..245d6311 100644 --- a/gov/rfc/RFC-0006/clauses/C-ROUND-EXECUTION.toml +++ b/gov/rfc/RFC-0006/clauses/C-ROUND-EXECUTION.toml @@ -25,7 +25,7 @@ Each round MUST have one of these local round states: 2. **submitted** — the round summary evidence has been provided but has not yet been incorporated into authoritative loop state. 3. **closed** — govctl has validated the round evidence and updated loop state. -`govctl loop run LOOP-ID` MUST advance the local round protocol. It MUST NOT implement repository changes itself. It MUST NOT mark a Work Item `done` directly merely because loop-local criteria appear satisfied. Work Item lifecycle transitions remain owned by `govctl work move` per [[RFC-0002:C-LIFECYCLE-VERBS]]. +`govctl loop run LOOP-ID` MUST advance exactly one local round-protocol step. It MUST NOT implement repository changes itself. It MUST NOT mark a Work Item `done` directly merely because loop-local criteria appear satisfied. Work Item lifecycle transitions remain owned by `govctl work move` per [[RFC-0002:C-LIFECYCLE-VERBS]]. **Opening a round:** @@ -38,7 +38,7 @@ When no open round exists, `loop run` MUST: 5. Write a summary skeleton that records where the agent MUST provide actions, changed paths, verification evidence, blockers, and note candidates. 6. Display the round artifact path and the next required agent action. -Opening a round MAY update loop-local item statuses and round counters, but MUST NOT write transient execution trace into Work Item files. +Opening a round MAY update loop-local item statuses and round counters, but MUST NOT write transient execution trace into Work Item files. Round counters are audit metadata only and MUST NOT control selection, failure, or dependency propagation. **Closing a round:** @@ -56,4 +56,6 @@ If blockers prevent progress, `loop run` MUST keep the loop non-terminal and rec If the relevant Work Items have already reached terminal Work Item lifecycle states through `govctl work move`, `loop run` MUST reflect those lifecycle states in loop-local item status and MAY mark the loop completed when every current resolved item is terminal without failed or blocked loop outcomes. -Rationale: govctl can coordinate local execution state, Work Item metadata, dependency readiness, and verification evidence, but the agent performs implementation work. This keeps loop execution trace in local loop artifacts, preserves Work Items as durable outcome artifacts, and reuses existing `notes`, `depends_on`, `verify`, and `work move` mechanisms instead of inventing parallel systems.""" +`loop run` MUST NOT infer loop failure, Work Item failure, or dependency blockage from the number of rounds opened or closed for a Work Item. + +Rationale: govctl can coordinate local execution state, Work Item metadata, dependency readiness, and verification evidence, but the agent performs implementation work. This keeps loop execution trace in local loop artifacts, preserves Work Items as durable outcome artifacts, and reuses existing `notes`, `depends_on`, `verify`, and `work move` mechanisms instead of inventing parallel systems. Retry budgets and repeated-invocation limits belong to callers such as skills, scripts, or humans because they decide how much autonomous execution is appropriate.""" diff --git a/gov/rfc/RFC-0006/rfc.toml b/gov/rfc/RFC-0006/rfc.toml index d9c3bd12..efe552fd 100644 --- a/gov/rfc/RFC-0006/rfc.toml +++ b/gov/rfc/RFC-0006/rfc.toml @@ -3,13 +3,13 @@ [govctl] id = "RFC-0006" title = "Loop Execution Model" -version = "0.4.0" +version = "0.5.0" status = "normative" phase = "impl" owners = ["@govctl-org"] created = "2026-05-31" -updated = "2026-06-03" -signature = "8c414b17e2d0a1422a7e6b2a637a26978f3ce561018154276202ab40f37e42ca" +updated = "2026-06-15" +signature = "68bcdf220d7340985bc16432930ac4600bd1254f2a6cd9af53f2a45b571533ab" [[sections]] title = "Summary" @@ -38,6 +38,16 @@ clauses = [ "clauses/C-DEPENDENCY-SEPARATION.toml", ] +[[changelog]] +version = "0.5.0" +date = "2026-06-15" +notes = "Remove loop max-rounds budget from the round protocol" +changed = ["round_count is audit metadata and cannot drive failure semantics"] +removed = [ + "loop run --max-rounds retry-budget argument", + "max_rounds from new loop round artifacts", +] + [[changelog]] version = "0.4.0" date = "2026-06-03" diff --git a/gov/schema/SCHEMA.md b/gov/schema/SCHEMA.md index ad5324d2..b4274dae 100644 --- a/gov/schema/SCHEMA.md +++ b/gov/schema/SCHEMA.md @@ -332,7 +332,6 @@ last_round = 1 [round] loop_id = "LOOP-2026-01-17-001" round_number = 1 -max_rounds = 2 status = "open" work = ["WI-2026-01-17-002"] @@ -357,7 +356,6 @@ note_candidates = [] | `items..round_count` | yes | integer | Number of executed rounds for the work item | | `items..last_round` | no | integer | Last loop-level round that selected the work item | | `round.round_number` | yes | integer | One-based loop-level round number | -| `round.max_rounds` | yes | integer | Per-item round limit used when the round opened | | `round.status` | yes | enum | `open` \| `submitted` \| `closed` | | `round.work` | yes | array | Work Item IDs selected for the round | | `summary.actions` | yes | array | Actions performed during the round | diff --git a/gov/schema/loop-round.schema.json b/gov/schema/loop-round.schema.json index c5567eb0..d57f1b38 100644 --- a/gov/schema/loop-round.schema.json +++ b/gov/schema/loop-round.schema.json @@ -7,7 +7,7 @@ "properties": { "round": { "type": "object", - "required": ["loop_id", "round_number", "max_rounds", "status", "work"], + "required": ["loop_id", "round_number", "status", "work"], "properties": { "loop_id": { "type": "string", @@ -17,10 +17,6 @@ "type": "integer", "minimum": 1 }, - "max_rounds": { - "type": "integer", - "minimum": 1 - }, "status": { "type": "string", "enum": ["open", "submitted", "closed"] diff --git a/gov/work/2026-06-15-reject-empty-rfc-bumps.toml b/gov/work/2026-06-15-reject-empty-rfc-bumps.toml new file mode 100644 index 00000000..58149af1 --- /dev/null +++ b/gov/work/2026-06-15-reject-empty-rfc-bumps.toml @@ -0,0 +1,37 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-15-002" +title = "Reject empty RFC bumps" +status = "done" +created = "2026-06-15" +started = "2026-06-15" +completed = "2026-06-15" +refs = [ + "RFC-0002", + "ADR-0016", +] +tags = ["lifecycle"] + +[content] +description = "Implement and test RFC bump validation for empty amendments according to [[RFC-0002:C-LIFECYCLE-VERBS]], with regression coverage for the affected changelog and lifecycle paths." + +[[content.acceptance_criteria]] +text = "rfc bump rejects a version bump when no RFC or clause content changed" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "changelog-only updates do not make a later version bump valid" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "RFC or clause content amendments still allow the next version bump" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "lifecycle tests and governance checks pass" +status = "done" +category = "chore" diff --git a/gov/work/2026-06-15-remove-loop-max-rounds-semantics.toml b/gov/work/2026-06-15-remove-loop-max-rounds-semantics.toml new file mode 100644 index 00000000..1f822a72 --- /dev/null +++ b/gov/work/2026-06-15-remove-loop-max-rounds-semantics.toml @@ -0,0 +1,39 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-15-001" +title = "Remove loop max-rounds semantics" +status = "done" +created = "2026-06-15" +started = "2026-06-15" +completed = "2026-06-15" +refs = ["RFC-0006"] +tags = ["lifecycle"] + +[content] +description = "Remove max-rounds handling from the loop command surface and implementation per [[RFC-0006:C-LOOP-COMMAND-SURFACE]], keeping round counts as audit-oriented execution metadata covered by [[RFC-0006:C-LOOP-LIFECYCLE]]." + +[[content.acceptance_criteria]] +text = "[[RFC-0006]] removes max-rounds as a loop run argument and failure condition" +status = "done" +category = "removed" + +[[content.acceptance_criteria]] +text = "govctl check passes" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "loop run no longer accepts --max-rounds or stores max_rounds in new round artifacts" +status = "done" +category = "removed" + +[[content.acceptance_criteria]] +text = "loop tests pass" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "loop run does not fail loops or work items from round_count alone" +status = "done" +category = "changed" diff --git a/gov/work/2026-06-15-report-bare-reference-warning-source.toml b/gov/work/2026-06-15-report-bare-reference-warning-source.toml new file mode 100644 index 00000000..2bed1b7a --- /dev/null +++ b/gov/work/2026-06-15-report-bare-reference-warning-source.toml @@ -0,0 +1,32 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-15-003" +title = "Report bare reference warning source" +status = "done" +created = "2026-06-15" +started = "2026-06-15" +completed = "2026-06-15" +refs = [ + "RFC-0000", + "RFC-0002", +] +tags = ["validation"] + +[content] +description = "Implement diagnostic traceability for W0112 bare-reference warnings in support of the inline reference syntax validation defined by [[RFC-0000:C-REFERENCE-HIERARCHY]], with focused tests for the emitted source details." + +[[content.acceptance_criteria]] +text = "W0112 bare reference warnings include the scanned artifact field and line" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "W0112 diagnostics include a short matched-text context for quick root-cause lookup" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "reference warning tests and govctl check pass" +status = "done" +category = "chore" diff --git a/src/cli/loop_cmd.rs b/src/cli/loop_cmd.rs index bdd3a27c..eb6e4f69 100644 --- a/src/cli/loop_cmd.rs +++ b/src/cli/loop_cmd.rs @@ -121,7 +121,6 @@ NOTES: #[command(after_help = "\ EXAMPLES: govctl loop run LOOP-2026-04-06-001 - govctl loop run LOOP-2026-04-06-001 --max-rounds 2 govctl loop run LOOP-2026-04-06-001 --work WI-2026-04-06-002 NOTES: @@ -133,9 +132,6 @@ NOTES: Run { /// Loop ID id: String, - /// Maximum rounds each work item may run before loop-level failure - #[arg(long, default_value_t = 1)] - max_rounds: u32, /// Work item IDs to target inside an existing explicit loop #[arg(long = "work", value_name = "WI-ID")] target_work_ids: Vec, diff --git a/src/cmd/lifecycle/rfc.rs b/src/cmd/lifecycle/rfc.rs index 09f8028e..df4bcde5 100644 --- a/src/cmd/lifecycle/rfc.rs +++ b/src/cmd/lifecycle/rfc.rs @@ -22,9 +22,10 @@ pub fn bump( let rfc_path = require_rfc_toml_path(config, rfc_id)?; let mut rfc = read_rfc(config, &rfc_path)?; - - match (level, summary, changes.is_empty()) { + 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 new_version = bump_rfc_version(&mut rfc, lvl, sum)?; if !op.is_preview() { ui::version_bumped(rfc_id, &new_version); @@ -52,12 +53,15 @@ pub fn bump( )); } (None, _, false) => { + let refresh_signature_after_write = + should_refresh_signature_after_changelog_only(config, &rfc_path)?; for change in changes { add_changelog_change(&mut rfc, change)?; if !op.is_preview() { ui::changelog_change_added(rfc_id, &rfc.version, change); } } + refresh_signature_after_write } (None, Some(_), true) => { return Err(Diagnostic::new( @@ -73,9 +77,12 @@ pub fn bump( rfc_id, )); } - } + }; write_lifecycle_rfc(config, &rfc_path, &rfc, op)?; + if refresh_signature_after_write { + refresh_rfc_signature_best_effort(config, &rfc_path, &mut rfc, op)?; + } Ok(vec![]) } @@ -176,10 +183,35 @@ fn refresh_rfc_signature_best_effort( op: WriteOp, ) -> DiagnosticResult<()> { if let Ok(rfc_index) = crate::load::load_rfc(config, rfc_path) - && let Ok(sig) = crate::signature::compute_rfc_signature(&rfc_index) + && let Ok(sig) = crate::signature::compute_rfc_content_signature(&rfc_index) { rfc.signature = Some(sig); write_lifecycle_rfc(config, rfc_path, rfc, op)?; } Ok(()) } + +fn ensure_rfc_has_content_amendment( + config: &Config, + rfc_path: &Path, + rfc_id: &str, +) -> DiagnosticResult<()> { + 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(()); + } + + Err(Diagnostic::new( + DiagnosticCode::E0113RfcBumpNoAmendment, + "RFC version bump requires RFC or clause content changes since the last bump", + rfc_id, + )) +} + +fn should_refresh_signature_after_changelog_only( + config: &Config, + rfc_path: &Path, +) -> DiagnosticResult { + let rfc_index = crate::load::load_rfc(config, rfc_path)?; + Ok(rfc_index.rfc.signature.is_some() && !crate::signature::is_rfc_amended(&rfc_index)) +} diff --git a/src/cmd/loop_cmd/execution/mod.rs b/src/cmd/loop_cmd/execution/mod.rs index a2a13784..f122c290 100644 --- a/src/cmd/loop_cmd/execution/mod.rs +++ b/src/cmd/loop_cmd/execution/mod.rs @@ -1,7 +1,7 @@ use super::output::print_loop; use super::state::{ ensure_loop_not_terminal, ensure_loop_plan_fresh, ensure_unique_work_item_ids, - loop_dependencies, loop_item_state, + loop_dependencies, }; use crate::cmd::work_lookup::load_work_item_by_id; use crate::config::Config; @@ -20,22 +20,12 @@ pub fn run( config: &Config, loop_id: &str, target_work_ids: &[String], - max_rounds: u32, op: WriteOp, ) -> DiagnosticResult { - if max_rounds == 0 { - return Err(Diagnostic::new( - DiagnosticCode::E1211LoopInvalidMaxRounds, - "Loop max rounds must be at least 1", - "loop", - )); - } - let mut state = state_for_run(config, loop_id, target_work_ids)?; ensure_loop_not_terminal(&state, "run")?; println!("Running loop {}", state.loop_meta.id); - println!("Max rounds: {max_rounds}"); if !target_work_ids.is_empty() { println!("Targets: {}", target_work_ids.join(", ")); } @@ -45,7 +35,7 @@ pub fn run( Some(record) if record.round_meta.status != LoopRoundStatus::Closed => { close_round(config, &mut state, record, target_work_ids, op) } - _ => open_round(config, &mut state, target_work_ids, max_rounds, op), + _ => open_round(config, &mut state, target_work_ids, op), } } @@ -127,7 +117,6 @@ fn open_round( config: &Config, state: &mut LoopState, target_work_ids: &[String], - max_rounds: u32, op: WriteOp, ) -> DiagnosticResult { // Implements [[RFC-0006:C-ROUND-EXECUTION]] by rejecting stale cached plans @@ -136,22 +125,17 @@ fn open_round( reflect_terminal_work_statuses(config, state)?; propagate_blocked_outcomes(state)?; - let ready_work = ready_work_for_round(state, target_work_ids, max_rounds)?; - if ready_work.failures.is_empty() && !ready_work.selected.is_empty() { + let ready_work = ready_work_for_round(state, target_work_ids)?; + if !ready_work.is_empty() { let round_number = next_round_number(state)?; state.loop_meta.current_round = round_number; state.loop_meta.next_action = LoopNextAction::WriteSummary; - for work_id in &ready_work.selected { + for work_id in &ready_work { state.set_item_status(work_id, LoopWorkItemStatus::Active)?; state.record_item_round(work_id, round_number)?; } - let record = LoopRoundRecord::open( - state.loop_meta.id.clone(), - round_number, - max_rounds, - ready_work.selected, - ); + let record = LoopRoundRecord::open(state.loop_meta.id.clone(), round_number, ready_work); write_loop_round_record(config, &record, op)?; write_loop_state_with_op(config, state, op)?; print_opened_round(config, &record)?; @@ -166,7 +150,7 @@ fn open_round( if state.loop_meta.state == LoopLifecycleState::Failed { return Err(Diagnostic::new( DiagnosticCode::E1210LoopExecutionFailed, - loop_failure_message(state, &ready_work.failures), + loop_failure_message(state), state.loop_meta.id.clone(), )); } @@ -212,7 +196,7 @@ fn close_round( if state.loop_meta.state == LoopLifecycleState::Failed { return Err(Diagnostic::new( DiagnosticCode::E1210LoopExecutionFailed, - loop_failure_message(state, &[]), + loop_failure_message(state), state.loop_meta.id.clone(), )); } @@ -242,19 +226,12 @@ fn validate_open_round_target_selector( Ok(()) } -struct ReadyWork { - selected: Vec, - failures: Vec, -} - fn ready_work_for_round( state: &mut LoopState, target_work_ids: &[String], - max_rounds: u32, -) -> DiagnosticResult { +) -> DiagnosticResult> { let selected_work_ids = selected_execution_set(state, target_work_ids)?; let mut selected = Vec::new(); - let mut failures = Vec::new(); for work_id in topological_order_for_state(state)? { propagate_blocked_outcomes(state)?; @@ -272,18 +249,11 @@ fn ready_work_for_round( continue; } } - - let current_rounds = loop_item_state(state, &work_id)?.round_count; - if current_rounds >= max_rounds { - state.set_item_status(&work_id, LoopWorkItemStatus::Failed)?; - failures.push(format!("{work_id}: maximum rounds reached ({max_rounds})")); - continue; - } selected.push(work_id); } propagate_blocked_outcomes(state)?; - Ok(ReadyWork { selected, failures }) + Ok(selected) } fn selected_execution_set( @@ -438,18 +408,6 @@ fn print_final_state(state: &LoopState) -> DiagnosticResult<()> { } } -fn loop_failure_message(state: &LoopState, failures: &[String]) -> String { - if failures.is_empty() { - format!("Loop '{}' failed", state.loop_meta.id) - } else { - format!( - "Loop '{}' failed:\n{}", - state.loop_meta.id, - failures - .iter() - .map(|failure| format!(" - {failure}")) - .collect::>() - .join("\n") - ) - } +fn loop_failure_message(state: &LoopState) -> String { + format!("Loop '{}' failed", state.loop_meta.id) } diff --git a/src/command_router/execute/builtin.rs b/src/command_router/execute/builtin.rs index 197d2822..37a9edcc 100644 --- a/src/command_router/execute/builtin.rs +++ b/src/command_router/execute/builtin.rs @@ -72,7 +72,6 @@ pub(super) fn execute_builtin(config: &Config, builtin: &BuiltinOp, op: WriteOp) BuiltinOp::LoopRun { loop_id, target_work_ids, - max_rounds, - } => cmd::loop_cmd::run(config, loop_id, target_work_ids, *max_rounds, op), + } => cmd::loop_cmd::run(config, loop_id, target_work_ids, op), } } diff --git a/src/command_router/parsed.rs b/src/command_router/parsed.rs index 76cfc0bc..35137df4 100644 --- a/src/command_router/parsed.rs +++ b/src/command_router/parsed.rs @@ -111,11 +111,9 @@ fn plan_loop_command(command: &LoopCommand) -> CommandPlan { LoopCommand::Run { id, target_work_ids, - max_rounds, } => BuiltinOp::LoopRun { loop_id: id.clone(), target_work_ids: target_work_ids.clone(), - max_rounds: *max_rounds, }, }; global(Op::Builtin(op)) diff --git a/src/command_router/plan.rs b/src/command_router/plan.rs index f38655e8..138fcdc8 100644 --- a/src/command_router/plan.rs +++ b/src/command_router/plan.rs @@ -120,7 +120,6 @@ pub enum BuiltinOp { LoopRun { loop_id: String, target_work_ids: Vec, - max_rounds: u32, }, } diff --git a/src/command_router/tests/lock_disposition.rs b/src/command_router/tests/lock_disposition.rs index 3cda7aa3..2c72ffb8 100644 --- a/src/command_router/tests/lock_disposition.rs +++ b/src/command_router/tests/lock_disposition.rs @@ -131,7 +131,6 @@ fn test_lock_disposition_requires_lock_for_mutating_commands() global(Op::Builtin(BuiltinOp::LoopRun { loop_id: "LOOP-2026-04-07-001".to_string(), target_work_ids: vec![], - max_rounds: 1, })) .lock_disposition(), LockDisposition::GovRootExclusive diff --git a/src/command_router/tests/routing.rs b/src/command_router/tests/routing.rs index 41dfcd9d..a143cfd9 100644 --- a/src/command_router/tests/routing.rs +++ b/src/command_router/tests/routing.rs @@ -122,7 +122,6 @@ fn test_loop_commands_route_to_builtin_ops() -> Result<(), Box Result<(), Box &'static str { DiagnosticCode::E0110RfcInvalidId => "E0110", DiagnosticCode::E0111RfcNoChangelog => "E0111", DiagnosticCode::E0112RfcReferenceHierarchy => "E0112", + DiagnosticCode::E0113RfcBumpNoAmendment => "E0113", // E02xx - Clause DiagnosticCode::E0201ClauseSchemaInvalid => "E0201", DiagnosticCode::E0202ClauseNotFound => "E0202", @@ -101,7 +102,6 @@ pub(super) fn code(code: &DiagnosticCode) -> &'static str { DiagnosticCode::E1208LoopResumeAmbiguous => "E1208", DiagnosticCode::E1209LoopWorkMismatch => "E1209", DiagnosticCode::E1210LoopExecutionFailed => "E1210", - DiagnosticCode::E1211LoopInvalidMaxRounds => "E1211", // E08xx - CLI/Command DiagnosticCode::E0801MissingRequiredArg => "E0801", DiagnosticCode::E0802ConflictingArgs => "E0802", diff --git a/src/diagnostic/code/mod.rs b/src/diagnostic/code/mod.rs index 67109e5b..64e172bf 100644 --- a/src/diagnostic/code/mod.rs +++ b/src/diagnostic/code/mod.rs @@ -27,6 +27,7 @@ pub enum DiagnosticCode { E0111RfcNoChangelog, /// RFC refs or [[...]] targets ADR/WI — violates [[RFC-0000:C-REFERENCE-HIERARCHY]] E0112RfcReferenceHierarchy, + E0113RfcBumpNoAmendment, // Clause errors (E02xx) E0201ClauseSchemaInvalid, @@ -111,7 +112,6 @@ pub enum DiagnosticCode { E1208LoopResumeAmbiguous, E1209LoopWorkMismatch, E1210LoopExecutionFailed, - E1211LoopInvalidMaxRounds, // CLI/Command errors (E08xx) E0801MissingRequiredArg, diff --git a/src/loop_state/mod.rs b/src/loop_state/mod.rs index 24ef5403..80102a2e 100644 --- a/src/loop_state/mod.rs +++ b/src/loop_state/mod.rs @@ -92,7 +92,8 @@ pub struct LoopState { pub struct LoopRoundMeta { pub loop_id: String, pub round_number: u32, - pub max_rounds: u32, + #[serde(default, rename = "max_rounds", skip_serializing)] + legacy_max_rounds: Option, pub status: LoopRoundStatus, pub work: Vec, } @@ -226,17 +227,12 @@ impl LoopState { } impl LoopRoundRecord { - pub fn open( - loop_id: impl Into, - round_number: u32, - max_rounds: u32, - work: Vec, - ) -> Self { + pub fn open(loop_id: impl Into, round_number: u32, work: Vec) -> Self { Self { round_meta: LoopRoundMeta { loop_id: loop_id.into(), round_number, - max_rounds, + legacy_max_rounds: None, status: LoopRoundStatus::Open, work, }, diff --git a/src/loop_state/tests.rs b/src/loop_state/tests.rs index 1460a59f..d4dd1ce7 100644 --- a/src/loop_state/tests.rs +++ b/src/loop_state/tests.rs @@ -120,6 +120,47 @@ round_count = 0 Ok(()) } +#[test] +fn test_loop_round_load_tolerates_legacy_max_rounds() -> TestResult { + let temp_dir = tempfile::TempDir::new()?; + let config = test_config(temp_dir.path()); + let loop_id = "LOOP-2026-05-31-007"; + let work_id = "WI-2026-05-31-001"; + let round_dir = temp_dir + .path() + .join(format!(".govctl/loops/{loop_id}/rounds")); + std::fs::create_dir_all(&round_dir)?; + std::fs::write( + round_dir.join("round-001.toml"), + format!( + r#"[round] +loop_id = "{loop_id}" +round_number = 1 +max_rounds = 1 +status = "open" +work = ["{work_id}"] + +[summary] +actions = [] +changed_paths = [] +verification = [] +blockers = [] +note_candidates = [] +"# + ), + )?; + + let record = load_loop_round_record(&config, loop_id, 1)?; + assert_eq!(record.round_meta.loop_id, loop_id); + assert_eq!(record.round_meta.round_number, 1); + assert_eq!(record.round_meta.work, vec![work_id.to_string()]); + + write_loop_round_record(&config, &record, WriteOp::Execute)?; + let rewritten = std::fs::read_to_string(round_dir.join("round-001.toml"))?; + assert!(!rewritten.contains("max_rounds"), "{rewritten}"); + Ok(()) +} + #[test] fn test_loop_state_updates_lifecycle_item_status_and_round_count() -> TestResult { let temp_dir = tempfile::TempDir::new()?; diff --git a/src/loop_state/validation/round.rs b/src/loop_state/validation/round.rs index ba6017bc..d51ee47a 100644 --- a/src/loop_state/validation/round.rs +++ b/src/loop_state/validation/round.rs @@ -13,12 +13,6 @@ pub(in crate::loop_state) fn validate_loop_round_record( "loop round record round_number must be at least 1", )); } - if round.max_rounds == 0 { - return Err(invalid_state( - &round.loop_id, - "loop round record max_rounds must be at least 1", - )); - } if round.work.is_empty() { return Err(invalid_state( &round.loop_id, diff --git a/src/signature/mod.rs b/src/signature/mod.rs index 3b966b23..54f42ac2 100644 --- a/src/signature/mod.rs +++ b/src/signature/mod.rs @@ -30,6 +30,31 @@ const SIGNATURE_VERSION: u32 = 1; /// # Errors /// Returns a diagnostic if an RFC or clause cannot be serialized for signature input. pub fn compute_rfc_signature(rfc: &RfcIndex) -> Result { + compute_rfc_signature_with_filter(rfc, |map| { + map.remove("signature"); + }) +} + +/// Compute SHA-256 signature for RFC amendment detection. +/// +/// This signature intentionally ignores bump bookkeeping fields. It answers: +/// "Has RFC or clause content changed since the last recorded amendment +/// baseline?" rather than "Would the rendered projection change?" +/// +/// # Errors +/// Returns a diagnostic if an RFC or clause cannot be serialized for signature input. +pub fn compute_rfc_content_signature(rfc: &RfcIndex) -> Result { + compute_rfc_signature_with_filter(rfc, |map| { + map.remove("signature"); + map.remove("version"); + map.remove("changelog"); + }) +} + +fn compute_rfc_signature_with_filter( + rfc: &RfcIndex, + filter_rfc_fields: impl FnOnce(&mut serde_json::Map), +) -> Result { let mut hasher = signature_hasher("rfc"); let mut rfc_json = signature_value( @@ -39,7 +64,7 @@ pub fn compute_rfc_signature(rfc: &RfcIndex) -> Result { &rfc.rfc.rfc_id, )?; if let Value::Object(ref mut map) = rfc_json { - map.remove("signature"); + filter_rfc_fields(map); } update_canonical_json(&mut hasher, &rfc_json); @@ -173,15 +198,27 @@ fn hex_encode(bytes: &[u8]) -> String { /// Returns `true` if the current content signature differs from the stored signature, /// indicating the RFC has been modified but not yet bumped to a new version. /// -/// Returns `false` if signatures match (clean state) or if no signature is stored (legacy RFC). +/// Returns `false` if signatures match (clean state) or if no signature is stored. +/// Stored signatures created before content-only signatures are accepted as a +/// legacy clean baseline when the full rendered-projection signature still matches. pub fn is_rfc_amended(rfc: &RfcIndex) -> bool { let Some(stored_sig) = &rfc.rfc.signature else { return false; }; - let Ok(current_sig) = compute_rfc_signature(rfc) else { + let Ok(current_content_sig) = compute_rfc_content_signature(rfc) else { + return false; + }; + if stored_sig == ¤t_content_sig { + return false; + } + + let Ok(current_full_sig) = compute_rfc_signature(rfc) else { return false; }; + if stored_sig == ¤t_full_sig { + return false; + } - stored_sig != ¤t_sig + true } diff --git a/src/signature/tests.rs b/src/signature/tests.rs index 5c61f9b6..f1437914 100644 --- a/src/signature/tests.rs +++ b/src/signature/tests.rs @@ -1,6 +1,11 @@ use super::canonical_json::canonicalize_json; use super::*; +use crate::model::{ + ChangelogEntry, ClauseEntry, ClauseKind, ClauseSpec, ClauseStatus, RfcIndex, RfcPhase, RfcSpec, + RfcStatus, SectionSpec, +}; use serde_json::Value; +use std::path::PathBuf; #[test] fn test_canonicalize_sorts_keys() -> Result<(), Box> { @@ -37,3 +42,86 @@ fn test_extract_signature_not_found() { let md = "# Just a plain markdown file"; assert_eq!(extract_signature(md), None); } + +#[test] +fn test_rfc_content_signature_ignores_bump_bookkeeping() -> Result<(), Diagnostic> { + let mut rfc = test_rfc_index(); + let baseline = compute_rfc_content_signature(&rfc)?; + + rfc.rfc.version = "0.1.1".to_string(); + rfc.rfc.signature = Some("legacy-or-current-signature".to_string()); + rfc.rfc.changelog.push(ChangelogEntry { + version: "0.1.1".to_string(), + date: "2026-06-15".to_string(), + notes: Some("Bookkeeping only".to_string()), + added: vec![], + changed: vec![], + deprecated: vec![], + removed: vec![], + fixed: vec!["no content change".to_string()], + security: vec![], + }); + + assert_eq!(compute_rfc_content_signature(&rfc)?, baseline); + assert_ne!(compute_rfc_signature(&rfc)?, baseline); + Ok(()) +} + +#[test] +fn test_rfc_content_signature_includes_clause_content() -> Result<(), Diagnostic> { + let mut rfc = test_rfc_index(); + let baseline = compute_rfc_content_signature(&rfc)?; + + rfc.clauses[0].spec.text = "Updated normative behavior.".to_string(); + + assert_ne!(compute_rfc_content_signature(&rfc)?, baseline); + Ok(()) +} + +#[test] +fn test_rfc_amended_accepts_legacy_full_signature_baseline() -> Result<(), Diagnostic> { + let mut rfc = test_rfc_index(); + rfc.rfc.signature = Some(compute_rfc_signature(&rfc)?); + + assert!(!is_rfc_amended(&rfc)); + Ok(()) +} + +fn test_rfc_index() -> RfcIndex { + RfcIndex { + rfc: RfcSpec { + rfc_id: "RFC-0001".to_string(), + title: "Test RFC".to_string(), + version: "0.1.0".to_string(), + status: RfcStatus::Normative, + phase: RfcPhase::Impl, + owners: vec!["@test-user".to_string()], + created: "2026-06-15".to_string(), + updated: None, + supersedes: None, + refs: vec![], + tags: vec![], + sections: vec![SectionSpec { + title: "Specification".to_string(), + clauses: vec!["C-TEST".to_string()], + }], + changelog: vec![], + signature: None, + }, + clauses: vec![ClauseEntry { + spec: ClauseSpec { + clause_id: "C-TEST".to_string(), + title: "Test Clause".to_string(), + kind: ClauseKind::Normative, + status: ClauseStatus::Active, + text: "Original normative behavior.".to_string(), + anchors: vec![], + superseded_by: None, + since: Some("0.1.0".to_string()), + tags: vec![], + }, + path: PathBuf::from("gov/rfc/RFC-0001/clauses/C-TEST.toml"), + }], + path: PathBuf::from("gov/rfc/RFC-0001/rfc.toml"), + } +} diff --git a/src/validate/bracket_refs.rs b/src/validate/bracket_refs.rs index 3faa95b8..5edf26eb 100644 --- a/src/validate/bracket_refs.rs +++ b/src/validate/bracket_refs.rs @@ -15,6 +15,12 @@ struct ReferenceScanner { known_ids: HashSet, } +#[derive(Clone, Copy)] +struct TextSource<'a> { + path: &'a str, + field: &'a str, +} + /// Validate inline references in governed prose per [[RFC-0000:C-REFERENCE-HIERARCHY]]. pub(super) fn validate_bracket_reference_hierarchy( index: &ProjectIndex, @@ -55,30 +61,60 @@ pub(super) fn validate_bracket_reference_hierarchy( let warn_on_bare_text = rfc.rfc.status == RfcStatus::Draft; for clause in &rfc.clauses { let clause_path = config.display_path(&clause.path).display().to_string(); + let field = format!("{} content.text", clause.spec.clause_id); scan_rfc_reference_hierarchy( &scanner, &clause.spec.text, rid, - &clause_path, + TextSource { + path: &clause_path, + field: &field, + }, true, warn_on_bare_text, result, ); } - for entry in &rfc.rfc.changelog { + for (entry_index, entry) in rfc.rfc.changelog.iter().enumerate() { if let Some(ref notes) = entry.notes { - scan_rfc_reference_hierarchy(&scanner, notes, rid, &rfc_path, false, false, result); + let field = format!("changelog[{entry_index}].notes"); + scan_rfc_reference_hierarchy( + &scanner, + notes, + rid, + TextSource { + path: &rfc_path, + field: &field, + }, + false, + false, + result, + ); } - for line in entry - .added - .iter() - .chain(entry.changed.iter()) - .chain(entry.deprecated.iter()) - .chain(entry.removed.iter()) - .chain(entry.fixed.iter()) - .chain(entry.security.iter()) - { - scan_rfc_reference_hierarchy(&scanner, line, rid, &rfc_path, false, false, result); + let changelog_sections = [ + ("added", &entry.added), + ("changed", &entry.changed), + ("deprecated", &entry.deprecated), + ("removed", &entry.removed), + ("fixed", &entry.fixed), + ("security", &entry.security), + ]; + for (section, lines) in changelog_sections { + for (line_index, line) in lines.iter().enumerate() { + let field = format!("changelog[{entry_index}].{section}[{line_index}]"); + scan_rfc_reference_hierarchy( + &scanner, + line, + rid, + TextSource { + path: &rfc_path, + field: &field, + }, + false, + false, + result, + ); + } } } } @@ -92,7 +128,10 @@ pub(super) fn validate_bracket_reference_hierarchy( &scanner, &c.context, aid, - &adr_path, + TextSource { + path: &adr_path, + field: "content.context", + }, warn_on_bare_text, result, ); @@ -100,7 +139,10 @@ pub(super) fn validate_bracket_reference_hierarchy( &scanner, &c.decision, aid, - &adr_path, + TextSource { + path: &adr_path, + field: "content.decision", + }, warn_on_bare_text, result, ); @@ -108,45 +150,64 @@ pub(super) fn validate_bracket_reference_hierarchy( &scanner, &c.consequences, aid, - &adr_path, + TextSource { + path: &adr_path, + field: "content.consequences", + }, warn_on_bare_text, result, ); - for alt in &c.alternatives { + for (alt_index, alt) in c.alternatives.iter().enumerate() { + let alt_text_field = format!("content.alternatives[{alt_index}].text"); scan_adr_reference_hierarchy( &scanner, &alt.text, aid, - &adr_path, + TextSource { + path: &adr_path, + field: &alt_text_field, + }, warn_on_bare_text, result, ); - for p in &alt.pros { + for (pro_index, p) in alt.pros.iter().enumerate() { + let pro_field = format!("content.alternatives[{alt_index}].pros[{pro_index}]"); scan_adr_reference_hierarchy( &scanner, p, aid, - &adr_path, + TextSource { + path: &adr_path, + field: &pro_field, + }, warn_on_bare_text, result, ); } - for cons in &alt.cons { + for (con_index, cons) in alt.cons.iter().enumerate() { + let con_field = format!("content.alternatives[{alt_index}].cons[{con_index}]"); scan_adr_reference_hierarchy( &scanner, cons, aid, - &adr_path, + TextSource { + path: &adr_path, + field: &con_field, + }, warn_on_bare_text, result, ); } if let Some(ref rr) = alt.rejection_reason { + let rejection_field = format!("content.alternatives[{alt_index}].rejection_reason"); scan_adr_reference_hierarchy( &scanner, rr, aid, - &adr_path, + TextSource { + path: &adr_path, + field: &rejection_field, + }, warn_on_bare_text, result, ); @@ -163,22 +224,40 @@ pub(super) fn validate_bracket_reference_hierarchy( &scanner, &content.description, wid, - &work_path, + TextSource { + path: &work_path, + field: "content.description", + }, warn_on_bare_text, result, ); - for criterion in &content.acceptance_criteria { + for (criterion_index, criterion) in content.acceptance_criteria.iter().enumerate() { + let criterion_field = format!("content.acceptance_criteria[{criterion_index}].text"); scan_work_reference_syntax( &scanner, &criterion.text, wid, - &work_path, + TextSource { + path: &work_path, + field: &criterion_field, + }, warn_on_bare_text, result, ); } - for note in &content.notes { - scan_work_reference_syntax(&scanner, note, wid, &work_path, warn_on_bare_text, result); + for (note_index, note) in content.notes.iter().enumerate() { + let note_field = format!("content.notes[{note_index}]"); + scan_work_reference_syntax( + &scanner, + note, + wid, + TextSource { + path: &work_path, + field: ¬e_field, + }, + warn_on_bare_text, + result, + ); } } } @@ -187,7 +266,7 @@ fn scan_rfc_reference_hierarchy( scanner: &ReferenceScanner, text: &str, rfc_id: &str, - path: &str, + source: TextSource<'_>, scan_bare_text: bool, warn_on_bare_text: bool, result: &mut ValidationResult, @@ -196,7 +275,7 @@ fn scan_rfc_reference_hierarchy( scanner, text, rfc_id, - path, + source, scan_bare_text, warn_on_bare_text, result, @@ -207,18 +286,26 @@ fn scan_adr_reference_hierarchy( scanner: &ReferenceScanner, text: &str, adr_id: &str, - path: &str, + source: TextSource<'_>, warn_on_bare_text: bool, result: &mut ValidationResult, ) { - scan_reference_hierarchy(scanner, text, adr_id, path, true, warn_on_bare_text, result); + scan_reference_hierarchy( + scanner, + text, + adr_id, + source, + true, + warn_on_bare_text, + result, + ); } fn scan_work_reference_syntax( scanner: &ReferenceScanner, text: &str, work_id: &str, - path: &str, + source: TextSource<'_>, warn_on_bare_text: bool, result: &mut ValidationResult, ) { @@ -226,7 +313,7 @@ fn scan_work_reference_syntax( scanner, text, work_id, - path, + source, true, warn_on_bare_text, result, @@ -237,7 +324,7 @@ fn scan_reference_hierarchy( scanner: &ReferenceScanner, text: &str, owner_id: &str, - path: &str, + source: TextSource<'_>, scan_bare_text: bool, warn_on_bare_text: bool, result: &mut ValidationResult, @@ -252,7 +339,7 @@ fn scan_reference_hierarchy( }; let target = m.as_str(); if let Err(diagnostic) = - check_ref_hierarchy(owner_id, target, path, ReferenceSurface::BracketLink) + check_ref_hierarchy(owner_id, target, source.path, ReferenceSurface::BracketLink) { result.diagnostics.push(diagnostic); } @@ -276,26 +363,61 @@ fn scan_reference_hierarchy( if !scanner.known_ids.contains(target) { continue; } - match check_ref_hierarchy(owner_id, target, path, ReferenceSurface::BareText) { - Ok(()) if warn_on_bare_text => result - .diagnostics - .push(bare_artifact_reference_warning(owner_id, target, path)), + match check_ref_hierarchy(owner_id, target, source.path, ReferenceSurface::BareText) { + Ok(()) if warn_on_bare_text => result.diagnostics.push( + bare_artifact_reference_warning(owner_id, target, source, text, m.start()), + ), Ok(()) => {} Err(diagnostic) => result.diagnostics.push(diagnostic), } } } -fn bare_artifact_reference_warning(owner_id: &str, target: &str, path: &str) -> Diagnostic { +fn bare_artifact_reference_warning( + owner_id: &str, + target: &str, + source: TextSource<'_>, + text: &str, + match_start: usize, +) -> Diagnostic { + let (line, context) = source_line_context(text, match_start); Diagnostic::new( DiagnosticCode::W0112BareArtifactReference, format!( - "Artifact '{owner_id}' mentions known artifact ID {target} without [[...]] inline reference syntax (hint: use [[{target}]])" + "Artifact '{owner_id}' {field} line {line} mentions known artifact ID {target} without [[...]] inline reference syntax (hint: use [[{target}]]; context: \"{context}\")", + field = source.field, ), - path, + source.path, ) } +fn source_line_context(text: &str, byte_offset: usize) -> (usize, String) { + let line = text[..byte_offset].bytes().filter(|b| *b == b'\n').count() + 1; + let line_start = text[..byte_offset].rfind('\n').map_or(0, |idx| idx + 1); + let line_end = text[byte_offset..] + .find('\n') + .map_or(text.len(), |idx| byte_offset + idx); + let context = collapse_context_whitespace(&text[line_start..line_end]); + (line, truncate_context(&context)) +} + +fn collapse_context_whitespace(line: &str) -> String { + line.split_whitespace().collect::>().join(" ") +} + +fn truncate_context(context: &str) -> String { + const MAX_CONTEXT_CHARS: usize = 120; + let mut out = String::new(); + for (count, ch) in context.chars().enumerate() { + if count == MAX_CONTEXT_CHARS { + out.push_str("..."); + break; + } + out.push(ch); + } + out.replace('"', "\\\"") +} + #[cfg(test)] mod tests { use super::*; @@ -342,7 +464,10 @@ mod tests { &scanner(known_ids)?, "This mentions ADR-0001 without brackets.", "RFC-0001", - "f", + TextSource { + path: "f", + field: "content.text", + }, true, true, &mut result, @@ -365,7 +490,10 @@ mod tests { &scanner(known_ids)?, "This mentions ADR-0001 only as an example shape.", "RFC-0001", - "f", + TextSource { + path: "f", + field: "content.text", + }, true, true, &mut result, @@ -383,9 +511,12 @@ mod tests { scan_reference_hierarchy( &scanner(known_ids)?, - "This follows RFC-0001.", + "Intro line.\nThis follows RFC-0001.", "ADR-0001", - "f", + TextSource { + path: "f", + field: "content.decision", + }, true, true, &mut result, @@ -396,6 +527,20 @@ mod tests { result.diagnostics[0].code, DiagnosticCode::W0112BareArtifactReference ); + assert!( + result.diagnostics[0] + .message + .contains("content.decision line 2"), + "message: {}", + result.diagnostics[0].message + ); + assert!( + result.diagnostics[0] + .message + .contains("context: \"This follows RFC-0001.\""), + "message: {}", + result.diagnostics[0].message + ); Ok(()) } @@ -409,7 +554,10 @@ mod tests { &scanner(known_ids)?, "This follows [[RFC-0001]].", "ADR-0001", - "f", + TextSource { + path: "f", + field: "content.decision", + }, true, true, &mut result, diff --git a/tests/common/loop_helpers.rs b/tests/common/loop_helpers.rs index 87678003..9f960804 100644 --- a/tests/common/loop_helpers.rs +++ b/tests/common/loop_helpers.rs @@ -71,10 +71,6 @@ pub fn loop_run(loop_id: &str) -> Vec { command(&["loop", "run", loop_id]) } -pub fn loop_run_with_max_rounds(loop_id: &str, max_rounds: &str) -> Vec { - command(&["loop", "run", loop_id, "--max-rounds", max_rounds]) -} - pub fn loop_run_target(loop_id: &str, work_id: &str) -> Vec { command(&["loop", "run", loop_id, "--work", work_id]) } diff --git a/tests/error_tests/rfc_clause_cases/check.rs b/tests/error_tests/rfc_clause_cases/check.rs index 185bf813..2cf38fa3 100644 --- a/tests/error_tests/rfc_clause_cases/check.rs +++ b/tests/error_tests/rfc_clause_cases/check.rs @@ -325,6 +325,16 @@ consequences = "Consequences" &[&["check"], &["check", "--deny-warnings"]], )?; assert!(output.contains("warning[W0112]"), "output: {}", output); + assert!( + output.contains("content.decision line 1"), + "output: {}", + output + ); + assert!( + output.contains("context: \"This decision follows RFC-0001.\""), + "output: {}", + output + ); assert!(output.contains("use [[RFC-0001]]"), "output: {}", output); assert!(output.contains("$ govctl check\n"), "output: {}", output); assert!(output.contains("exit: 0"), "output: {}", output); @@ -372,8 +382,24 @@ rejection_reason = "Rejected after comparing with RFC-0001." "output: {}", output ); + assert!(output.contains("Artifact 'ADR-0001'"), "output: {}", output); + assert!( + output.contains("content.alternatives[0].text line 1"), + "output: {}", + output + ); + assert!( + output.contains("content.alternatives[0].pros[0] line 1"), + "output: {}", + output + ); + assert!( + output.contains("content.alternatives[0].cons[0] line 1"), + "output: {}", + output + ); assert!( - output.contains("Artifact 'ADR-0001' mentions known artifact ID RFC-0001"), + output.contains("content.alternatives[0].rejection_reason line 1"), "output: {}", output ); diff --git a/tests/error_tests/work.rs b/tests/error_tests/work.rs index 56304a4a..d40fa1d0 100644 --- a/tests/error_tests/work.rs +++ b/tests/error_tests/work.rs @@ -90,7 +90,10 @@ created = "2026-01-01" refs = ["RFC-0001"] [content] -description = "This work follows RFC-0001." +description = """ +Intro line. +This work follows RFC-0001. +""" [[content.acceptance_criteria]] text = "Use [[RFC-0001]] in bracketed form here" @@ -102,7 +105,17 @@ category = "chore" let output = run_commands(temp_dir.path(), &[&["check"]])?; assert!(output.contains("warning[W0112]"), "output: {}", output); assert!( - output.contains("Artifact 'WI-2026-01-01-001' mentions known artifact ID RFC-0001"), + output.contains("Artifact 'WI-2026-01-01-001' content.description line 2"), + "output: {}", + output + ); + assert!( + output.contains("content.description line 2"), + "output: {}", + output + ); + assert!( + output.contains("context: \"This work follows RFC-0001.\""), "output: {}", output ); @@ -148,7 +161,17 @@ category = "chore" output ); assert!( - output.contains("Artifact 'WI-2026-01-01-001' mentions known artifact ID RFC-0001"), + output.contains("Artifact 'WI-2026-01-01-001'"), + "output: {}", + output + ); + assert!( + output.contains("content.notes[0] line 1"), + "output: {}", + output + ); + assert!( + output.contains("content.acceptance_criteria[0].text line 1"), "output: {}", output ); diff --git a/tests/lifecycle_tests/rfc_cases/bump.rs b/tests/lifecycle_tests/rfc_cases/bump.rs index 3ad658bc..231f390c 100644 --- a/tests/lifecycle_tests/rfc_cases/bump.rs +++ b/tests/lifecycle_tests/rfc_cases/bump.rs @@ -109,6 +109,135 @@ fn test_bump_with_change() -> common::TestResult { Ok(()) } +#[test] +fn test_bump_rejects_empty_bump_after_signature_baseline() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Test RFC"], + &["rfc", "finalize", "RFC-0001", "normative"], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Establish baseline", + ], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Empty bump", + ], + &["rfc", "list"], + ], + )?; + assert_lifecycle_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) +} + +#[test] +fn test_bump_rejects_changelog_only_after_signature_baseline() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + + let output = run_commands( + temp_dir.path(), + &[ + &["rfc", "new", "Test RFC"], + &["rfc", "finalize", "RFC-0001", "normative"], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Establish baseline", + ], + &[ + "rfc", + "bump", + "RFC-0001", + "--change", + "Added changelog note", + ], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Changelog-only bump", + ], + &["rfc", "list"], + ], + )?; + assert_lifecycle_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) +} + +#[test] +fn test_bump_change_does_not_clear_pending_amendment() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + + 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", + "Updated normative behavior.", + ], + &["rfc", "bump", "RFC-0001", "--change", "Added release note"], + &["rfc", "list"], + &[ + "rfc", + "bump", + "RFC-0001", + "--patch", + "--summary", + "Release amendment", + ], + &["rfc", "list"], + ], + )?; + assert_lifecycle_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) +} + #[test] fn test_bump_nonexistent_rfc() -> common::TestResult { let (temp_dir, date) = init_project_with_date()?; diff --git a/tests/loop_tests/execution_cases/lifecycle.rs b/tests/loop_tests/execution_cases/lifecycle.rs index f3932684..50675d1c 100644 --- a/tests/loop_tests/execution_cases/lifecycle.rs +++ b/tests/loop_tests/execution_cases/lifecycle.rs @@ -55,7 +55,7 @@ fn test_loop_run_opens_round_without_mutating_work_item() -> common::TestResult let round: toml::Value = toml::from_str(&round_toml)?; assert_eq!(round["round"]["loop_id"].as_str(), Some(loop_id.as_str())); assert_eq!(round["round"]["round_number"].as_integer(), Some(1)); - assert_eq!(round["round"]["max_rounds"].as_integer(), Some(1)); + assert!(round["round"].get("max_rounds").is_none(), "{round_toml}"); assert_eq!(round["round"]["status"].as_str(), Some("open")); assert_eq!(round["round"]["work"][0].as_str(), Some(root_id.as_str())); assert!( @@ -167,7 +167,7 @@ fn test_loop_run_closes_submitted_round_and_reflects_done_work() -> common::Test } #[test] -fn test_loop_run_closes_blocked_round_as_paused_then_honors_max_rounds() -> common::TestResult { +fn test_loop_run_closes_blocked_round_as_paused_then_opens_next_round() -> common::TestResult { let (temp_dir, date) = init_project_with_date()?; let root_id = format!("WI-{date}-001"); let loop_id = loop_id(&date, 1); @@ -210,9 +210,15 @@ fn test_loop_run_closes_blocked_round_as_paused_then_honors_max_rounds() -> comm let output = run_dynamic_commands(temp_dir.path(), &[loop_run(&loop_id)])?; assert!( - output.contains(&format!("Failed loop {loop_id}")), + output.contains(&format!("Opened round 2 for loop {loop_id}")), "{output}" ); - assert!(output.contains("maximum rounds reached (1)"), "{output}"); + let state_toml = fs::read_to_string( + temp_dir + .path() + .join(format!(".govctl/loops/{loop_id}/state.toml")), + )?; + assert!(state_toml.contains("current_round = 2"), "{state_toml}"); + assert_eq!(loop_item_round_count(&state_toml, &root_id)?, 2); Ok(()) } diff --git a/tests/loop_tests/mod.rs b/tests/loop_tests/mod.rs index 28c2143e..b331dd28 100644 --- a/tests/loop_tests/mod.rs +++ b/tests/loop_tests/mod.rs @@ -5,9 +5,9 @@ mod surface_cases; use crate::common; use crate::common::loop_helpers::{ append_required_guard, assert_schema_rejects, loop_id, loop_item_round_count, loop_item_status, - loop_list, loop_resume, loop_run, loop_run_target, loop_run_with_max_rounds, loop_show, - loop_start, loop_start_with_id, loop_start_with_id_dry_run, read_round_record, - submit_round_summary, validate_toml_against_schema, write_guard, + loop_list, loop_resume, loop_run, loop_run_target, loop_show, loop_start, loop_start_with_id, + loop_start_with_id_dry_run, read_round_record, submit_round_summary, + validate_toml_against_schema, write_guard, }; use crate::common::{ command, init_project, init_project_with_date, run_dynamic_commands, work_add_acceptance, diff --git a/tests/loop_tests/scope.rs b/tests/loop_tests/scope.rs index 9aba580f..48d5830d 100644 --- a/tests/loop_tests/scope.rs +++ b/tests/loop_tests/scope.rs @@ -1,8 +1,8 @@ use crate::common; use crate::common::loop_helpers::{ loop_add_field, loop_add_wi, loop_add_work, loop_id, loop_item_round_count, loop_item_status, - loop_item_table, loop_remove_wi, loop_remove_work, loop_replan, loop_resolved, - loop_run_with_max_rounds, loop_start_with_id, loop_work, + loop_item_table, loop_remove_wi, loop_remove_work, loop_replan, loop_resolved, loop_run, + loop_start_with_id, loop_work, }; use crate::common::{ init_project_with_date, run_dynamic_commands, work_add_acceptance, work_add_dependency, @@ -134,7 +134,7 @@ fn test_loop_scope_add_remove_and_replan_preserve_current_state() -> common::Tes work_new("Original"), work_add_acceptance(&original_id, "add: unfinished"), loop_start_with_id(&loop_id, &[&original_id]), - loop_run_with_max_rounds(&loop_id, "2"), + loop_run(&loop_id), work_new("Dependency"), work_new("New root"), work_add_dependency(&new_root_id, &new_dependency_id), @@ -218,8 +218,7 @@ fn test_loop_run_rejects_stale_dependency_plan_until_replanned() -> common::Test )?; assert!(setup_output.contains("exit: 0"), "{setup_output}"); - let stale_output = - run_dynamic_commands(temp_dir.path(), &[loop_run_with_max_rounds(&loop_id, "2")])?; + let stale_output = run_dynamic_commands(temp_dir.path(), &[loop_run(&loop_id)])?; assert!(stale_output.contains("error[E1201]"), "{stale_output}"); assert!( stale_output.contains(&format!("Loop '{loop_id}' is stale")), @@ -232,10 +231,7 @@ fn test_loop_run_rejects_stale_dependency_plan_until_replanned() -> common::Test let repaired_output = run_dynamic_commands( temp_dir.path(), - &[ - loop_replan(&loop_id), - loop_run_with_max_rounds(&loop_id, "2"), - ], + &[loop_replan(&loop_id), loop_run(&loop_id)], )?; assert!( repaired_output.contains(&format!("Replanned loop {loop_id}")), diff --git a/tests/loop_tests/surface_cases/listing.rs b/tests/loop_tests/surface_cases/listing.rs index 7d7501ba..d969ceb2 100644 --- a/tests/loop_tests/surface_cases/listing.rs +++ b/tests/loop_tests/surface_cases/listing.rs @@ -120,7 +120,7 @@ fn test_loop_list_filters_resumable_aliases_and_limit() -> common::TestResult { work_new("Paused"), work_add_acceptance(&paused_root, "add: waiting"), loop_start_with_id(&paused_loop, &[&paused_root]), - loop_run_with_max_rounds(&paused_loop, "2"), + loop_run(&paused_loop), ], )?; assert!(setup_output.contains("exit: 0"), "{setup_output}"); @@ -136,7 +136,7 @@ fn test_loop_list_filters_resumable_aliases_and_limit() -> common::TestResult { let setup_output = run_dynamic_commands( temp_dir.path(), &[ - loop_run_with_max_rounds(&paused_loop, "2"), + loop_run(&paused_loop), work_new_active("Completed"), work_add_acceptance(&completed_root, "add: ready"), work_tick_acceptance_done(&completed_root, "ready"), diff --git a/tests/loop_tests/surface_cases/validation.rs b/tests/loop_tests/surface_cases/validation.rs index 34861a60..652f4c8b 100644 --- a/tests/loop_tests/surface_cases/validation.rs +++ b/tests/loop_tests/surface_cases/validation.rs @@ -24,6 +24,34 @@ fn test_loop_start_rejects_plain_text_loop_id() -> common::TestResult { Ok(()) } +#[test] +fn test_loop_run_rejects_removed_max_rounds_flag() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + let root_id = format!("WI-{date}-001"); + let loop_id = loop_id(&date, 1); + + let output = run_dynamic_commands( + temp_dir.path(), + &[ + work_new("Root"), + loop_start_with_id(&loop_id, &[&root_id]), + command(&["loop", "run", &loop_id, "--max-rounds", "2"]), + ], + )?; + + assert!( + output.contains("unexpected argument '--max-rounds'"), + "{output}" + ); + assert!( + !temp_dir + .path() + .join(format!(".govctl/loops/{loop_id}/rounds/round-001.toml")) + .exists() + ); + Ok(()) +} + #[test] fn test_loop_schemas_reject_invalid_calendar_dates() -> common::TestResult { let temp_dir = init_project()?; @@ -45,7 +73,6 @@ round_count = 0 [round] loop_id = "LOOP-2026-02-31-001" round_number = 1 -max_rounds = 1 status = "open" work = ["WI-2026-02-28-001"] diff --git a/tests/snapshots/test_lifecycle__bump_change_does_not_clear_pending_amendment.snap b/tests/snapshots/test_lifecycle__bump_change_does_not_clear_pending_amendment.snap new file mode 100644 index 00000000..4c192981 --- /dev/null +++ b/tests/snapshots/test_lifecycle__bump_change_does_not_clear_pending_amendment.snap @@ -0,0 +1,54 @@ +--- +source: tests/lifecycle_tests/rfc_cases/bump.rs +expression: snapshot +--- +$ govctl rfc new Test RFC +Created RFC: gov/rfc/RFC-0001/rfc.toml + Clauses dir: gov/rfc/RFC-0001/clauses +exit: 0 + +$ govctl clause new RFC-0001:C-TEST Test Clause -s Specification -k normative +Created clause: gov/rfc/RFC-0001/clauses/C-TEST.toml + Added to section 'Specification', path: clauses/C-TEST.toml +exit: 0 + +$ govctl clause edit RFC-0001:C-TEST --text Original normative behavior. +Updated clause: RFC-0001:C-TEST +exit: 0 + +$ govctl rfc finalize RFC-0001 normative + Set C-TEST.since = 0.1.0 +Finalized RFC-0001 to status: normative +exit: 0 + +$ govctl rfc bump RFC-0001 --patch --summary Establish baseline +Bumped RFC-0001 to 0.1.1 +exit: 0 + +$ govctl clause edit RFC-0001:C-TEST --text Updated normative behavior. +Updated clause: RFC-0001:C-TEST +exit: 0 + +$ govctl rfc bump RFC-0001 --change Added release note +Added change to RFC-0001 v0.1.1: Added release note +exit: 0 + +$ govctl rfc list +┌───────────┬─────────┬───────────┬───────┬──────────┐ +│ RFC ┆ Version ┆ Status ┆ Phase ┆ Title │ +╞═══════════╪═════════╪═══════════╪═══════╪══════════╡ +│ RFC-0001* ┆ 0.1.1 ┆ normative ┆ spec ┆ Test RFC │ +└───────────┴─────────┴───────────┴───────┴──────────┘ +exit: 0 + +$ govctl rfc bump RFC-0001 --patch --summary Release amendment +Bumped RFC-0001 to 0.1.2 +exit: 0 + +$ govctl rfc list +┌──────────┬─────────┬───────────┬───────┬──────────┐ +│ RFC ┆ Version ┆ Status ┆ Phase ┆ Title │ +╞══════════╪═════════╪═══════════╪═══════╪══════════╡ +│ RFC-0001 ┆ 0.1.2 ┆ normative ┆ spec ┆ Test RFC │ +└──────────┴─────────┴───────────┴───────┴──────────┘ +exit: 0 diff --git a/tests/snapshots/test_lifecycle__bump_rejects_changelog_only_after_signature_baseline.snap b/tests/snapshots/test_lifecycle__bump_rejects_changelog_only_after_signature_baseline.snap new file mode 100644 index 00000000..5a016077 --- /dev/null +++ b/tests/snapshots/test_lifecycle__bump_rejects_changelog_only_after_signature_baseline.snap @@ -0,0 +1,32 @@ +--- +source: tests/lifecycle_tests/rfc_cases/bump.rs +expression: snapshot +--- +$ govctl rfc new Test RFC +Created RFC: gov/rfc/RFC-0001/rfc.toml + Clauses dir: gov/rfc/RFC-0001/clauses +exit: 0 + +$ govctl rfc finalize RFC-0001 normative +Finalized RFC-0001 to status: normative +exit: 0 + +$ govctl rfc bump RFC-0001 --patch --summary Establish baseline +Bumped RFC-0001 to 0.1.1 +exit: 0 + +$ govctl rfc bump RFC-0001 --change Added changelog note +Added change to RFC-0001 v0.1.1: Added changelog note +exit: 0 + +$ govctl rfc bump RFC-0001 --patch --summary Changelog-only bump +error[E0113]: RFC version bump requires RFC or clause content changes since the last bump (RFC-0001) +exit: 1 + +$ govctl rfc list +┌──────────┬─────────┬───────────┬───────┬──────────┐ +│ RFC ┆ Version ┆ Status ┆ Phase ┆ Title │ +╞══════════╪═════════╪═══════════╪═══════╪══════════╡ +│ RFC-0001 ┆ 0.1.1 ┆ normative ┆ spec ┆ Test RFC │ +└──────────┴─────────┴───────────┴───────┴──────────┘ +exit: 0 diff --git a/tests/snapshots/test_lifecycle__bump_rejects_empty_bump_after_signature_baseline.snap b/tests/snapshots/test_lifecycle__bump_rejects_empty_bump_after_signature_baseline.snap new file mode 100644 index 00000000..b4852797 --- /dev/null +++ b/tests/snapshots/test_lifecycle__bump_rejects_empty_bump_after_signature_baseline.snap @@ -0,0 +1,28 @@ +--- +source: tests/lifecycle_tests/rfc_cases/bump.rs +expression: snapshot +--- +$ govctl rfc new Test RFC +Created RFC: gov/rfc/RFC-0001/rfc.toml + Clauses dir: gov/rfc/RFC-0001/clauses +exit: 0 + +$ govctl rfc finalize RFC-0001 normative +Finalized RFC-0001 to status: normative +exit: 0 + +$ govctl rfc bump RFC-0001 --patch --summary Establish baseline +Bumped RFC-0001 to 0.1.1 +exit: 0 + +$ govctl rfc bump RFC-0001 --patch --summary Empty bump +error[E0113]: RFC version bump requires RFC or clause content changes since the last bump (RFC-0001) +exit: 1 + +$ govctl rfc list +┌──────────┬─────────┬───────────┬───────┬──────────┐ +│ RFC ┆ Version ┆ Status ┆ Phase ┆ Title │ +╞══════════╪═════════╪═══════════╪═══════╪══════════╡ +│ RFC-0001 ┆ 0.1.1 ┆ normative ┆ spec ┆ Test RFC │ +└──────────┴─────────┴───────────┴───────┴──────────┘ +exit: 0