Prevent output publication after cancellation

This commit is contained in:
2026-08-01 21:49:18 +00:00
parent b67fae886e
commit 28bdc04fba
3 changed files with 330 additions and 641 deletions

View File

@@ -1,416 +1,162 @@
# Stateless Execution Implementation Plan # Stateless Execution Implementation Plan
Status: Complete. Status: Ready for remediation implementation (Stages 14-18).
## Purpose ## Purpose
This plan implements the accepted [Stateless Execution This plan implements the accepted [Stateless Execution
Roadmap](ephemeral-state.md). That roadmap is authoritative for product intent, Roadmap](ephemeral-state.md). The roadmap is authoritative for product intent,
policy choices, and the desired end state. This document owns implementation policy choices, and the desired end state. This document records the completed
order, concrete code changes, and verification gates. migration and defines the remaining remediation work discovered during
post-implementation review.
The target is a Weatherreporter process whose ordinary invocations require no Stages 1-13 are complete and are summarized below. Implement Stages 14-18 in
prior application state and leave only operator-owned Markdown outputs. Local numeric order. Each remaining stage is intentionally scoped for one focused
Recent Changes, the managed workspace, historical artifacts and metadata, and implementation prompt.
the `inspect` command family are removed. Explicit secure Promptkit debug
capture remains operator-owned.
## Implementation Rules ## Implementation Rules
1. Implement the stages in numeric order. Do not merge or release an 1. Keep the repository buildable and `go test ./...` passing after every stage.
intermediate stage as a completed stateless-execution feature. 2. Follow all policies under `docs/policy/`, especially the architecture,
2. Keep the repository buildable and `go test ./...` passing at every stage. documentation, and risk-based testing requirements.
Use focused tests while iterating, then run the stage's listed commands. 3. Preserve the accepted stateless contracts: no application-owned durable
3. Follow the architecture, documentation, and testing policies under workspace, no local Recent Changes comparison, one operator-owned output per
`docs/policy/`. Delete tests that protect intentionally removed contracts; successful report, atomic publication, and explicit-only prompt debug
do not mechanically rewrite them to preserve obsolete structures. capture.
4. Do not retain compatibility shims for `workspace`, `recent_change`, 4. Preserve prompt and profile inspection before weather collection, exact
historical metadata, inspection commands, prompt artifact paths, or the prompt versions, structured-output validation, repository-owned rendering,
`recent_changes` prompt field. Strict configuration loading should reject deterministic report periods, batch membership, and Distributor upload of
removed fields. published Markdown files.
5. Preserve prompt inspection before weather collection, batch-wide prompt 5. Keep default tests deterministic and offline. Do not contact live Weather
inspection before collection, exact profile selection, structured-output API, Promptkit provider, or Distributor services.
validation, repository-owned rendering, deterministic report periods, 6. Use behavior-focused regression tests at the narrowest stable boundary.
batch membership, safe error classification, and Distributor bundle paths. Avoid tests that merely encode private helper structure or call order.
6. Preserve the existing `--llm-debug-dir PATH` interface and security 7. Update canonical documentation in the same stage as any user-visible or
properties. Do not introduce an implicit debug location or a general architectural behavior change. Do not create release notes until a release
logging subsystem. version is selected.
7. Do not contact live Weather API, Promptkit provider, or Distributor 8. Do not modify or reinterpret the Accepted stateless-execution ADR. A changed
services in repository tests. architectural decision would require a new ADR; the remaining work does not
8. Do not create release notes until a release version is selected. The require one.
durable documentation stage must record the compatibility and operator
actions that future release notes need to summarize.
## Target Contracts ## Fixed Remediation Decisions
These decisions are fixed for implementation: The following decisions are complete and require no further product input:
- `generate` always writes exactly one operator-owned Markdown file. - A canceled generation must not publish or replace its selected output if
- `--out PATH` selects that file. Without `--out`, the destination is in the cancellation is observable before atomic publication begins.
working directory captured at invocation start: - A batch must validate every planned report destination before starting any
`daily-YYYY-MM-DD.md`, `today.md`, `tomorrow.md`, or `hourly.md`. report's Promptkit execution or output publication. Dynamic Daily
- `run` writes each successful report beneath `--out-dir PATH`, or beneath the destinations may be validated after weather collection and batch planning,
captured working directory when the flag is omitted. Daily batch filenames because those dates are not known earlier.
remain date-qualified. - `BatchResult.Total`, `Succeeded`, and `Failed` count reports only. A batch
- CLI-resolved output paths are cleaned absolute paths. App-level action notification failure changes overall batch status and exit behavior through
requests receive an absolute working directory plus an optional operator the top-level notification result; it does not increment `Failed`.
override and reject invalid or empty resolved destinations before weather - Batch report items do not expose per-report notification fields because batch
collection. reports deliberately suppress per-report notification.
- A generation publishes its selected output atomically. Failure or - Completed roadmap prose must distinguish the former persistent architecture
cancellation before publication leaves an existing destination unchanged. from current stateless behavior, and repository hygiene must no longer hide
- Single-report notification occurs after local output publication. Batch an accidentally recreated root-level `workspace` directory.
notification occurs only after every planned report succeeds. Distributor
reads the selected output files; there is no second managed copy.
- Action summaries retain identity, valid period, safe effective
profile/backend/model information, source warnings, final output path,
optional debug path, notification status, and a safe error. They contain no
historical or transient artifact paths.
- RunIDs remain active correlation and idempotency values only.
- The prompt data-package schema becomes
`weatherreporter.data_package.v4`, with no `recent_changes` member. All four
embedded prompts become exact version `2.0.0`.
- Ordinary execution creates no `workspace` tree, metadata, receipts,
snapshots, data packages, generated-text intermediates, render-context
files, notification artifacts, or run index.
## Stage 1: Record The Stateless Architecture Decision ## Completed Stages
### Stage 1: Record The Stateless Architecture Decision — Complete
Added the Accepted ADR recording the stateless transformation pipeline,
operator-owned output boundary, removal of local comparison and historical
inspection, atomic publication, and explicit debug-capture exception.
### Stage 2: Remove Recent Changes From The Prompt Contract — Complete
Removed Recent Changes from prompt input, advanced the data package to
`weatherreporter.data_package.v4`, and advanced all four embedded prompts to
exact version `2.0.0`.
### Stage 3: Delete Dormant Local Comparison Policy — Complete
Removed the local comparison implementation and `recent_change` configuration;
strict configuration loading now rejects the obsolete field.
### Stage 4: Establish The Operator-Owned Output Contract — Complete
Made every successful report publish one atomic Markdown output, added
current-working-directory defaults and explicit destination overrides, and
centralized report output naming.
### Stage 5: Separate Explicit Debug Capture From State — Complete
Moved secure opt-in Promptkit diagnostics into `internal/promptdebug` without
introducing implicit diagnostics or ordinary artifact persistence.
### Stage 6: Remove Notification Persistence — Complete
Removed notification receipts and changed Distributor delivery to consume the
published operator-owned output from the active workflow.
### Stage 7: Replace The Persisted Generation Workflow — Complete
Converted single and batch generation to in-memory orchestration and removed
ordinary persistence of snapshots, prompt packages, generated text, render
contexts, metadata, and managed reports.
### Stage 8: Remove Historical Inspection And Prior Compatibility — Complete
Removed the `inspect` command family, historical lookup, prior-run selection,
and metadata compatibility surfaces.
### Stage 9: Delete The Workspace And State Subsystem — Complete
Removed `internal/state`, workspace configuration, state-only helpers, and
legacy artifact models. Strict loading rejects the obsolete `workspace` stanza.
### Stage 10: Consolidate Stateless Behavioral Coverage — Complete
Replaced state-oriented fixtures with focused offline coverage of generation,
batching, atomic output, partial success, notification ordering, prompt
inspection, summaries, and explicit debug capture.
### Stage 11: Publish User, Operator, And Policy Documentation — Complete
Updated the CLI, operations, architecture, configuration, and documentation
policy owners for stateless operation and manual legacy-workspace cleanup.
### Stage 12: Reconcile Internal And Integration Documentation — Complete
Updated focused internal and integration documents to describe in-memory
orchestration, published-output notification sources, and the absence of
historical inspection.
### Stage 13: Run The Repository Exit Gate — Complete
Ran the complete offline test, race, vet, build, help, formatting, stale-string,
and documentation review gates and marked the initial migration implemented.
## Stage 14: Prevent Publication After Cancellation
### Goal ### Goal
Create the durable decision record before changing the architecture, without Close the cancellation window between successful Promptkit execution and
describing unimplemented behavior as current behavior elsewhere. atomic output publication.
### Work ### Work
1. Create `docs/adr/` if it does not exist and add 1. In the report workflow, check the active context after validation and
`docs/adr/0001-stateless-execution.md` as an Accepted ADR using the format rendering have completed and immediately before calling the atomic file
required by `docs/policy/documentation.md`. writer.
2. Record: 2. If `ctx.Err()` is `context.Canceled`, wrap it in a
- why run-addressed provenance conflicts with ephemeral weather reports; `promptexec.Canceled` error; if it is `context.DeadlineExceeded`, wrap it in
- removal of local Recent Changes rather than retention of state for it; a `promptexec.DeadlineExceeded` error. Return that classified error through
- the stateless transformation pipeline and operator-owned output boundary; the existing report-error surface. Preserve `errors.Is` behavior for the
- atomic output and notification ordering; underlying context error; do not collapse either case into a generic
- explicit debug capture as the only retained diagnostic-file exception; rendering or output error.
- removal of inspection and backward-compatible workspace decoding; 3. Do not remove an output that was published before a later cancellation.
- the upstream Weather API path for future forecast comparison; and Cancellation observed after publication remains subject to the existing
- alternatives: the former bounded-current-state design, time-based notification and result behavior.
retention, and bounded run history. 4. Add a focused app regression test whose fake executor returns valid output
3. Link the ADR to the feature roadmap for scope, while keeping the ADR focused but cancels the context before returning. Prove that generation fails and a
on durable rationale rather than implementation stages. pre-existing destination remains byte-for-byte unchanged. This test must not
4. Do not update current-state architecture or user documentation in this depend on the fake executor voluntarily returning a cancellation error.
stage. 5. Ensure the same workflow check protects every single-report and batch item;
do not add duplicate cancellation logic in CLI or batch orchestration.
### Tests
Run:
```sh
git diff --check
```
Verify every repository-relative ADR link resolves.
### Exit Gate
An Accepted ADR records the exact decision and consequences; no production
behavior or current-state documentation has changed.
## Stage 2: Remove Recent Changes From The Prompt Contract
### Goal
Stop generating or sending local comparison results while the existing
workspace remains temporarily available for unrelated artifacts.
### Work
1. Remove `RecentChanges` from `promptinput.BuildRequest` and
`promptinput.Package`, delete the prompt-input `RecentChanges` wrapper, and
remove the `internal/changes` dependency from `internal/promptinput`.
2. Advance `promptinput.SchemaVersion` from
`weatherreporter.data_package.v3` to `weatherreporter.data_package.v4`.
Update marshal, load, validation, round-trip, ordering, and fixture tests so
v4 has no `recent_changes` key and v3 is rejected.
3. In app orchestration, stop finding a prior snapshot, loading it, invoking a
comparator, or placing Recent Changes in `ReportResult` or prompt input.
Continue building the current module snapshot because it is still needed
in memory for prompt input and rendering, and may still be persisted by the
transitional workflow.
4. Remove Recent Changes fields and assertions from app workflow fakes and
tests. Delete prior-run workflow cases whose only purpose was local change
detection.
5. Change all four report-definition prompt versions and embedded prompt YAML
versions from `1.1.0` to `2.0.0`.
6. Remove the common system-prompt reference to supplied recent changes.
Review every prompt body to ensure none instructs the model to infer or
discuss changes from an absent comparison field.
7. Update prompt-asset and adapter integration fixtures to use exact version
`2.0.0`. Do not change generated-text output schemas or report templates;
neither directly depends on Recent Changes.
### Tests
Run:
```sh
go test ./internal/promptinput ./internal/promptassets ./internal/report
go test ./internal/app ./internal/adapters/promptkit
go test ./...
git diff --check
```
Add or retain focused tests proving that serialized v4 packages omit
`recent_changes` entirely and every embedded prompt resolves at `2.0.0`.
### Exit Gate
No generated prompt package or active app workflow contains Recent Changes,
while report generation and the still-transitional persistence workflow
continue to function.
## Stage 3: Delete Dormant Local Comparison Policy
### Goal
Remove comparison code and configuration that no active report workflow uses.
### Work
1. Delete `internal/changes` and its tests.
2. Remove `RecentChangeConfig`, `Config.RecentChange`, its defaults,
validation, YAML handling, test fixtures, and maintained example values.
3. Add a strict-loading regression test showing that a top-level
`recent_change:` stanza is now rejected as unknown. Do not silently ignore
the obsolete field.
4. Remove stale Recent Changes imports, helpers, test builders, and comments
throughout app, config, prompt input, facts, forecast, briefing, generated
text, and report code.
5. Leave report comparison declarations and state prior-lookup code only where
the still-supported historical `inspect prior` path requires them. They are
removed with inspection in Stage 8; do not invent a new consumer.
### Tests
Run:
```sh
go test ./internal/config ./internal/app ./internal/promptinput
go test ./...
git diff --check
```
### Exit Gate
No active generation code or configuration surface implements local change
detection, and the legacy field fails strict configuration loading.
## Stage 4: Establish The Operator-Owned Output Contract
### Goal
Make every successful action select and atomically write its final output even
while the old managed workspace still exists behind the workflow.
### Work
1. Add an injectable `WorkingDir string` to `cli.Runner`. When empty,
production calls `os.Getwd` once per action; tests supply an absolute
temporary directory without calling `os.Chdir`.
2. Pass the captured absolute working directory through `GenerateRequest` and
`BatchRequest`. Resolve relative `--out` and `--out-dir` values against it
and clean the resulting absolute paths.
3. Centralize output filenames rather than duplicating them in CLI and app:
- Daily uses `daily-YYYY-MM-DD.md` with the resolved valid-period start in
the effective report timezone;
- Today, Tomorrow, and Hourly use `today.md`, `tomorrow.md`, and
`hourly.md`;
- dynamic Daily batch items use the same date-qualified rule.
Rename `report.Definition.BatchOutputName` to `OutputName` and rename
app-internal “output copy” fields/helpers to “output” terminology. Preserve
the external Distributor template variable `batch_output_name` and populate
it from the selected output filename.
4. When `--out` is absent, resolve the single-report default after the report
period is known. When `--out-dir` is absent, use the captured working
directory for all batch items.
5. Require a non-empty absolute final output path before collection. Reject a
filesystem root or a destination that resolves to a directory. Let atomic
publication create missing parent directories for a valid file path.
6. Continue using `fileutil.WriteFileAtomic` or an equivalently narrow helper.
An existing destination may be replaced only after the complete new report
has been written and closed successfully.
7. Change help and flag descriptions so `--out` and `--out-dir` select report
destinations rather than “extra copies.”
8. Update app and CLI tests for defaults, explicit absolute and relative
overrides, Daily date naming, batch naming, existing-file preservation on
failure, and absolute summary paths.
### Tests
Run:
```sh
go test ./internal/report ./internal/fileutil
go test ./internal/app ./internal/cli
go test ./...
git diff --check
```
### Exit Gate
Every successful single or batch item has one selected operator output path,
omitted flags use the captured working directory, and failed publication
cannot corrupt an existing destination.
## Stage 5: Separate Explicit Debug Capture From State
### Goal
Preserve secure opt-in Promptkit diagnostics without retaining a dependency on
the package that owns the obsolete workspace.
### Work
1. Move `internal/state/debug_writer.go` and its focused tests to
`internal/promptdebug`. Move rather than duplicate the implementation.
2. Preserve:
- empty-path disablement without filesystem access;
- absolute non-root destination validation;
- safe report/date/RunID path segments;
- directory mode `0700` and file mode `0600`;
- endpoint sanitization and parameter redaction;
- credential exclusion; and
- preparation-callback failure preventing provider execution.
3. Update app imports and debug tests to use the new package. Do not move
ordinary artifacts into the debug package.
4. Leave the transitional `DataPackagePath` execution fields intact while
persisted receipts still validate them. Stage 7 removes those fields with
their final consumer.
### Tests
Run:
```sh
go test ./internal/promptdebug ./internal/promptexec
go test ./internal/adapters/promptkit ./internal/app ./internal/cli
go test ./...
git diff --check
```
### Exit Gate
Debug capture is independent of `internal/state`, and its security and
preparation-callback behavior are unchanged.
## Stage 6: Remove Notification Persistence
### Goal
Make Distributor delivery an active-workflow result that consumes final output
files and writes no Weatherreporter receipt.
### Work
1. Refactor `notifyReport` and `notifyBatch` so they do not accept a
`state.Store` and do not call any `SaveDistributorNotification` operation.
Delete notification-artifact conversion and persistence helpers from app.
2. For single reports, build the notification request from the resolved report
identity, active RunID/timestamps, and selected `OutputPath`. Remove
“managed report” terminology from code and safe errors.
3. For batches, use each successful item's `OutputPath` as the Distributor
source. Preserve bundle-path rendering, duplicate bundle-path rejection,
batch inclusion metadata, all-success gating, and one batch upload.
4. Remove `NotificationPath` and batch notification `Path` fields from app
results and CLI summaries. Preserve remote run, pipeline, bundle,
idempotency, status, timing, and safe-error fields.
5. Preserve ordering:
- local atomic publication precedes a single notification;
- a notification failure leaves the output and returns a failure;
- batch notification is skipped after any report failure;
- successful batch items remain successful when only batch notification
fails.
6. Delete tests that assert notification receipt files. Replace them with
focused interaction tests proving source paths, ordering, skip behavior,
one-call behavior, and failure propagation.
### Tests
Run:
```sh
go test ./internal/app ./internal/adapters/distributor
go test ./internal/cli
go test ./...
git diff --check
```
### Exit Gate
Distributor behavior uses operator outputs and returns active status without
creating notification artifacts or depending on state.
## Stage 7: Replace The Persisted Generation Workflow
### Goal
Convert the shared single-report generation path into a state-free in-memory
pipeline used by both `generate` and `run`.
### Work
1. Replace the persistence-oriented `promptReportWorkflow` with a cohesive
workflow that:
- initializes a safe partial `ReportResult` from `report.Resolved`;
- builds collected and derived facts;
- builds the module snapshot in memory;
- builds `briefing.Metadata` and the v4 prompt package in memory;
- marshals YAML in memory;
- executes Promptkit with the existing preparation callback;
- writes requested preparation and execution debug captures only;
- requires a completed passed Promptkit validation;
- validates and decodes generated text;
- builds the render context and renders Markdown in memory;
- atomically writes the selected output; and
- notifies only after publication when notification is enabled.
2. Delete all ordinary calls that save module snapshots, data packages,
prompt preparation/execution, raw output, validated output, render context,
metadata, or managed reports.
3. Remove `DataPackagePath` from `promptexec.ExecuteRequest`,
`promptexec.Preparation`, `promptexec.Execution`, and debug artifact
mappings. Change the Promptkit adapter input from `InlineWithURI` using a
filesystem path to `promptkit.Inline`; the input remains copied inline and
its hashes remain available. Advance the changed debug schema identifiers
from `weatherreporter.prompt_preparation_debug.v1` and
`weatherreporter.prompt_execution_debug.v1` to their corresponding `v2`
identifiers without adding compatibility readers.
4. Preserve failure classification and context. A debug-write error remains an
invalid-configuration failure and the preparation callback must still stop
the provider when it fails. Operational provider failure and completed
validation rejection remain distinct.
5. Redesign `ReportResult` as an active result rather than an artifact index.
Retain only:
- report ID/name, prompt ID/version, RunID, generation time, timezone, and
valid period;
- selected profile ID, backend ID, and model name once inspected;
- source warnings;
- validation status when execution reached validation;
- final `OutputPath` once publication succeeds;
- `LLMDebugPath` when explicitly created; and
- notification result.
Do not retain module snapshots, prompt packages, generated bodies, render
contexts, state metadata, or transient paths in the result.
6. Make the CLI generate summary map directly from this active result and
remove `ReportPath`, `MetadataPath`, `DataPackagePath`,
`PreparationPath`, `ExecutionPath`, `GeneratedTextRawPath`,
`GeneratedTextPath`, `RenderContextPath`, and `NotificationPath`.
7. Apply the same safe identity/profile/warning/output fields to
`BatchReportResult`. Keep batch failure accounting and sequential execution.
8. Remove `Store` from `GenerateRequest` and `BatchRequest`. Remove
`defaultStore` and all persistence-only finalization helpers in this stage.
9. Keep app tests behavioral. Replace receipt/checkpoint assertions with a
compact matrix covering success, each consequential failure boundary,
partial results, inspection-before-collection, output atomicity, effective
profile propagation, debug capture, and notification sequencing.
### Tests ### Tests
@@ -418,142 +164,98 @@ Run:
```sh ```sh
go test ./internal/app go test ./internal/app
go test ./internal/cli ./internal/adapters/promptkit
go test ./... go test ./...
go test -race ./internal/app
git diff --check git diff --check
``` ```
### Exit Gate ### Exit Gate
Single and batch generation share a state-free pipeline, ordinary success Cancellation observable before publication prevents the atomic write, the
leaves only selected outputs, and no app production path imports selected destination remains unchanged, and cancellation identity is retained.
`internal/state`.
## Stage 8: Remove Historical Inspection And Prior Compatibility ## Stage 15: Preflight Every Planned Batch Output
### Goal ### Goal
Delete public and internal surfaces whose only purpose is reading prior runs. Ensure a structural destination error cannot appear midway through a batch
after earlier reports have already been published.
### Work ### Work
1. Delete `internal/app/inspect.go` and all inspection request/result types and 1. After weather collection and `planBatchRun` have produced the complete
focused tests. dynamic report set, resolve and validate the output path for every planned
2. Remove the top-level `inspect` dispatch, parsers, `--limit` handling, report before executing the first report prompt.
command tables, help text, and CLI tests for: 2. Store each validated absolute output path with its planned report for use by
`reports`, `metadata`, `modules`, `data-package`, `prior`, and `sources`. the generation loop. Do not recompute or revalidate destinations inside the
`inspect` becomes an unknown command; do not retain a tombstone command. loop.
3. Remove report `ComparisonStrategy`, comparison constants, 3. Treat any invalid path, including an existing directory at a report's final
`CompatiblePriorIDs`, compatibility helpers, and registry tests. filename, as a batch preflight error. Return before Promptkit execution and
4. Remove `FindPriorSnapshot`, `PriorSnapshot`, prior compatibility helpers, before publication of any batch item. Weather collection may already have
and their tests from the still-transitional state package so it continues occurred because eligible Daily dates depend on collected coverage.
to compile until full deletion. 4. Keep missing parent-directory creation in the atomic publication helper;
5. Remove state load/list methods that existed only for CLI inspection where preflight must not create report files or introduce a new managed directory
doing so is clean and local. Do not spend effort preserving a smaller lifecycle.
historical reader that Stage 9 will delete. 5. Add a focused app test using a real temporary output directory where a later
6. Ensure `generate` and `run` parsing, help, summaries, and error behavior planned filename, such as `tomorrow.md`, already exists as a directory.
remain intact. Assert that prompt inspection still occurs before collection, the executor's
prompt-execution method is never called, and no earlier report output is
created or replaced.
6. Retain the existing independent report-failure behavior after successful
preflight: a provider, validation, rendering, or publication failure for one
report remains an item failure, later items continue, and successful outputs
remain available.
### Tests ### Tests
Run: Run:
```sh ```sh
go test ./internal/report ./internal/app ./internal/cli ./internal/state go test ./internal/app
go test ./... go test ./internal/fileutil
go run ./cmd/weatherreporter --help
git diff --check
```
Assert that `inspect` is rejected as an unknown command and no inspection
subcommand appears in help.
### Exit Gate
No public or app-level historical inspection contract remains, and report
definitions contain no prior-run compatibility policy.
## Stage 9: Delete The Workspace And State Subsystem
### Goal
Remove the now-unreferenced durable-state implementation and configuration.
### Work
1. Delete the remaining `internal/state` package and all of its tests. Do not
preserve metadata structs, artifact schemas, validators, path builders,
filesystem stores, or compatibility readers in another package.
2. Remove `WorkspaceConfig`, `Config.Workspace`, workspace defaults,
validation, YAML fixtures, and test helpers.
3. Add strict-loading coverage showing that a top-level `workspace:` stanza is
rejected as unknown. Existing legacy configuration is intentionally not
accepted.
4. Remove workspace and Recent Changes sections from maintained examples.
Keep every example complete, secret-free, and accepted by the production
loader.
5. Remove dead app code exposed by the deletion, including `FetchBundle`,
`FetchAndSaveBundle`, store helpers, artifact conversion helpers, copy
helpers, imports, and persistence-only test infrastructure. Neither fetch
helper currently has a production caller.
6. Keep state-independent atomic output helpers in `internal/fileutil`, update
their package comments to describe operator-owned outputs, and delete the
now-unused `CopyFileAtomic` helper.
7. Verify production Go code contains no import of `internal/state` and no
construction of a directory named `workspace`.
### Tests
Run:
```sh
go test ./internal/config ./internal/fileutil ./internal/app ./internal/cli
go test ./... go test ./...
git diff --check git diff --check
``` ```
### Exit Gate ### Exit Gate
`internal/state` and `workspace` configuration no longer exist, legacy fields Every selected batch destination is validated before any batch report executes,
fail strict loading, and normal execution has no application-owned durable and a destination collision cannot yield an unreported partial batch.
state mechanism.
## Stage 10: Consolidate Stateless Behavioral Coverage ## Stage 16: Separate Report Counts From Batch Notification Status
### Goal ### Goal
Review the rewritten suite as a whole and retain a lean set of tests that Restore coherent batch counters while preserving failed status and non-zero
protect the new risks without preserving deleted implementation choreography. exit behavior when the batch notification fails.
### Work ### Work
1. Audit app and CLI tests that were rewritten in earlier stages. Consolidate 1. Define and enforce the invariant
overlapping cases and delete helpers whose only value was constructing `Total == Succeeded + Failed == len(Reports)` after report execution.
stores, metadata, artifact paths, or prior snapshots. `Succeeded` and `Failed` count only report-item statuses.
2. Ensure durable offline coverage at the narrowest stable boundary for: 2. Remove the increment of `BatchResult.Failed` when `notifyBatch` returns an
- default and explicit absolute output selection; error. Preserve the failed top-level `BatchNotificationResult`, including
- relative override resolution from an injected working directory; its safe error and identity fields.
- Daily valid-date filenames and distinct multi-day batch outputs; 3. Update the `RunBatch` wrapper to return `BatchError` when either a report
- preservation of an existing destination on generation, rendering, failed or the top-level batch notification failed. Keep
output-write, or cancellation before publication; `RunBatchDetailed` returning the populated result according to its existing
- retention of the newly published output when notification fails; detailed-result contract.
- no output on pre-publication failure; 4. Retain CLI behavior in which the batch summary status is failed and the
- successful items surviving partial batch failure; command exits unsuccessfully for a batch notification failure, even though
- batch notification only after all outputs exist; all report counters show success.
- Distributor source and bundle paths; 5. Make `BatchError` use report counts only for report-failure wording and its
- inspection/profile validation before weather collection; existing notification-specific wording when the reports succeeded but the
- safe effective profile/backend/model and source warnings in summaries; notification failed.
- absence of historical/transient paths in JSON summaries; 6. Add focused app and CLI tests for a batch whose reports all publish
- no ordinary debug directory creation; successfully but whose batch notifier fails. Assert:
- secure explicit debug capture and credential redaction; and - `Total == Succeeded == len(Reports)` and `Failed == 0`;
- no default workspace or intermediate artifacts after repeated successes - every output remains present;
and failures. - the top-level notification status is `failed`;
3. Use real temporary directories and real internal rendering/file helpers. - the summary status is `failed`;
Fake only Weather API collection, Promptkit/provider execution, clocks, and - the returned error describes notification failure rather than claiming a
Distributor delivery. report failed; and
4. Avoid tests of private phase ordering unless the ordering is a stated - the action exits unsuccessfully.
external requirement. Do not replace deleted state tests with broad
filesystem snapshots.
### Tests ### Tests
@@ -561,156 +263,95 @@ Run:
```sh ```sh
go test ./internal/app ./internal/cli go test ./internal/app ./internal/cli
go test ./internal/fileutil ./internal/promptdebug
go test ./... go test ./...
git diff --check git diff --check
``` ```
### Exit Gate ### Exit Gate
The suite protects stateless product behavior and consequential failure Batch report counters are internally consistent, while notification failure
boundaries without retaining obsolete state-oriented fixtures or redundant still produces a failed summary, safe diagnostic, retained outputs, and
mock choreography. non-zero command result.
## Stage 11: Publish User, Operator, And Policy Documentation ## Stage 17: Remove Impossible Per-Report Notification State
### Goal ### Goal
Publish the user-visible, operational, and normative stateless contracts in Align batch result types and stderr output with the architecture in which
their canonical owners. per-report notification is suppressed and delivery is represented once at the
batch level.
### Work ### Work
1. Update `README.md` so the shortest useful command relies on the documented 1. Remove `NotificationStatus`, `NotificationRunID`,
current-directory output and links to canonical CLI and operations details. `NotificationPipelineID`, and `NotificationError` from
2. Update `docs/cli.md` with: `BatchReportResult`.
- removal of `inspect`; 2. Remove the unreachable copying of `ReportResult.Notification` into a batch
- default and explicit output behavior; item. Rename `copyBatchReportPaths` to reflect that it copies the current
- absolute `outputPath` summaries; safe report result fields rather than only paths, or replace it with an
- retained identity/profile/model/warning/debug/notification fields; and equally clear narrow helper.
- removal of historical artifact path fields. 3. Remove per-report notification formatting from `writeBatchStatus`. Keep the
3. Update `docs/config.md` and maintained examples to remove `workspace` and top-level `batchNotification` status line and the batch summary
`recent_change`. Keep Promptkit debug and notification configuration in `notification` object unchanged.
their existing canonical owners. 4. Search tests and documentation for the removed per-report fields. Delete
4. Rewrite `docs/operations.md` around operator-owned outputs, atomic stale assertions or descriptions rather than adding compatibility fields;
replacement, batch partial success, Distributor ordering, explicit debug no backward compatibility is required for this pre-release result cleanup.
capture, and precise manual legacy-workspace cleanup. Remove all inspection, 5. Retain single-report notification fields and behavior. This stage changes
metadata, receipt, recovery, and managed-workspace procedures. only batch report items.
5. Update `docs/policy/architecture.md` to make stateless execution,
in-memory processing, operator-owned atomic outputs, and state-free
notification normative. Remove `internal/state` and prior-comparison
ownership and persistence invariants.
6. Update `docs/policy/documentation.md` so Operations owns output lifecycle,
diagnosis, legacy cleanup, and explicit debug handling rather than physical
workspace and inspection contracts.
7. Update `docs/policy/testing.md` where its examples assign behavior to state
tests or name persistence/inspection as current app workflow contracts.
Preserve its general risk-based guidance.
8. Update `docs/development.md` package inventory and task guide for the
stateless package layout, removed inspection workflow, and operator-output
boundary. Route detailed subsystem work to its canonical internal or
integration document rather than duplicating it.
9. Leave `docs/releases/v0.9.0.md` unchanged as a historical record. Do not
create a new release note without a selected version. Confirm that the
future note must call out removed commands and fields, default outputs,
prompt contract changes, and manual legacy cleanup.
### Tests
Run:
```sh
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Check every changed repository-relative link, maintained YAML example, command,
flag, field, and output filename against executable sources.
### Exit Gate
User, operator, policy, development, and example documentation describe the
implemented stateless contract, and historical release notes remain unchanged.
## Stage 12: Reconcile Internal And Integration Documentation
### Goal
Bring maintainer-facing subsystem and external-contract documentation into
line with the implemented package boundaries without duplicating user-facing
contracts.
### Work
1. Delete `docs/internal/state.md` and `docs/internal/changes.md` and repair all
incoming links.
2. Update app orchestration internals for the in-memory workflow, partial
active results, atomic output, notification order, and batch isolation.
3. Update CLI internals for working-directory capture, destination resolution,
stateless summaries, and removal of inspection dispatch.
4. Update prompt-input internals for v4 without `recent_changes`, report
registry internals for output naming without comparison policy, and module
and generated-text internals only where their actual contracts changed.
5. Update Promptkit adapter internals and
`docs/integrations/promptkit.md` for inline input without a filesystem URI,
exact prompt version `2.0.0`, active safe provenance, and explicit debug
capture.
6. Update Distributor adapter and integration documents so source files are
operator outputs and notification state is not retained. Preserve the
remote API and bundle-path contract.
7. Review weather-data, collection, template, and other internal/integration
documents for stale references, but edit only documents whose owned
contracts changed.
8. Do not mark either roadmap implemented in this stage; Stage 13 owns that
status transition after the repository-wide gate. Do not turn either
roadmap into a second current-state reference.
### Tests ### Tests
Run: Run:
```sh ```sh
go test ./internal/app ./internal/cli
go test ./... go test ./...
git diff --check git diff --check
``` ```
Validate every changed repository-relative link and verify each named package,
schema, prompt version, field, and integration path against executable sources.
### Exit Gate ### Exit Gate
No current internal or integration document describes local comparison, Batch items expose only report-generation facts, and all batch delivery state
historical state, inspection, managed-report sources, or durable prompt is represented by the single top-level notification result.
provenance, and roadmap status accurately reflects completion.
## Stage 13: Run The Repository Exit Gate ## Stage 18: Reconcile Documentation And Run The Remediation Exit Gate
### Goal ### Goal
Verify the complete refactor as one coherent change and remove any remaining Remove the remaining documentation and repository-hygiene traces of the former
legacy coupling before declaring implementation complete. workspace architecture and verify the remediated implementation as a whole.
### Work ### Work
1. Run `gofmt` on every changed Go file and review the complete diff for stale 1. In `docs/roadmap/ephemeral-state.md`, rename `Current State` to
compatibility code, unnecessary abstractions, accidental content capture, `Former State`, convert its description of persistence and Recent Changes to
or user-owned unrelated changes. past tense, and reconcile other implementation-future phrasing with the
2. Search production code, tests, examples, and current-state documentation roadmap's Implemented status. Preserve the roadmap's product intent and
for obsolete `internal/state` imports, `workspace` and `recent_change` desired end state; do not turn it back into a staged plan.
configuration, `recent_changes` prompt input, comparison strategy fields, 2. Remove the root-level `/workspace` ignore rule from `.gitignore` and update
inspection commands, managed-report terminology, and removed artifact-path its adjacent comment. Legacy workspace cleanup remains an explicit operator
summary fields. Historical `v0.9.0` release notes and roadmap discussion of procedure in `docs/operations.md`; removing the ignore rule must not delete
removed behavior are allowed. any operator data or add automated cleanup.
3. Verify the built help contains only `generate` and `run` action families 3. Review the canonical CLI, operations, architecture, app-orchestration, and
plus top-level help/version behavior. testing documentation for the Stage 14-17 behavior. Update only documents
4. Verify from focused offline CLI tests that commands run in a clean temporary whose owned contract changed, avoiding duplicate definitions.
working directory and leave only expected Markdown outputs unless a debug 4. Search production code, tests, examples, and current-state documentation
directory was explicitly supplied. for:
5. Confirm no test, example, or default path depends on the developer machine, - stale per-report batch notification fields;
live credentials, or external services. - report counters that include notification failures;
6. Mark the roadmap and plan complete only after all gates pass. Do not create - output validation performed inside the batch execution loop;
or tag a release in this implementation plan. - claims that cancellation can publish an output;
- present-tense descriptions of the removed workspace; and
- active ignore rules or defaults that conceal a workspace tree.
5. Confirm ordinary generation and batch tests use isolated temporary
directories and leave only the selected Markdown outputs unless explicit
prompt debug capture is requested.
6. Run formatting and all repository validation gates. Review the complete
remediation diff for unrelated changes, content leakage, compatibility
shims, and unnecessary abstractions.
7. When every exit gate passes, mark Stages 14-18 and this plan Complete. Keep
the feature roadmap marked Implemented. Do not create, tag, or publish a
release in this plan.
### Tests ### Tests
@@ -726,14 +367,17 @@ go run ./cmd/weatherreporter --help
git diff --check git diff --check
``` ```
Verify all changed repository-relative documentation links resolve and inspect
`git status --short` for an accidentally created root-level `workspace` tree.
### Exit Gate ### Exit Gate
Weatherreporter builds and passes its deterministic offline suite; ordinary The remediated stateless workflow honors cancellation before publication,
actions are stateless and leave only selected outputs; debug and Distributor preflights complete batch destinations, reports coherent batch counts, exposes
boundaries remain safe; documentation matches implementation; and no required only reachable notification state, and has documentation and repository
work remains. hygiene consistent with the implemented architecture.
## Open Questions ## Open Questions
None. The roadmap supplies all product and policy decisions required to None. The roadmap, accepted ADR, and remediation decisions above provide all
implement these stages. product and architectural choices required to implement Stages 14-18.

View File

@@ -31,7 +31,7 @@ type generationExecutor struct {
called bool called bool
inspectErr error inspectErr error
executeErr error executeErr error
respectCancellation bool cancelBeforeReturn context.CancelFunc
validation promptexec.ValidationStatus validation promptexec.ValidationStatus
rawOutput []byte rawOutput []byte
failedPrompt string failedPrompt string
@@ -47,10 +47,7 @@ func (e generationExecutor) InspectPrompt(_ context.Context, id, version string)
func (generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) { func (generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
} }
func (e *generationExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) { func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
if e.respectCancellation && ctx.Err() != nil {
return nil, ctx.Err()
}
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC) stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp}, nil); err != nil { if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
return nil, err return nil, err
@@ -70,6 +67,9 @@ func (e *generationExecutor) Execute(ctx context.Context, req promptexec.Execute
if rawOutput == nil { if rawOutput == nil {
rawOutput = []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."}`) rawOutput = []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."}`)
} }
if e.cancelBeforeReturn != nil {
e.cancelBeforeReturn()
}
return &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil)}, nil return &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil)}, nil
} }
@@ -137,25 +137,17 @@ func TestGenerateDetailedPreservesDestinationBeforePublish(t *testing.T) {
for _, scenario := range []struct { for _, scenario := range []struct {
name string name string
executor generationExecutor executor generationExecutor
cancel bool
}{ }{
{name: "generation", executor: generationExecutor{executeErr: errors.New("provider unavailable")}}, {name: "generation", executor: generationExecutor{executeErr: errors.New("provider unavailable")}},
{name: "render", executor: generationExecutor{rawOutput: []byte(`{"summary":""}`)}}, {name: "render", executor: generationExecutor{rawOutput: []byte(`{"summary":""}`)}},
{name: "cancellation", executor: generationExecutor{respectCancellation: true}, cancel: true},
} { } {
t.Run(scenario.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md") outputPath := filepath.Join(t.TempDir(), "daily.md")
if err := os.WriteFile(outputPath, []byte("previous report"), 0o600); err != nil { if err := os.WriteFile(outputPath, []byte("previous report"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
ctx := context.Background()
if scenario.cancel {
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
cancel()
}
bundle := generationBundle(t) bundle := generationBundle(t)
result, err := GenerateDetailed(ctx, GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &scenario.executor}) result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &scenario.executor})
data, readErr := os.ReadFile(outputPath) data, readErr := os.ReadFile(outputPath)
if err == nil || result == nil || readErr != nil || string(data) != "previous report" { if err == nil || result == nil || readErr != nil || string(data) != "previous report" {
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr) t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
@@ -164,6 +156,45 @@ func TestGenerateDetailedPreservesDestinationBeforePublish(t *testing.T) {
} }
} }
func TestGenerateDetailedPreservesDestinationWhenContextCancelsBeforePublication(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
const previousReport = "previous report"
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
result, err := GenerateDetailed(ctx, GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel},
})
data, readErr := os.ReadFile(outputPath)
if !errors.Is(err, context.Canceled) || promptexec.CategoryOf(err) != promptexec.Canceled || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
}
}
func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePublication(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
const previousReport = "previous report"
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(0, 0))
defer cancel()
bundle := generationBundle(t)
result, err := GenerateDetailed(ctx, GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
})
data, readErr := os.ReadFile(outputPath)
if !errors.Is(err, context.DeadlineExceeded) || promptexec.CategoryOf(err) != promptexec.DeadlineExceeded || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
}
}
func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) { func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) {
cfg := generationConfig() cfg := generationConfig()
cfg.Notify.Distributor.Enabled = true cfg.Notify.Distributor.Enabled = true

View File

@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
@@ -169,6 +170,9 @@ func (w *promptReportWorkflow) renderAndPublish(raw []byte) (*ReportResult, erro
if err != nil { if err != nil {
return w.result, w.reportError("render template", err) return w.result, w.reportError("render template", err)
} }
if err := publicationContextError(w.ctx); err != nil {
return w.result, w.reportError("publish report", err)
}
if err := fileutil.WriteFileAtomic(w.req.OutputPath, rendered); err != nil { if err := fileutil.WriteFileAtomic(w.req.OutputPath, rendered); err != nil {
return w.result, err return w.result, err
} }
@@ -195,6 +199,16 @@ func classifiedPromptError(operation string, err error) error {
return promptexec.NewError(promptexec.Generation, operation, err) return promptexec.NewError(promptexec.Generation, operation, err)
} }
func publicationContextError(ctx context.Context) error {
if err := ctx.Err(); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return promptexec.NewError(promptexec.DeadlineExceeded, "context expired before output publication", err)
}
return promptexec.NewError(promptexec.Canceled, "context canceled before output publication", err)
}
return nil
}
func promptDebugWriteError(err error) error { func promptDebugWriteError(err error) error {
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err) return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
} }