Files
weatherreporter/docs/roadmap/implementation.md

399 lines
18 KiB
Markdown

# LLM Profile Comparison Implementation Plan
Status: Complete.
## Purpose And Authority
This document is the ordered implementation plan for the accepted [LLM
Profile Comparison Roadmap](profile-comparison.md). The roadmap owns the
feature purpose, policy, scope, and desired end state. This plan records the
completed implementation and records the corrective work completed during
post-implementation review.
All implementation work in this plan is complete. The recorded work leaves the
repository compiling, tested, documented to its implemented boundary, and
internally coherent.
## Implementation Rules
The following rules governed every implementation stage:
- Read `docs/development.md`, the task-specific documents it identifies, all
files under `docs/policy/`, and the feature roadmap before changing code.
- Preserve the existing `generate`, `run`, and `compare` command contracts
except for the explicit comparison corrections defined below.
- Keep Promptkit types and calls behind `internal/adapters/promptkit` and the
dependency-neutral `internal/promptexec` interface.
- Keep comparison artifacts operator-owned and explicit. They are not durable
application state and must never be discovered or consumed implicitly by a
later invocation.
- Preserve comparison's prepare-once, execute-concurrently, order-results-by-
selection, publish-on-profile-failure, and never-notify invariants.
- Do not add a Weatherreporter concurrency limit. Promptkit owns backend
capacity.
- Never expose provider bodies, prompts, schemas, model output, endpoints,
credentials, or arbitrary wrapped error text in normal JSON summaries or
manifests.
- Use deterministic, offline, credential-free tests. Test filesystem safety,
concurrency, and recovery through the narrowest stable behavioral boundary;
do not rely on timing-only sleeps or host permission behavior.
- Run `gofmt` on changed Go files and `git diff --check` in every stage. Run
focused tests while developing and `GOWORK=off go test -count=1 ./...` before
completing each stage. Stages involving concurrency or filesystem mutation
must also run affected packages with `-race`.
- Do not commit, tag, push, or prepare a release unless the implementing prompt
separately requests it.
## Completed Stages
### Stage 1: Comparison Artifact And Naming Contracts
Added the dependency-neutral comparison model, schema version, manifest
validation and encoding, safe errors, deterministic profile filenames,
comparison identities, default directory names, and content hashing.
### Stage 2: Destination Recognition And Transactional Publication
Added read-only destination planning, strict recognition of current comparison
bundles, private sibling staging, guarded replacement, rollback, and atomic
directory publication.
### Stage 3: Ordered Multi-Profile Preflight
Added exact prompt inspection followed by sequential profile inspection before
weather collection, including effective backend, model, and credential checks.
### Stage 4: Immutable Shared Report Preparation
Extracted one immutable prepared-report value so comparison collection,
derivation, module construction, and data-package serialization happen once.
### Stage 5: Profile Execution And In-Memory Rendering
Separated profile-specific Promptkit execution, generated-text validation, and
Markdown rendering from output publication while preserving ordinary report
generation behavior.
### Stage 6: Concurrent Ordered Profile Execution
Added one goroutine per selected profile using one shared executor and one
prepared input, deterministic debug identities, isolated profile failures,
joined cancellation, and selection-ordered results.
### Stage 7: Application-Level Comparison
Added `app.CompareDetailed`, coherent complete and partial bundle construction,
aggregate profile-failure behavior, absolute published paths, and the
application-level guarantee that comparison never notifies Distributor.
### Stage 8: Compare Command Parsing
Added the `compare` command request path, repeatable ordered `--profile`, exact
`--out-dir`, guarded `--replace`, applicable common flags, validation, and one
executor construction per invocation.
### Stage 9: CLI Results And Exit Behavior
Added structured success and failure summaries, quiet-mode suppression,
ordered per-profile results, safe bounded errors, and nonzero exit behavior for
partial or command-level failure.
### Stage 10: Canonical Documentation And Initial Validation
Documented the implemented CLI, operations, Promptkit integration, comparison
bundle, application orchestration, execution, publication, architecture, and
development contracts, then passed the original repository-wide validation
gate.
## Stage 11: Make Concurrent Prompt Debug Creation Race-Safe
### Goal
Ensure concurrent comparison profiles can create their distinct debug runs
under one new report/date directory without spuriously failing or leaving a
test goroutine blocked.
### Work
1. Update `internal/promptdebug.ensureSecureDirectory` so concurrent creation
of the same missing directory is idempotent. If `os.Mkdir` reports that the
path already exists, inspect the path with `Lstat` and accept it only when it
is the expected real directory. Continue to reject symlinks, non-directories,
unsafe modes, and every unrelated filesystem error.
2. Preserve the existing absolute-path, containment, `0700` directory, `0600`
file, and no-symlink guarantees. Do not weaken debug-root validation or make
all `EEXIST` errors successful.
3. Add a focused prompt-debug concurrency regression that starts multiple
writers beneath a shared missing ancestor, joins every goroutine, and
verifies every expected artifact and permission invariant.
4. Make comparison execution test barriers time-bounded and failure-aware. A
callback failure before executor entry must fail the test promptly rather
than leave `waitForProfileStarts` waiting forever.
5. Retain distinct deterministic debug references and profile-local debug
failure behavior.
### Tests And Exit Criteria
- The focused prompt-debug concurrency test passes repeatedly and with the race
detector.
- `TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences`
cannot hang when a profile fails before reaching the fake executor.
- Run, at minimum:
```sh
GOWORK=off go test -count=100 ./internal/promptdebug
GOWORK=off go test -count=100 -run TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences ./internal/app
GOWORK=off go test -race -count=1 ./internal/promptdebug ./internal/app
GOWORK=off go test -count=1 ./...
```
## Stage 12: Make Replacement Authorization Commit-Safe
### Goal
Prevent a destination changed after the final read-only preflight from being
treated as the previously authorized empty directory or recognized bundle and
then deleted during replacement.
### Work
1. Replace `DestinationPlan.Exists` as the publication decision with an
explicit destination-state classification: absent, empty real directory, or
recognized current-schema bundle. Keep `Replace` in the plan so publication
can apply the same authorization policy at commit time.
2. Continue to call `PlanDestination` immediately before publication. For an
absent target, install staging with one rename; a concurrently created
target must cause that rename to fail without modifying the new target.
3. For an existing target, rename that exact filesystem entry to the unique
sibling backup before deleting or installing anything. Classify the moved
backup while it is at its stable backup path and authorize it under the
original replacement policy:
- an empty real directory is allowed with or without `Replace`;
- a recognized current-schema comparison bundle is allowed only with
`Replace`; and
- a file, symlink, unrecognized/nonempty directory, unreadable entry, or
other classification failure is not allowed.
4. Treat this post-move classification as the destructive-action
authorization point. If it fails, restore the moved entry to the target and
return an error without installing staging. If the target has concurrently
reappeared or restoration otherwise fails, retain the backup and return an
actionable joined or typed error that identifies its recovery path; never
delete either entry to force restoration.
5. Install staging only after the moved target has passed authorization. Never
remove a backup that did not pass post-move authorization.
6. Preserve the existing final cancellation linearization rule: cancellation
observed before the rename transaction prevents replacement; after the
transaction starts, finish commit or rollback rather than abandoning it.
7. Add a package-private filesystem-operation seam only if needed for
deterministic tests. Keep the public destination and publication APIs free
of test-only hooks.
### Tests And Exit Criteria
- Deterministically replace an initially accepted destination after final
preflight but before its move with each consequential unauthorized type:
unrelated nonempty directory, regular file, and symlink. Publication must
fail, staging must not become the target, and the moved entry must be restored
or retained at a reported recovery path.
- Cover an initially empty directory whose contents change before its move and
a recognized bundle swapped for an unrecognized directory.
- Retain coverage for absent targets, empty directories, recognized bundle
replacement, cancellation before commit, install failure, successful
rollback, failed rollback, and cleanup of ordinary staging failures.
- Run `GOWORK=off go test -race -count=1 ./internal/comparison` and the
repository-wide standard test command.
## Stage 13: Represent Committed Publication Cleanup Failures Accurately
### Goal
Keep application and CLI results truthful when the new comparison bundle has
been committed but removal of the old sibling backup fails.
### Work
1. Change comparison publication to return a dependency-neutral result as well
as an error:
```go
type PublicationResult struct {
Committed bool
RetainedBackupPath string
}
func Publish(
ctx context.Context,
plan DestinationPlan,
bundle LogicalBundle,
) (PublicationResult, error)
```
2. Define `Committed` as meaning the complete staged bundle is now installed
at the target. Pre-commit, staging, authorization, install, and successful-
rollback failures return `Committed == false`. A successful install returns
`Committed == true` even if later backup cleanup fails.
3. Add a typed post-commit cleanup error that unwraps its filesystem cause and
records the retained backup path for operator recovery. On this error,
return `Committed == true` and the absolute retained backup path. Do not
roll back or remove the newly committed valid bundle merely because old
backup cleanup failed.
4. In `app.CompareDetailed`, populate `ManifestPath`, `DataPackagePath`, and
successful profile `ReportPath` values whenever publication reports
`Committed == true`, before returning any cleanup error.
5. Treat post-commit cleanup failure as a command-level operational failure:
return the non-nil structured result plus an error, produce status `failed`,
and exit nonzero even though the published artifact paths are present. The
ordinary safe JSON error must not contain the raw filesystem cause or backup
path; the wrapped diagnostic returned on stderr may identify the retained
backup for recovery.
6. Keep `RetainedBackupPath` out of the versioned comparison manifest. It
describes an incomplete local transaction cleanup, not the logical bundle.
### Tests And Exit Criteria
- Inject a deterministic backup-removal failure after successful installation
and assert the target is the new recognized bundle, the old bundle remains
at the reported backup, `Committed` is true, and the error is inspectable by
type.
- At the application boundary, assert all committed artifact paths are
absolute and populated while the method still returns an error.
- At the CLI boundary, assert status `failed`, nonzero return, present artifact
paths, and a bounded generic safe error with no raw filesystem detail.
- Retain tests showing every pre-commit or rolled-back failure omits published
artifact paths.
- Run comparison, application, and CLI tests with `-race`, then the
repository-wide standard test command.
## Stage 14: Complete Structured Failure Metadata And Classification
### Goal
Make every non-nil comparison result a reliable description of the attempted
run and preserve useful safe error categories in the top-level CLI summary.
### Work
1. In `app.CompareDetailed`, assign the absolute resolved
`OutputDirectory` immediately after output-directory resolution and before
destination preflight. Do not wait for `PlanDestination` to succeed.
2. Once the initial `ComparisonResult` exists, guarantee that every return path
sets a nonzero UTC `FinishedAt` that is not before `StartedAt`. Use one
centralized finalization path or a defer; do not scatter timestamp writes
across individual failures.
3. Continue to omit manifest, data-package, and report paths until publication
commits. Preserve whatever prompt identity fields have actually been
resolved; never invent a hash or profile result for a phase that did not
run.
4. Update `safeComparisonSummaryError` to use this stable mapping, always
passing messages through `comparison.NewSafeError`:
| Error | Category | Safe message |
| --- | --- | --- |
| aggregate profile failure | `application` | existing bounded aggregate message |
| `context.Canceled` | `canceled` | `comparison canceled` |
| `context.DeadlineExceeded` | `deadline_exceeded` | `comparison deadline exceeded` |
| categorized `promptexec` error | exact `promptexec.CategoryOf` value | `comparison prompt operation failed` |
| `comparison.DestinationError` | `destination_<kind>` | `comparison destination preflight failed` |
| post-commit cleanup error | `publication_cleanup` | `comparison published but cleanup did not complete` |
| any unknown error | `application` | `comparison did not complete` |
5. Apply the most specific mapping before a more general wrapped match. In
particular, detect the post-commit cleanup and destination types before
falling back to a nested filesystem or context cause.
6. Do not copy `DestinationError.Target`, wrapped causes, or arbitrary
`error.Error()` text into normal JSON. Detailed returned errors remain
available on stderr and through Go error inspection.
### Tests And Exit Criteria
- Add application tests for destination-preflight, debug initialization,
prompt-preflight, collection, and preparation failures. Whenever a non-nil
result is returned, assert an absolute output directory, nonzero ordered UTC
timestamps, and omission of unpublished artifact paths.
- Add table-driven CLI tests for every mapping row, including wrapped errors,
and assert that unsafe sentinel text cannot enter serialized output.
- Preserve existing ordered profile-level categories and safe messages.
- Run application and CLI tests with `-race`, then the repository-wide standard
test command.
## Stage 15: Remove Temporary Seams, Reconcile Documentation, And Validate
### Goal
Remove review-discovered maintenance debt, document the corrected implemented
behavior in its canonical owners, and complete the release-equivalent gate.
### Work
1. Remove the unused `Runner.resolveComparison` wrapper. Remove the
test-oriented `Runner.executeComparison` seam if it has no production
caller, and rewrite its remaining coverage through `Runner.Run`,
`resolveComparisonAction`, or another stable behavioral boundary.
2. Remove `comparisonProfileOutcome.err` if production code still does not use
it. Keep raw failures in returned/wrapped errors or explicit internal error
types; do not retain an otherwise dead field solely for private test
assertions.
3. Correct the `internal/comparison` package comment so it describes the
package's actual ownership of both logical comparison contracts and
filesystem destination/publication behavior.
4. Update only the canonical current-state documents affected by Stages 11
through 14:
- `docs/internal/comparison-publication.md` owns post-move authorization,
commit state, rollback, retained backups, and cleanup mechanics;
- `docs/internal/comparison-execution.md` owns concurrency and debug-write
behavior;
- `docs/internal/app-orchestration.md` owns partial results and committed
publication error handling;
- `docs/cli.md` owns structured status, safe category, path, and exit
behavior; and
- `docs/operations.md` owns operator recovery for a retained sibling backup.
Link rather than duplicating complete contracts, and update architecture or
integration documentation only if its existing invariant is inaccurate.
5. Mark this plan `Complete` and restore the feature roadmap's implemented
status after every exit criterion below passes. Retain or remove the two
roadmap documents only according to a later maintainer-directed roadmap
cleanup; do not archive them as a second current-state reference in this
stage.
### Tests And Exit Criteria
- Confirm no production-only helper or field remains solely to support tests,
and no test loses meaningful behavioral coverage during cleanup.
- Verify changed relative links and fenced examples. Search current-state docs
for stale claims about comparison publication, debug behavior, results, or
recovery.
- Run the release-equivalent local gate:
```sh
set -eu
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod; then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
GOWORK=off go test -count=1 ./...
GOWORK=off go test -race -count=1 ./...
GOWORK=off go vet ./...
GOWORK=off go build ./...
GOWORK=off go mod tidy -diff
unformatted="$(git ls-files '*.go' | while IFS= read -r file; do gofmt -l "$file"; done)"
test -z "$unformatted"
git diff --check
```
- Run `GOWORK=off go run ./cmd/weatherreporter --help` and
`GOWORK=off go run ./cmd/weatherreporter compare --help` without credentials
or network access, and confirm that help agrees with `docs/cli.md`.
- Inspect the final diff for accidental generated artifacts, secrets,
workspaces, vendored dependencies, release notes, or unrelated changes.
## Open Questions
None. The roadmap and the contracts in Stages 11 through 15 define the
remaining decisions needed to complete the corrective work.