diff --git a/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md b/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md index 1b12b043d..a316d2129 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md @@ -26,6 +26,19 @@ boatstack-user-config-field:adapters Boatstack's installer owns the complete `.boatstack-project.json` shape. Edit only the controls below, then regenerate the export and review the infrastructure diff. Fields not listed here are identity, compatibility, or installer state rather than product policy. +## Delivery readiness and journey evidence + +New Boatstack plans use schema v3. Before approval is shown, Boatstack fetches +`origin` and verifies the current feature worktree, base/head commits, branch, +upstream relation, and journey-oracle manifest. Activation repeats the check and +stores the same readiness fingerprint in the immutable plan lock. + +Each plan declares `journey_evidence`. Use `not_relevant` with a reason when no +user or operator journey can regress. Use `relevant` with typed runnable oracles +mapped to acceptance criteria when a journey matters. Relevant results are +recorded with `record-journey-results`; test and review gates reject missing, +failed, or stale results. + ## Choose the outcome | Outcome | Control | Enforcement | diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-29-readiness-and-journey-control.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-29-readiness-and-journey-control.md new file mode 100644 index 000000000..23af9180d --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-29-readiness-and-journey-control.md @@ -0,0 +1,3 @@ +### Readiness-bound delivery and journey evidence + +Boatstack now checks branch and worktree freshness before approval and again at activation, binds that readiness to the immutable plan lock, requires explicit journey evidence decisions, and prevents stale or failed journey results from passing delivery gates. Repair retries now have independent implementation, verification, and review budgets and must identify the mechanism being changed. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md index a6306c085..2bd89915c 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md @@ -36,11 +36,11 @@ To see every feature at once, run the read-only `.product-loop/bin/boatstack-hel ## Run through ship -For `$boatstack run`, `/boatstack-run`, or natural language such as “run Boatstack through ship,” first run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan `; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Before the first delivery-stage operation (`build`, `repair`, `test-gate`, `review-gate`, or `ship-gate`), run `run-preflight --repo . --json`. Planning and approval do not require a remote fetch. The preflight fetches `origin` and verifies the current named branch contains the fetched delivery base and is not behind or diverged from its upstream. A failed fetch, missing remote/base, stale base, upstream drift, or constrained branch mismatch blocks before delivery mutation. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions. +For `$boatstack run`, `/boatstack-run`, or natural language such as “run Boatstack through ship,” first run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan `; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Schema-v3 `check-plan` runs the Git freshness preflight before it displays the approval fingerprint, and activation repeats it immediately before writing the immutable lock. A failed fetch, missing remote/base, stale base, upstream drift, wrong worktree, constrained branch mismatch, or incomplete journey decision blocks without creating authority or consuming repair budget. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions. After preflight, repeatedly run `next-status --repo . --json`, execute only its verified next operation using the canonical semantics below, verify the resulting repository state, and resolve again. Continue across all declared slices. Pause for explicit `a` plan approval, material product questions, and the exact `o` or `u` PR confirmation; a valid answer resumes the foreground run in the current host session. The run invocation itself is never approval or publication authority. Same-intent test/review failures may be recorded and repaired for at most three complete repair-and-gate cycles per active slice; the durable delivery attempt count does not reset across turns or hosts. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or an exhausted budget. Persist execution facts and retry identity, never autonomous workflow intent; conversation is not workflow evidence. Completion means every slice PR is published for review, never merged or deployed — unless `delivery.terminal` is `merged`, in which case the flow continues past publication with prescribed read-only steps until the PR is observed merged (see the post-publish rules below). -When `delivery.terminal` is `merged`, follow the post-publish prescriptions exactly. After publication, run `flow next` (or `next-status`). When it prescribes `flow watch`, run the watch and re-resolve when it exits. When checks fail, it prescribes `record-change --source-stage ci`; derive the exact message and classification from the failing check logs, never from memory, then repair, re-gate, and republish with `publish-pr --action update`. When the PR is observed merge-eligible, it prescribes the exact `gh pr merge` command; run it only as rendered, under the host's own permissions — Boatstack never merges, and you never merge without the prescription. A required review approval, a changes-requested verdict, a closed PR, or an unverifiable PR position always ends your turn at the operator frontier. +When `delivery.terminal` is `merged`, follow the post-publish prescriptions exactly. After publication, run `flow next` (or `next-status`). When it prescribes `flow watch`, run the watch and re-resolve when it exits. When checks fail, it prescribes `record-change --source-stage ci`; derive the exact message, classification, evidence, and changed repair mechanism from the failing check logs, never from memory, then repair, re-gate, and republish with `publish-pr --action update`. When the PR is observed merge-eligible, it prescribes the exact `gh pr merge` command; run it only as rendered, under the host's own permissions — Boatstack never merges, and you never merge without the prescription. A required review approval, a changes-requested verdict, a closed PR, or an unverifiable PR position always ends your turn at the operator frontier. ## Enforce the irreversible-operation boundary @@ -134,7 +134,7 @@ Treat repository-owned product context as canonical. Do not require it to be mig --plan .product-loop/features//plan.md ``` -2. Present the draft spec, plan, open decisions, accepted assumptions, gaps, risks, validation provenance, and `PLAN_FINGERPRINT` in a reviewable form. +2. Present the draft spec, plan, open decisions, accepted assumptions, gaps, risks, validation provenance, `PLAN_FINGERPRINT`, and `READINESS_FINGERPRINT` in a reviewable form. A schema-v3 plan must decide `journey_evidence`: `relevant` with complete typed runnable oracles, or `not_relevant` with a reason. 3. When `workflow.human_plan_approval` is true, ask the developer to approve it or request changes and end with: Reply `a` to approve. When false, state that Build will create a policy-activation lock and do not imply human approval. 4. On changes, return to `auto-plan`, preserve the feedback in the question ledger, and issue a new draft. 5. When human approval is enabled, invoke `boatstack-helper record-approval` with the plan, named human, RFC3339 timestamp, and exact fingerprint. When disabled, create no `approval.md`. @@ -156,7 +156,7 @@ All files created or updated by `auto-plan` and `plan-gate` must be Markdown. gs For human authorization, add `--approval .product-loop/features//approval.md`. -- Activation verifies the plan fingerprint and any required approval, compiles `tasks.json`, `test-matrix.json`, and the evidence skeleton, then writes a schema-v2 lock with `authorization_mode: human` or `policy`. Missing required approval, open blocking questions, or any changed input returns `BLOCKED`. +- Activation atomically repeats readiness, verifies the plan fingerprint and any required approval, compiles `tasks.json`, `test-matrix.json`, `journey-oracles.json`, and the evidence skeleton, then writes a schema-v3 readiness-bound lock with `authorization_mode: human` or `policy`. Existing active schema-v1/v2 locks remain readable. Missing required approval, open blocking questions, or any changed input returns `BLOCKED`. - Activation also creates ignored delivery state bound to the plan lock. Read it with `delivery-status`; implement only the active slice's `task_ids`. A multi-slice plan advances only after the current slice publishes through `ship-gate`. - Keep the source plan present and hash-current through completion of `build`. - Choose any suitable model, tool, or implementation tactic inside the approved boundary. Boatstack controls transitions and claims, not local creativity. @@ -178,7 +178,7 @@ If Cursor reports `MainThreadShellExec not initialized`, the host failed before If any host reports `HOST_PAYLOAD_MALFORMED`, Boatstack received an event it could not safely decode; no unsafe operation was detected. Retry once with an explicit non-empty command. If the same code repeats, stop shell and tool retries, preserve current edits, and run `.product-loop/bin/boatstack-helper diagnose-hook --host --repo .` from an external terminal. For Cursor, start a new task after the probe. The diagnostic proves the installed guard with a canonical event but cannot inspect the live event supplied by the host. Do not recommend reinstall or hydration unless Boatstack separately reports a missing, drifted, unsafe, or checksum-invalid runtime. -Same-intent repair resumes at the helper-reported stage and reuses the existing gates. A requirement amendment or ambiguous expected behavior blocks product edits and returns to a concise Plan Gate delta. Never edit `changes.md`, ignored delivery state, or receipts directly; those are emitted by controlled transitions. Conversation history is never workflow authority. +Same-intent repair resumes at the helper-reported stage and reuses the existing gates. Pass `--mechanism` for every repair classification. Implementation, verification, and review repairs each have an independent three-attempt budget. Requirement amendments and readiness recovery consume none. An identical failure-class, evidence, and mechanism retry is denied. A requirement amendment or ambiguous expected behavior blocks product edits and returns to a concise Plan Gate delta. Never edit `changes.md`, ignored delivery state, or receipts directly; those are emitted by controlled transitions. Conversation history is never workflow authority. A published delivery is immutable. Record the append-only observation without changing its state, then automatically prepare a one-slice correction under the suggested feature id with `parent_delivery` set to the published feature. Present the inherited intent, observed failure, existing local diff, verification, and PR destination, then pause for the normal fingerprinted human approval. The corrective child receives its own lock and full gates. A verified open PR reuses its head branch and is updated; merged or closed work uses a fresh branch and PR. Unknown PR state may be planned but blocks destination-specific publication. @@ -189,6 +189,7 @@ A published delivery is immutable. Record the append-only observation without ch - After build completes, the source Plan-mode file is no longer a runtime prerequisite. Test, review, and ship use the approved lock, actual diff, and accumulated evidence; provenance remains recorded in the lock. - Derive tests from acceptance criteria and affected contracts, not only from the implementation. - Run existing relevant tests plus targeted new tests, linters, type checks, builds, and runtime checks. +- When `journey_evidence` is relevant, run every compiled oracle and import typed results with `record-journey-results --feature --results `. Test and review gates reject missing, failed, manifest-mismatched, head-mismatched, or diff-stale results. - For relevant PR visual scenarios, use the repository runner first, then a host browser against the existing development server, one supplied launch instruction, or an explicitly approved machine-local runtime. Do not modify repository dependencies or configuration for capture. Review each exact PNG for secrets and private data, then import the temporary manifest with `record-pr-visual-evidence`; keep the images outside the repository. - Treat model-authored tests and same-model self-review as evidence, not ground truth. - Validate that tests load and exercise the intended interface. For high-risk code, add an independent oracle such as contract fixtures, mutation testing, differential checks, staging verification, or human acceptance. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/approval.md b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/approval.md index 438b7ed4b..7f5a94671 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/approval.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/approval.md @@ -5,13 +5,21 @@ This receipt may be created only after the named human explicitly approves the e ```json { - "schema_version": 2, + "schema_version": 3, "status": "APPROVED", "approved_by": "", "approved_at": "", "approval_fingerprint": "", "baseline_diff_sha256": "", - "baseline_changed_paths": [] + "baseline_changed_paths": [], + "readiness_fingerprint": "", + "base_branch": "", + "head_branch": "", + "base_commit": "", + "head_commit": "", + "upstream": "", + "upstream_relation": "", + "journey_manifest_sha256": "" } ``` diff --git a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan-lock.json b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan-lock.json index b1f0f00e2..20375e047 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan-lock.json +++ b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan-lock.json @@ -1,5 +1,5 @@ { - "schema_version": 2, + "schema_version": 3, "status": "LOCKED", "authorization_mode": "human", "activated_at": "", @@ -16,6 +16,15 @@ "task_graph_sha256": "", "baseline_diff_sha256": "", "baseline_changed_paths": [], + "readiness_fingerprint": "", + "readiness_plan_fingerprint": "", + "base_branch": "", + "head_branch": "", + "base_commit": "", + "head_commit": "", + "upstream": "", + "upstream_relation": "", + "journey_manifest_sha256": "", "invalidated_at": null, "invalidation_reason": null } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan.md b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan.md index 57822c055..f3897b7ea 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan.md @@ -13,7 +13,7 @@ ```json { - "schema_version": 2, + "schema_version": 3, "feature_id": "", "source_plan_path": "source-plan.md", "spec_path": "feature-spec.md", @@ -45,6 +45,11 @@ "reason": "", "scenarios": [] }, + "journey_evidence": { + "relevance": "not_relevant", + "reason": "", + "oracles": [] + }, "tasks": [ { "id": "T-1", diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go index 076e1f32c..2658c8440 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go @@ -6,11 +6,13 @@ import ( "fmt" "io" "os" + "path/filepath" "sort" "strings" "time" boatstack "github.com/operatorstack/boatstack/boatstack" + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" ) func fail(err error) int { @@ -454,8 +456,19 @@ func checkPlanCommand(arguments []string) int { if err != nil { return fail(fmt.Errorf("cannot fingerprint the pre-activation product baseline: %w", err)) } + readinessFingerprint := "" + if version, _ := check.Plan["schema_version"].(float64); version >= 3 { + readiness, readinessErr := boatstack.CheckPlanReadiness(*plan) + repo, _ := boatstack.ResolveRepository(filepath.Dir(*plan)) + if readinessErr != nil { + boatstack.RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, true, readinessErr.Error()) + return fail(readinessErr) + } + readinessFingerprint = readiness.Fingerprint + boatstack.RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, false, "current") + } paths, _ := json.Marshal(baseline.ChangedPaths) - fmt.Printf("PASS: Markdown plan is structurally valid\nPLAN_FINGERPRINT=%s\nSOURCE_PLAN=%s\nSPEC=%s\nBASELINE_DIFF_SHA256=%s\nBASELINE_CHANGED_PATHS=%s\n", check.Fingerprint, check.SourcePlanPath, check.SpecPath, baseline.DiffSHA256, paths) + fmt.Printf("PASS: Markdown plan is structurally valid\nPLAN_FINGERPRINT=%s\nREADINESS_FINGERPRINT=%s\nSOURCE_PLAN=%s\nSPEC=%s\nBASELINE_DIFF_SHA256=%s\nBASELINE_CHANGED_PATHS=%s\n", check.Fingerprint, readinessFingerprint, check.SourcePlanPath, check.SpecPath, baseline.DiffSHA256, paths) return 0 } @@ -491,6 +504,7 @@ func activatePlanCommand(arguments []string) int { if err := boatstack.ActivatePlan(options); err != nil { return fail(fmt.Errorf("plan activation failed: %w", err)) } + boatstack.RecordFlowAttribution(filepath.Dir(options.PlanPath), "authorization_freshness", deliverycontrol.CostQuery, false, "immutable lock current") fmt.Printf("PASS: approved Markdown plan activated and locked: %s\n", options.OutputPath) return 0 } @@ -921,6 +935,7 @@ func recordChangeCommand(arguments []string) int { flags.StringVar(&options.Expected, "expected", "", "approved or requested expected behavior") flags.StringVar(&options.Actual, "actual", "", "observed behavior") flags.StringVar(&options.Evidence, "evidence", "", "bounded evidence or reproduction reference") + flags.StringVar(&options.Mechanism, "mechanism", "", "repair mechanism used to address the observed failure") flags.StringVar(&options.Classification, "classification", "", "implementation_repair, verification_repair, review_repair, requirement_amendment, needs_clarification, or plan_invalid") flags.StringVar(&options.SliceID, "slice", "", "delivery slice id the correction targets; redirects to the named active or published-open slice (default: the correction's branch, then the active slice)") if err := flags.Parse(arguments); err != nil { @@ -929,10 +944,20 @@ func recordChangeCommand(arguments []string) int { if options.Feature == "" || options.Message == "" || options.SourceStage == "" || options.Classification == "" { return fail(fmt.Errorf("record-change requires --feature, --message, --source-stage, and --classification")) } + if strings.HasSuffix(strings.ToLower(options.Classification), "_repair") && strings.TrimSpace(options.Mechanism) == "" { + return fail(fmt.Errorf("record-change requires --mechanism for repair classifications")) + } observation, state, err := boatstack.RecordChangeObservation(options) if err != nil { + if strings.HasSuffix(strings.ToLower(options.Classification), "_repair") && + (strings.Contains(err.Error(), "friction:") || strings.Contains(err.Error(), "budget exhausted")) { + boatstack.RecordFlowAttribution(options.Repo, "repair."+strings.ToLower(options.Classification), deliverycontrol.CostFriction, true, err.Error()) + } return fail(err) } + if strings.HasSuffix(strings.ToLower(options.Classification), "_repair") { + boatstack.RecordFlowAttribution(options.Repo, "repair."+strings.ToLower(options.Classification), deliverycontrol.CostRecovery, false, options.Mechanism) + } // A recorded correction is the honest moment coding rework is initiated; // record one unit of coding effort as telemetry (never a gate, never J_flow). boatstack.RecordCodingEffort(options.Repo, 1, string(observation.Classification)) @@ -943,6 +968,29 @@ func recordChangeCommand(arguments []string) int { return 0 } +func recordJourneyResultsCommand(arguments []string) int { + flags := flag.NewFlagSet("record-journey-results", flag.ContinueOnError) + options := boatstack.JourneyResultsOptions{} + flags.StringVar(&options.Repo, "repo", ".", "repository containing the managed delivery") + flags.StringVar(&options.Feature, "feature", "", "managed Boatstack feature slug") + flags.StringVar(&options.BaseBranch, "base", "", "delivery base branch") + flags.StringVar(&options.InputPath, "results", "", "JSON file containing typed oracle results") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if options.Feature == "" || options.InputPath == "" { + return fail(fmt.Errorf("record-journey-results requires --feature and --results")) + } + result, err := boatstack.RecordJourneyResults(options) + if err != nil { + boatstack.RecordFlowAttribution(options.Repo, "journey_discovery", deliverycontrol.CostQuery, false, err.Error()) + return fail(err) + } + boatstack.RecordFlowAttribution(options.Repo, "journey_discovery", deliverycontrol.CostQuery, false, "results bound to manifest and diff") + fmt.Printf("PASS: journey results recorded\nMANIFEST_SHA256=%s\nHEAD_COMMIT=%s\nDIFF_SHA256=%s\n", result.ManifestSHA256, result.HeadCommit, result.DiffSHA256) + return 0 +} + func ignoreDeliveryCommand(arguments []string) int { flags := flag.NewFlagSet("ignore-delivery", flag.ContinueOnError) repo := flags.String("repo", ".", "repository containing the Boatstack installation") @@ -1439,7 +1487,7 @@ func workspaceSyncCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -1501,6 +1549,8 @@ func run() int { return runPreflightCommand(os.Args[2:]) case "record-change": return recordChangeCommand(os.Args[2:]) + case "record-journey-results": + return recordJourneyResultsCommand(os.Args[2:]) case "ignore-delivery": return ignoreDeliveryCommand(os.Args[2:]) case "discard-delivery": diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go index 7c2ca6d09..c0d1295bd 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go @@ -11,7 +11,7 @@ import ( "time" ) -const deliveryStateSchemaVersion = 1 +const deliveryStateSchemaVersion = 2 // Slice-status lifecycle literals — the canonical string values stored in // DeliverySlice.Status. These are the single source for the slice-status @@ -71,7 +71,8 @@ type DeliveryState struct { Mode string `json:"mode,omitempty"` ResumeStage string `json:"resume_stage,omitempty"` ActiveObservationID string `json:"active_observation_id,omitempty"` - RepairAttempt int `json:"repair_attempt,omitempty"` + RepairCounters map[string]int `json:"repair_counters"` + RepairAttempt int `json:"-"` SupersededReceipts []string `json:"superseded_receipts,omitempty"` ParentDelivery string `json:"parent_delivery,omitempty"` // Goal snapshots the non-default delivery terminal ("merged") this @@ -148,6 +149,7 @@ type ChangeObservationOptions struct { Expected string Actual string Evidence string + Mechanism string Classification string // SliceID optionally targets a specific addressable slice — the active slice or // a published-but-open earlier slice. Empty resolves to the correction's branch @@ -163,6 +165,9 @@ type ChangeObservation struct { Expected string `json:"expected,omitempty"` Actual string `json:"actual,omitempty"` Evidence string `json:"evidence,omitempty"` + EvidenceSHA256 string `json:"evidence_sha256,omitempty"` + Mechanism string `json:"mechanism,omitempty"` + MechanismSHA256 string `json:"mechanism_sha256,omitempty"` Message string `json:"message"` Classification string `json:"classification"` ResumeStage string `json:"resume_stage,omitempty"` @@ -325,6 +330,24 @@ func saveDeliveryState(repo string, state DeliveryState) error { if err != nil { return err } + if state.RepairCounters == nil { + state.RepairCounters = map[string]int{} + } + // Preserve source-level compatibility for callers that still populate the + // v1 shadow field: conservatively apply it to every typed counter. + allTypedZero := true + for _, class := range []string{"implementation_repair", "verification_repair", "review_repair"} { + if state.RepairCounters[class] != 0 { + allTypedZero = false + } + } + if state.RepairAttempt > 0 && allTypedZero { + for _, class := range []string{"implementation_repair", "verification_repair", "review_repair"} { + if state.RepairCounters[class] < state.RepairAttempt { + state.RepairCounters[class] = state.RepairAttempt + } + } + } value, err := MarshalJSON(state) if err != nil { return err @@ -356,6 +379,14 @@ func LoadDeliveryState(repo, feature string) (DeliveryState, error) { if state.SchemaVersion != deliveryStateSchemaVersion || state.Feature != feature || len(state.Slices) == 0 || state.ActiveIndex < 0 || state.ActiveIndex > len(state.Slices) { return DeliveryState{}, fmt.Errorf("managed delivery state is invalid") } + if state.RepairCounters == nil { + state.RepairCounters = map[string]int{} + } + for _, class := range []string{"implementation_repair", "verification_repair", "review_repair"} { + if state.RepairCounters[class] > state.RepairAttempt { + state.RepairAttempt = state.RepairCounters[class] + } + } return state, nil } @@ -391,6 +422,7 @@ func initializeDeliveryState(repo, feature, planPath, lockPath string) error { return saveDeliveryState(repo, DeliveryState{ SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash, ActiveIndex: 0, Slices: slices, Mode: "NORMAL", + RepairCounters: map[string]int{"implementation_repair": 0, "verification_repair": 0, "review_repair": 0}, ParentDelivery: strings.TrimSpace(stringValue(plan["parent_delivery"])), Goal: deliveryGoalSnapshot(repo), }) @@ -536,10 +568,11 @@ func appendChangeObservation(repo string, observation ChangeObservation) error { if len(existing) == 0 { existing = []byte("# Change observations\n\nAppend-only observations recorded after build activation.\n") } - block := fmt.Sprintf("\n## %s\n\n- Recorded: `%s`\n- Source stage: `%s`\n- Classification: `%s`\n- Outcome: `%s`\n- Resume stage: `%s`\n- Parent delivery: `%s`\n- Suggested corrective feature: `%s`\n- User message: %s\n- Expected: %s\n- Actual: %s\n- Evidence: %s\n- Resolution: pending\n", + block := fmt.Sprintf("\n## %s\n\n- Recorded: `%s`\n- Source stage: `%s`\n- Classification: `%s`\n- Outcome: `%s`\n- Resume stage: `%s`\n- Parent delivery: `%s`\n- Suggested corrective feature: `%s`\n- User message: %s\n- Expected: %s\n- Actual: %s\n- Evidence: %s\n- Evidence SHA-256: `%s`\n- Mechanism: %s\n- Mechanism SHA-256: `%s`\n- Resolution: pending\n", observation.ID, observation.RecordedAt, observation.SourceStage, observation.Classification, observation.Outcome, observation.ResumeStage, observation.ParentDelivery, observation.SuggestedFeatureID, - observation.Message, observation.Expected, observation.Actual, observation.Evidence) + observation.Message, observation.Expected, observation.Actual, observation.Evidence, + observation.EvidenceSHA256, observation.Mechanism, observation.MechanismSHA256) return atomicWriteMode(path, append(existing, []byte(block)...), 0o644) } @@ -585,6 +618,24 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio if strings.TrimSpace(options.Message) == "" || strings.TrimSpace(options.SourceStage) == "" { return ChangeObservation{}, DeliveryState{}, fmt.Errorf("change observation requires the user message and source stage") } + repairClass := classification == "implementation_repair" || classification == "verification_repair" || classification == "review_repair" + if repairClass && strings.TrimSpace(options.Mechanism) == "" { + options.Mechanism = fmt.Sprintf("legacy API mechanism %d", time.Now().UnixNano()) + } + evidenceHash := SHA256Bytes([]byte(strings.TrimSpace(options.Evidence))) + mechanismHash := SHA256Bytes([]byte(strings.TrimSpace(options.Mechanism))) + if repairClass { + changePath := filepath.Join(repo, ".product-loop", "features", options.Feature, "changes.md") + if prior, readErr := os.ReadFile(changePath); readErr == nil { + for _, block := range strings.Split(string(prior), "\n## ") { + if strings.Contains(block, "- Classification: `"+classification+"`") && + strings.Contains(block, "- Evidence SHA-256: `"+evidenceHash+"`") && + strings.Contains(block, "- Mechanism SHA-256: `"+mechanismHash+"`") { + return ChangeObservation{}, DeliveryState{}, fmt.Errorf("friction: identical %s evidence and mechanism retry denied; change the repair mechanism", classification) + } + } + } + } published := state.ActiveIndex >= len(state.Slices) // Resolve which addressable slice this correction targets before mutating any @@ -623,20 +674,23 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio } } - // The delivery-level repair budget governs only the active slice's build loop. - if !published && !publishedOpen { - if state.RepairAttempt >= 3 { - return ChangeObservation{}, DeliveryState{}, fmt.Errorf("persistent repair budget exhausted after %d attempts; preserve current state and require a reviewed recovery decision", state.RepairAttempt) + attempt := 0 + if repairClass && !published && !publishedOpen { + if state.RepairCounters == nil { + state.RepairCounters = map[string]int{} } - state.RepairAttempt++ - } - id := fmt.Sprintf("CHG-%03d", state.RepairAttempt) - if published || publishedOpen { - id = nextChangeObservationID(repo, options.Feature, state.RepairAttempt+1) + attempt = state.RepairCounters[classification] + if attempt >= 3 { + return ChangeObservation{}, DeliveryState{}, fmt.Errorf("friction: persistent repair budget exhausted for %s after %d attempts; preserve current state and require a reviewed recovery decision", classification, attempt) + } + attempt++ + state.RepairCounters[classification] = attempt } + id := nextChangeObservationID(repo, options.Feature, 1) observation := ChangeObservation{ ID: id, Feature: options.Feature, SourceStage: strings.ToUpper(strings.TrimSpace(options.SourceStage)), Expected: strings.TrimSpace(options.Expected), Actual: strings.TrimSpace(options.Actual), Evidence: strings.TrimSpace(options.Evidence), + EvidenceSHA256: evidenceHash, Mechanism: strings.TrimSpace(options.Mechanism), MechanismSHA256: mechanismHash, Message: strings.TrimSpace(options.Message), Classification: classification, ResumeStage: resume, RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), } @@ -1023,6 +1077,9 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if slice.HeadBranch != "" && slice.HeadBranch != head { return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s requires head branch %s; current branch is %s", slice.ID, slice.HeadBranch, head) } + if err := checkCurrentJourneyResults(repo, options.Feature, base, headCommit, diffHash); err != nil { + return DeliveryGateReceipt{}, fmt.Errorf("delivery gate requires current journey evidence: %w", err) + } if gate == "review" { if slice.Status != StatusTestPassed { return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s must pass its test gate before review", slice.ID) @@ -1199,6 +1256,7 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error { if state.ActiveIndex < len(state.Slices) { state.Slices[state.ActiveIndex].Status = StatusBuild state.RepairAttempt = 0 + state.RepairCounters = map[string]int{"implementation_repair": 0, "verification_repair": 0, "review_repair": 0} state.ActiveObservationID = "" state.ResumeStage = "" state.Mode = "NORMAL" diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery_migrate.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery_migrate.go index 9de38b063..85f41e834 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery_migrate.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery_migrate.go @@ -19,9 +19,23 @@ type deliveryStateMigration struct { } // deliveryStateMigrations is the ordered, one-step-at-a-time upgrade chain for the -// managed delivery state. It is intentionally empty at schema version 1; a schema -// bump adds one entry per version step, each covered by conformance. -var deliveryStateMigrations []deliveryStateMigration +// managed delivery state. Schema v2 maps the legacy global repair attempt to every +// typed counter so old exhaustion cannot create fresh authority. +var deliveryStateMigrations = []deliveryStateMigration{ + { + from: 1, to: 2, + apply: func(value map[string]any) (map[string]any, error) { + attempt := intValue(value["repair_attempt"]) + value["repair_counters"] = map[string]any{ + "implementation_repair": attempt, + "verification_repair": attempt, + "review_repair": attempt, + } + delete(value, "repair_attempt") + return value, nil + }, + }, +} // migrateDeliveryStateBytes upgrades raw managed-delivery-state JSON to the current // deliveryStateSchemaVersion using the registered migration chain. It is the diff --git a/labs/12-product-engineering-loop/product-engineering-loop/deliverycontrol_parity_test.go b/labs/12-product-engineering-loop/product-engineering-loop/deliverycontrol_parity_test.go index f6278b8eb..0bef9fb04 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/deliverycontrol_parity_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/deliverycontrol_parity_test.go @@ -21,6 +21,7 @@ var realDeliveryHandlers = map[string]any{ "ActivatePlan": ActivatePlan, "RecordDeliveryGate": RecordDeliveryGate, "RecordChangeObservation": RecordChangeObservation, + "RecordJourneyResults": RecordJourneyResults, "PublishPR": PublishPR, "UndoManagedMutation": UndoManagedMutation, "DiscardDelivery": DiscardDelivery, diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export.go b/labs/12-product-engineering-loop/product-engineering-loop/export.go index d3a7baa74..b642a2f2c 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -331,6 +331,14 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "workspace-cleanup": "Reclaim a published feature's managed workspace once its work has landed. This operation is surfaced by boatstack-next after publication; the user does not invoke it directly. Run the project-local helper workspace-status --repo . --branch to report whether the pull request is merged, using the GitHub CLI with a local-ancestry fallback. When workspace.cleanup_after is merge, offer removal only once the PR is confirmed merged; if it is still open, report that and offer to keep waiting or, only on an explicit human override request, proceed. Never remove a workspace with uncommitted or unmerged work without an explicit forced override, and never delete a remote branch or merge anything; cleanup reclaims only the local worktree and branch. In confirm mode respond Workspace ready to clean up and render the one next action as: Reply `c` to clean up, or `k` to keep. Only after the exact reply c run workspace-cleanup --repo . --branch with --confirm (add --force only for an explicit override); on k respond Workspace kept with no action required. In auto mode reclaim a merged workspace without a prompt; in off mode do not offer cleanup. After removal, report whether the worktree and branch were reclaimed.", "workspace-reap": "Sweep every terminal managed workspace at the safe post-merge checkpoint, reclaiming the accumulated backlog in one prompt. This operation is surfaced by boatstack-next when a delivery's PR is confirmed merged; the user does not invoke it directly. Run the project-local helper workspace-reap --repo . to inspect all Boatstack worktrees and branches — those created under .product-loop/worktrees/ — and identify which are reclaimable: confirmed merged (GitHub CLI with a local-ancestry fallback) or explicitly abandoned (their feature slug is in workflow.ignored_deliveries). Never reap an unmerged workspace with an open or unknown-state PR, a non-Boatstack worktree, the base branch, the current worktree, or a workspace with uncommitted or unmerged work without an explicit forced override; and never delete a remote branch or merge anything. In confirm mode the helper returns NEEDS_CONFIRMATION with the reclaimable count: respond N Boatstack worktrees/branches are merged or abandoned and reclaimable and render the one next action as: Reply `c` to reap, or `k` to keep. Only after the exact reply c run workspace-reap --repo . --confirm (add --force only for an explicit override); on k respond Workspaces kept with no action required. In auto mode reclaim them without a prompt; in off mode do not offer reaping. After removal, report how many worktrees and branches were reclaimed.", } + operations["auto-plan"] += " Use plan schema v3. Record journey_evidence as relevant with typed runnable oracles mapped to acceptance criteria, or not_relevant with a reason." + operations["plan-gate"] += " check-plan must return current READINESS_FINGERPRINT before approval is displayed. Stop on any branch, worktree, origin, base, upstream, or journey-capability block." + operations["build"] = strings.Replace(operations["build"], "compiled task graph, test matrix, evidence ledger, and the plan lock", "compiled task graph, test matrix, evidence ledger, journey-oracle manifest, and the plan lock", 1) + operations["build"] = strings.Replace(operations["build"], "so all four land", "so all five land", 1) + operations["build"] += " Activation repeats readiness atomically and compiles journey-oracles.json into the same immutable authority boundary." + operations["repair"] += " Every repair classification requires --mechanism. Implementation, verification, and review repairs each have an independent three-attempt budget; identical class/evidence/mechanism retries are friction and amendments or readiness recovery consume no attempts." + operations["test-gate"] += " For relevant journey evidence, run every compiled oracle and import current typed results with record-journey-results before the gate." + operations["review-gate"] += " Reject missing, failed, manifest-mismatched, head-mismatched, or diff-stale required journey results." if contains(adapters, "cursor") { rule := fmt.Sprintf(`--- diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go index 254d90850..83adb0bda 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go @@ -144,6 +144,7 @@ var operatorOwedFlags = map[string]bool{ "--message": true, // correction facts are human knowledge "--source-stage": true, "--classification": true, + "--mechanism": true, } // classifyNextActor types the next step by who must act. Fail-closed: anything @@ -209,7 +210,7 @@ type FlowNext struct { // that decides whether anything is still owed after publish. In this // slice it is surfaced only; post-publish prescriptions follow. // control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config - Terminal DeliveryTerminal `json:"terminal"` + Terminal DeliveryTerminal `json:"terminal"` RecommendedOp string `json:"recommended_operation"` OracleNext deliverycontrol.TransitionID `json:"oracle_next_transition,omitempty"` RemainingCost int `json:"remaining_flow_cost"` @@ -325,7 +326,7 @@ func prescribeCommand(repo, feature string, status NextStatus, transition delive if status.ActiveSlice != "" { cmd.Args = append(cmd.Args, "--slice", status.ActiveSlice) } - cmd.RequiresHumanInput = []string{"--message", "--source-stage", "--classification"} + cmd.RequiresHumanInput = []string{"--message", "--source-stage", "--classification", "--mechanism"} case deliverycontrol.TransitionID("delivery.undo"): // The mutation id names WHICH receipt to reverse — a human decision. cmd.Args = repoArgs @@ -496,12 +497,12 @@ func prescribePostPublish(repo string, status NextStatus, terminal DeliveryTermi cmd.Args = append(cmd.Args, "--slice", status.ActiveSlice) } cmd.Args = append(cmd.Args, "--source-stage", "ci") - cmd.RequiresHumanInput = []string{"--message", "--classification"} + cmd.RequiresHumanInput = []string{"--message", "--classification", "--mechanism"} followUp := "Read the failing check logs" if len(status.PRFailingChecks) > 0 { followUp += " (" + strings.Join(status.PRFailingChecks, ", ") + ")" } - followUp += " to derive the exact message and classification; after the correction re-passes its gates, republish with publish-pr --action update." + followUp += " to derive the exact message, classification, and changed mechanism; after the correction re-passes its gates, republish with publish-pr --action update." return cmd, followUp case PRPhaseMergeEligible: if strings.TrimSpace(status.PRURL) == "" { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_prescribe_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_prescribe_conformance_test.go index 2605a0f7e..19589483b 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_prescribe_conformance_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_prescribe_conformance_test.go @@ -140,7 +140,7 @@ func TestPrescribeEmitsNothingForUnassemblableTransition(t *testing.T) { } // The rework/recovery edges owe exactly their human facts, never fabricated. for id, owed := range map[deliverycontrol.TransitionID][]string{ - "delivery.record_change": {"--message", "--source-stage", "--classification"}, + "delivery.record_change": {"--message", "--source-stage", "--classification", "--mechanism"}, "delivery.undo": {"--mutation"}, "delivery.discard_delivery": nil, } { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_report.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_report.go index 930f14238..04a12b9cd 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_report.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_report.go @@ -2,6 +2,7 @@ package boatstack import ( "fmt" + "sort" "strings" "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" @@ -44,6 +45,16 @@ func FormatFlowReport(report deliverycontrol.FlowTrajectoryReport) string { fmt.Fprintf(&b, "J_flow=%d regret=unresolved (no oracle baseline for this start)\n", report.JFlow) } fmt.Fprintf(&b, "J_coding=%d (telemetry, separate from flow)\n", report.JCoding) + if len(report.PositiveGapByCategory) > 0 { + categories := make([]string, 0, len(report.PositiveGapByCategory)) + for category := range report.PositiveGapByCategory { + categories = append(categories, category) + } + sort.Strings(categories) + for _, category := range categories { + fmt.Fprintf(&b, "positive_gap[%s]=%d\n", category, report.PositiveGapByCategory[category]) + } + } return b.String() } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_report_test.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_report_test.go index a1e0840f0..c190b79b5 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_report_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_report_test.go @@ -55,3 +55,21 @@ func TestFlowReportEmptySession(t *testing.T) { t.Errorf("empty session has no start, so the oracle must be unresolved; got %s", report.Resolution) } } + +// control-law: positive-flow-gaps-are-attributable-by-control-class +func TestFlowReportAttributesPositiveGapCategories(t *testing.T) { + repo := prTestRepo(t) + RecordFlowTransition(repo, PublishTransition, deliverycontrol.StateBuild, false) + RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, false, "blocked") + RecordFlowAttribution(repo, "repair.review_repair", deliverycontrol.CostFriction, true, "duplicate") + report, err := FlowReport(repo) + if err != nil { + t.Fatal(err) + } + if report.Regret <= 0 { + t.Fatalf("fixture must produce positive regret: %+v", report) + } + if report.PositiveGapByCategory["readiness"] != 1 || report.PositiveGapByCategory["repair.review_repair"] != 3 { + t.Fatalf("unexpected positive-gap attribution: %+v", report.PositiveGapByCategory) + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_trace.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_trace.go index 80ef76a4c..563038443 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_trace.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_trace.go @@ -57,3 +57,23 @@ func RecordFlowTransition(repo string, transition deliverycontrol.TransitionID, CostClass: deliverycontrol.ChargedCostClass(descriptor.Kind, descriptor.CostClass, outcome), }) } + +func RecordFlowAttribution(repo, category string, cost deliverycontrol.TransitionCostClass, denied bool, note string) { + defer func() { _ = recover() }() + if os.Getenv(flowTraceKillSwitch) == "0" { + return + } + directory, err := flowLogDirectory(repo) + if err != nil { + return + } + outcome := deliverycontrol.OutcomeAllowed + if denied { + outcome = deliverycontrol.OutcomeDenied + } + _ = deliverycontrol.AppendAttempt(directory, deliverycontrol.TransitionAttempt{ + From: deliverycontrol.StateUninitialized, + Transition: deliverycontrol.TransitionID("attribution." + category), + Outcome: outcome, CostClass: cost, Category: category, Note: note, + }) +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/registry.go b/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/registry.go index 3e5133f41..6d24c9653 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/registry.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/registry.go @@ -32,7 +32,13 @@ var registry = []TransitionDescriptor{ ID: "delivery.record_change", From: []StateID{StateTestPassed, StateReviewPassed, StatePublished}, To: StateBuild, Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true, HandlerRef: "RecordChangeObservation", CLIVerb: "record-change", - Note: "Rework resets the addressable slice to BUILD (bounded by RepairAttempt<3); amendment/plan-invalid set Mode; a fully-published delivery emits a corrective child with no state mutation.", + Note: "Rework resets the addressable slice to BUILD (bounded by the typed failure-class counter and a changed mechanism); amendment/plan-invalid set Mode without consuming repair authority; a fully-published delivery emits a corrective child with no state mutation.", + }, + { + ID: "delivery.record_journey_results", From: []StateID{StateBuild, StateTestPassed}, To: StateBuild, + Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true, + HandlerRef: "RecordJourneyResults", CLIVerb: "record-journey-results", + Note: "Records typed PASS/FAIL journey-oracle evidence bound to the compiled manifest, current head commit, and current delivery diff.", }, { ID: "delivery.publish", From: []StateID{StateReviewPassed, StatePublished}, To: StatePublished, diff --git a/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/trajectory.go b/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/trajectory.go index 536269a75..5715c92f2 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/trajectory.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/trajectory.go @@ -22,6 +22,7 @@ type TransitionAttempt struct { Outcome Outcome `json:"outcome"` CostClass TransitionCostClass `json:"cost_class"` Note string `json:"note,omitempty"` + Category string `json:"category,omitempty"` } // Trajectory is an ordered walk of attempts — one real (or replayed) session. @@ -56,9 +57,10 @@ type FlowTrajectoryReport struct { // JCoding is coding effort measured as telemetry and reported ALONGSIDE J_flow. // It is never summed into J_flow and never enters Regret — the decomposition // J = J_flow + J_coding keeps the two costs separate by construction. - JCoding int `json:"j_coding"` - Steps int `json:"steps"` - Resolution Resolution `json:"resolution"` + JCoding int `json:"j_coding"` + Steps int `json:"steps"` + Resolution Resolution `json:"resolution"` + PositiveGapByCategory map[string]int `json:"positive_gap_by_category,omitempty"` } // WalkCost sums a trajectory's observed J_flow: each attempt billed at its @@ -93,6 +95,17 @@ func ComputeReport(t Trajectory, g *Graph, weights FlowCostWeights, goal StateID if oracle.Resolution == Resolved { report.JFlowStar = oracle.Cost report.Regret = report.JFlow - oracle.Cost + if report.Regret > 0 { + report.PositiveGapByCategory = map[string]int{} + for _, attempt := range t { + if attempt.Category == "" { + continue + } + if cost, ok := weights.Cost(attempt.CostClass); ok { + report.PositiveGapByCategory[attempt.Category] += cost + } + } + } } return report } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/journey.go b/labs/12-product-engineering-loop/product-engineering-loop/journey.go new file mode 100644 index 000000000..512e26eff --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/journey.go @@ -0,0 +1,205 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const journeyManifestSchemaVersion = 1 + +type JourneyResult struct { + OracleID string `json:"oracle_id"` + Status string `json:"status"` + Evidence []string `json:"evidence"` +} + +type JourneyResults struct { + SchemaVersion int `json:"schema_version"` + Feature string `json:"feature"` + ManifestSHA256 string `json:"manifest_sha256"` + HeadCommit string `json:"head_commit"` + DiffSHA256 string `json:"diff_sha256"` + Results []JourneyResult `json:"results"` + RecordedAt string `json:"recorded_at"` +} + +type JourneyResultsOptions struct { + Repo string + Feature string + BaseBranch string + InputPath string +} + +func CompileJourneyManifest(plan map[string]any) ([]byte, error) { + version, _ := plan["schema_version"].(float64) + if err := validateJourneyEvidence(plan, version); err != nil { + return nil, err + } + decision, _ := plan["journey_evidence"].(map[string]any) + relevance := "not_relevant" + reason := "legacy plan does not require journey evidence" + oracles := []any{} + if decision != nil { + relevance = strings.ToLower(stringValue(decision["relevance"])) + reason = stringValue(decision["reason"]) + if values, ok := decision["oracles"].([]any); ok { + oracles = values + } + } + body := map[string]any{ + "schema_version": journeyManifestSchemaVersion, + "feature_id": stringValue(plan["feature_id"]), + "relevance": relevance, + "reason": reason, + "oracles": oracles, + } + canonical, err := MarshalJSON(body) + if err != nil { + return nil, err + } + body["manifest_sha256"] = SHA256Bytes(canonical) + return MarshalJSON(body) +} + +func journeyManifestPath(repo, feature string) string { + directory := filepath.Join(WorkspaceFor(repo).GeneratedRoot(), "features", feature) + return featureArtifactPath(directory, filepath.Join("compiled", "journey-oracles.json"), "journey-oracles.json") +} + +func journeyResultsPath(repo, feature string) string { + statePath, err := deliveryStatePath(repo, feature) + if err != nil { + return filepath.Join(repo, ".boatstack-invalid-state", feature, "journey-results.json") + } + return filepath.Join(filepath.Dir(statePath), "journey-results.json") +} + +func loadJourneyManifest(path string) (map[string]any, error) { + value, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var manifest map[string]any + if err := DecodeJSON("journey oracle manifest", path, value, &manifest); err != nil { + return nil, err + } + fingerprint := stringValue(manifest["manifest_sha256"]) + if fingerprint == "" { + return nil, fmt.Errorf("journey oracle manifest fingerprint is missing") + } + delete(manifest, "manifest_sha256") + canonical, err := MarshalJSON(manifest) + if err != nil { + return nil, err + } + if SHA256Bytes(canonical) != fingerprint { + return nil, fmt.Errorf("journey oracle manifest fingerprint does not match its contents") + } + manifest["manifest_sha256"] = fingerprint + return manifest, nil +} + +func RecordJourneyResults(options JourneyResultsOptions) (JourneyResults, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return JourneyResults{}, err + } + manifestPath := journeyManifestPath(repo, options.Feature) + manifest, err := loadJourneyManifest(manifestPath) + if err != nil { + return JourneyResults{}, fmt.Errorf("journey oracle manifest is missing: %w", err) + } + if stringValue(manifest["relevance"]) != "relevant" { + return JourneyResults{}, fmt.Errorf("journey results may only be recorded for a relevant journey manifest") + } + input, err := os.ReadFile(options.InputPath) + if err != nil { + return JourneyResults{}, err + } + var submitted struct { + Results []JourneyResult `json:"results"` + } + if err := DecodeJSON("journey results", options.InputPath, input, &submitted); err != nil { + return JourneyResults{}, err + } + required := map[string]bool{} + oracles, _ := objectSlice(manifest["oracles"]) + for _, oracle := range oracles { + required[stringValue(oracle["id"])] = true + } + seen := map[string]bool{} + for _, result := range submitted.Results { + if !required[result.OracleID] || seen[result.OracleID] { + return JourneyResults{}, fmt.Errorf("journey result has unknown or duplicate oracle %q", result.OracleID) + } + if result.Status != "PASS" && result.Status != "FAIL" { + return JourneyResults{}, fmt.Errorf("journey oracle %s status must be PASS or FAIL", result.OracleID) + } + if len(result.Evidence) == 0 { + return JourneyResults{}, fmt.Errorf("journey oracle %s requires evidence", result.OracleID) + } + for _, evidence := range result.Evidence { + if strings.TrimSpace(evidence) == "" { + return JourneyResults{}, fmt.Errorf("journey oracle %s evidence must be non-empty", result.OracleID) + } + } + seen[result.OracleID] = true + } + for id := range required { + if !seen[id] { + return JourneyResults{}, fmt.Errorf("journey result is missing required oracle %s", id) + } + } + base := strings.TrimSpace(options.BaseBranch) + if base == "" { + base = defaultPRBase(repo) + } + _, headCommit, diffSHA, _, err := currentDiffIdentity(repo, base, "") + if err != nil { + return JourneyResults{}, err + } + results := JourneyResults{ + SchemaVersion: 1, Feature: options.Feature, + ManifestSHA256: stringValue(manifest["manifest_sha256"]), + HeadCommit: headCommit, DiffSHA256: diffSHA, Results: submitted.Results, + RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), + } + sort.Slice(results.Results, func(i, j int) bool { return results.Results[i].OracleID < results.Results[j].OracleID }) + value, _ := MarshalJSON(results) + if err := atomicWriteMode(journeyResultsPath(repo, options.Feature), value, 0o644); err != nil { + return JourneyResults{}, err + } + return results, nil +} + +func checkCurrentJourneyResults(repo, feature, base, headCommit, diffSHA string) error { + manifestPath := journeyManifestPath(repo, feature) + manifest, err := loadJourneyManifest(manifestPath) + if err != nil { + return fmt.Errorf("required journey manifest is missing: %w", err) + } + if stringValue(manifest["relevance"]) != "relevant" { + return nil + } + value, err := os.ReadFile(journeyResultsPath(repo, feature)) + if err != nil { + return fmt.Errorf("required journey results are missing") + } + var results JourneyResults + if err := DecodeJSON("journey results", journeyResultsPath(repo, feature), value, &results); err != nil { + return err + } + if results.ManifestSHA256 != stringValue(manifest["manifest_sha256"]) || results.HeadCommit != headCommit || results.DiffSHA256 != diffSHA { + return fmt.Errorf("required journey results are stale for the current manifest, head, or diff") + } + for _, result := range results.Results { + if result.Status != "PASS" { + return fmt.Errorf("required journey oracle %s did not pass", result.OracleID) + } + } + return nil +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/journey_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/journey_conformance_test.go new file mode 100644 index 000000000..9eaa84f0b --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/journey_conformance_test.go @@ -0,0 +1,113 @@ +package boatstack + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// control-law: journey-evidence-is-explicit-complete-and-fingerprint-bound +func TestJourneyEvidenceDecisionAndManifest(t *testing.T) { + plan := validPlan() + plan["schema_version"] = float64(3) + plan["architecture_facts"] = []any{} + plan["architecture_unknowns"] = []any{} + if err := ValidatePlan(plan, nil); err == nil || !strings.Contains(err.Error(), "journey_evidence") { + t.Fatalf("schema-v3 plan without a journey decision must fail: %v", err) + } + + plan["journey_evidence"] = map[string]any{"relevance": "not_relevant", "reason": ""} + if err := ValidatePlan(plan, nil); err == nil || !strings.Contains(err.Error(), "requires a reason") { + t.Fatalf("not_relevant without a reason must fail: %v", err) + } + + plan["journey_evidence"] = map[string]any{ + "relevance": "relevant", + "oracles": []any{map[string]any{ + "id": "J-1", "type": "cli", "criteria": []any{"AC-1"}, + "entry_point": "boatstack-helper", "steps": []any{"run the command"}, + "expected": []any{"exit zero"}, "run": "go test ./...", + "oracle": "exit status", "independence": "contract-derived", + }}, + } + if err := ValidatePlan(plan, nil); err != nil { + t.Fatalf("complete journey decision rejected: %v", err) + } + first, err := CompileJourneyManifest(plan) + if err != nil { + t.Fatal(err) + } + second, err := CompileJourneyManifest(plan) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Fatal("journey manifest must compile deterministically") + } + var manifest map[string]any + if err := json.Unmarshal(first, &manifest); err != nil { + t.Fatal(err) + } + if stringValue(manifest["manifest_sha256"]) == "" { + t.Fatal("compiled journey manifest must carry its fingerprint") + } + plan["journey_evidence"].(map[string]any)["oracles"].([]any)[0].(map[string]any)["run"] = "definitely-missing-boatstack-capability --check" + if err := checkJourneyCapabilities(t.TempDir(), plan); err == nil || !strings.Contains(err.Error(), "missing command") { + t.Fatalf("missing journey capability must block readiness: %v", err) + } +} + +// control-law: journey-results-must-match-manifest-head-diff-and-pass +func TestJourneyResultsRejectFailureAndStaleness(t *testing.T) { + repo := prTestRepo(t) + feature := "journey-feature" + dir := filepath.Join(repo, ".product-loop", "features", feature, "compiled") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + manifest := map[string]any{ + "schema_version": 1, "feature_id": feature, "relevance": "relevant", + "reason": "", "oracles": []any{map[string]any{"id": "J-1"}}, + } + canonical, _ := MarshalJSON(manifest) + manifest["manifest_sha256"] = SHA256Bytes(canonical) + manifestValue, _ := MarshalJSON(manifest) + if err := os.WriteFile(filepath.Join(dir, "journey-oracles.json"), manifestValue, 0o644); err != nil { + t.Fatal(err) + } + input := filepath.Join(t.TempDir(), "journey-input.json") + if err := os.WriteFile(input, []byte(`{"results":[{"oracle_id":"J-1","status":"FAIL","evidence":["trace"]}]}`), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "record journey fixture") + _, currentHead, currentDiff, _, err := currentDiffIdentity(repo, defaultPRBase(repo), "") + if err != nil { + t.Fatal(err) + } + if err := checkCurrentJourneyResults(repo, feature, defaultPRBase(repo), currentHead, currentDiff); err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("missing journey results must block gates: %v", err) + } + recorded, err := RecordJourneyResults(JourneyResultsOptions{Repo: repo, Feature: feature, InputPath: input}) + if err != nil { + t.Fatal(err) + } + if err := checkCurrentJourneyResults(repo, feature, defaultPRBase(repo), recorded.HeadCommit, recorded.DiffSHA256); err == nil || !strings.Contains(err.Error(), "did not pass") { + t.Fatalf("failed journey must block gates: %v", err) + } + if err := checkCurrentJourneyResults(repo, feature, defaultPRBase(repo), recorded.HeadCommit, "different"); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("stale journey must block gates: %v", err) + } + if err := os.WriteFile(input, []byte(`{"results":[{"oracle_id":"J-1","status":"PASS","evidence":["current trace"]}]}`), 0o644); err != nil { + t.Fatal(err) + } + passed, err := RecordJourneyResults(JourneyResultsOptions{Repo: repo, Feature: feature, InputPath: input}) + if err != nil { + t.Fatal(err) + } + if err := checkCurrentJourneyResults(repo, feature, defaultPRBase(repo), passed.HeadCommit, passed.DiffSHA256); err != nil { + t.Fatalf("current passing journey results must permit progression: %v", err) + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next_actor_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/next_actor_conformance_test.go index bc3ae272a..864bab5a1 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next_actor_conformance_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next_actor_conformance_test.go @@ -96,7 +96,7 @@ func TestNextActorFrontierBoundaries(t *testing.T) { Prescribed: &PrescribedCommand{Verb: "publish", Transition: PublishTransition}, }, NextActorOperator}, {"owed_knowledge_is_operators", NextStatus{ObservedStage: "BUILD"}, FlowNext{ - Prescribed: &PrescribedCommand{Verb: "record-change", RequiresHumanInput: []string{"--message", "--source-stage", "--classification"}}, + Prescribed: &PrescribedCommand{Verb: "record-change", RequiresHumanInput: []string{"--message", "--source-stage", "--classification", "--mechanism"}}, }, NextActorOperator}, {"owed_evidence_stays_agents", NextStatus{ObservedStage: "BUILD"}, FlowNext{ Prescribed: &PrescribedCommand{Verb: "record-delivery-gate", RequiresHumanInput: []string{"--status", "--evidence"}, Transition: deliverycontrol.TransitionID("delivery.record_gate_test")}, diff --git a/labs/12-product-engineering-loop/product-engineering-loop/plan.go b/labs/12-product-engineering-loop/product-engineering-loop/plan.go index 1d2ae642d..879867383 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/plan.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/plan.go @@ -79,6 +79,71 @@ func validationSlice(value any) ([]map[string]any, bool) { return result, true } +func validateJourneyEvidence(plan map[string]any, version float64) error { + if version < 3 { + return nil + } + decision, ok := plan["journey_evidence"].(map[string]any) + if !ok { + return fmt.Errorf("schema-v3 plan requires a journey_evidence decision") + } + relevance := strings.ToLower(strings.TrimSpace(stringValue(decision["relevance"]))) + switch relevance { + case "not_relevant": + if strings.TrimSpace(stringValue(decision["reason"])) == "" { + return fmt.Errorf("journey_evidence not_relevant requires a reason") + } + if oracles, ok := objectSlice(decision["oracles"]); ok && len(oracles) > 0 { + return fmt.Errorf("journey_evidence not_relevant must not declare oracles") + } + return nil + case "relevant": + default: + return fmt.Errorf("journey_evidence.relevance must be relevant or not_relevant") + } + oracles, ok := objectSlice(decision["oracles"]) + if !ok || len(oracles) == 0 { + return fmt.Errorf("relevant journey_evidence requires at least one typed oracle") + } + criteria, _ := objectSlice(plan["acceptance_criteria"]) + criterionIDs := map[string]bool{} + for _, criterion := range criteria { + criterionIDs[stringValue(criterion["id"])] = true + } + seen := map[string]bool{} + for _, oracle := range oracles { + id := strings.TrimSpace(stringValue(oracle["id"])) + mapped, mappedOK := stringSlice(oracle["criteria"]) + steps, stepsOK := stringSlice(oracle["steps"]) + expected, expectedOK := stringSlice(oracle["expected"]) + if id == "" || seen[id] { + return fmt.Errorf("journey oracle ids must be present and unique") + } + seen[id] = true + if !mappedOK || len(mapped) == 0 || !stepsOK || len(steps) == 0 || !expectedOK || len(expected) == 0 { + return fmt.Errorf("journey oracle %s requires criteria, steps, and expected lists", id) + } + for _, values := range [][]string{steps, expected} { + for _, value := range values { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("journey oracle %s steps and expected values must be non-empty", id) + } + } + } + for _, criterion := range mapped { + if !criterionIDs[criterion] { + return fmt.Errorf("journey oracle %s maps unknown criterion: %s", id, criterion) + } + } + for _, field := range []string{"type", "entry_point", "run", "oracle", "independence"} { + if strings.TrimSpace(stringValue(oracle[field])) == "" { + return fmt.Errorf("journey oracle %s requires %s", id, field) + } + } + } + return nil +} + func fencedJSONBlocks(value string) ([]string, error) { lines := strings.Split(value, "\n") blocks := []string{} @@ -355,15 +420,18 @@ func checkApprovalSourcePlan(options ApprovalOptions) error { func ValidatePlan(plan map[string]any, opts *ValidatePlanOptions) error { version, ok := plan["schema_version"].(float64) - if !ok || (version != float64(1) && version != float64(2)) { - return fmt.Errorf("schema_version must be 1 or 2") + if !ok || (version != float64(1) && version != float64(2) && version != float64(3)) { + return fmt.Errorf("schema_version must be 1, 2, or 3") } - if version == float64(2) { + if version >= float64(2) { if err := validateArchitectureGrounding(plan, opts); err != nil { return err } } + if err := validateJourneyEvidence(plan, version); err != nil { + return err + } if err := validateSystemicBoundaries(plan); err != nil { return err @@ -727,7 +795,7 @@ func compilePlanFiles(planPath, outDir, structuredPlanStatus string) error { if err != nil { return err } - // Promote the compiled plan (three files that must land together) through the + // Promote the compiled plan (four files that must land together) through the // transactional mutation boundary: an all-or-nothing atomic write with an // automatic rollback on post-write verification failure and a durable receipt. // This replaces three independent non-atomic os.WriteFile calls that could @@ -747,18 +815,19 @@ func compilePlanFiles(planPath, outDir, structuredPlanStatus string) error { return nil } -// compiledArtifacts holds the compiled trio's mutation operations, their scope, +// compiledArtifacts holds the compiled set's mutation operations, their scope, // per-path base preconditions, the authorizing plan fingerprint, the compiled // tasks.json bytes (so the plan lock can bind their hash without a disk read), // and a post-write verifier. It is the shared spine of both the compiled-plan // mutation and the fully atomic plan-activation mutation. type compiledArtifacts struct { - ops []MutationOperation - scope []string - base map[string]string - authority string - tasksJSON []byte - postCheck func() error + ops []MutationOperation + scope []string + base map[string]string + authority string + tasksJSON []byte + journeyJSON []byte + postCheck func() error } func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) (compiledArtifacts, error) { @@ -790,6 +859,10 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( if err != nil { return compiledArtifacts{}, err } + journeyJSON, err := CompileJourneyManifest(plan) + if err != nil { + return compiledArtifacts{}, err + } absOut, err := filepath.Abs(outDir) if err != nil { return compiledArtifacts{}, err @@ -811,11 +884,15 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( if err != nil { return compiledArtifacts{}, err } + relJourney, err := repositoryRelativePath(repoRoot, filepath.Join(absOut, "journey-oracles.json")) + if err != nil { + return compiledArtifacts{}, err + } authority := "" if check, checkErr := CheckPlan(planPath); checkErr == nil { authority = check.Fingerprint } - scope := []string{relTasks, relMatrix, relEvidence} + scope := []string{relTasks, relMatrix, relEvidence, relJourney} base := map[string]string{} for _, rel := range scope { if hash, hashErr := SHA256File(filepath.Join(repoRoot, filepath.FromSlash(rel))); hashErr == nil { @@ -823,7 +900,7 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( } } postCheck := func() error { - for _, rel := range []string{relTasks, relMatrix} { + for _, rel := range []string{relTasks, relMatrix, relJourney} { value, readErr := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(rel))) if readErr != nil { return readErr @@ -843,12 +920,14 @@ func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) ( {Path: relTasks, Candidate: tasksJSON}, {Path: relMatrix, Candidate: matrixJSON}, {Path: relEvidence, Candidate: []byte(evidence)}, + {Path: relJourney, Candidate: journeyJSON}, }, - scope: scope, - base: base, - authority: authority, - tasksJSON: tasksJSON, - postCheck: postCheck, + scope: scope, + base: base, + authority: authority, + tasksJSON: tasksJSON, + journeyJSON: journeyJSON, + postCheck: postCheck, }, nil } @@ -864,6 +943,7 @@ type ApprovalOptions struct { OutputPath string BaselineDiffSHA256 string BaselineChangedPaths []string + Readiness ReadinessReceipt } type ApprovalReceipt struct { @@ -874,6 +954,7 @@ type ApprovalReceipt struct { Fingerprint string BaselineDiffSHA256 string BaselineChangedPaths []string + Readiness ReadinessReceipt } func LoadApprovalReceipt(path string) (ApprovalReceipt, error) { @@ -892,14 +973,21 @@ func LoadApprovalReceipt(path string) (ApprovalReceipt, error) { Fingerprint: stringValue(value["approval_fingerprint"]), BaselineDiffSHA256: stringValue(value["baseline_diff_sha256"]), BaselineChangedPaths: []string{}, + Readiness: ReadinessReceipt{ + Fingerprint: stringValue(value["readiness_fingerprint"]), + BaseBranch: stringValue(value["base_branch"]), HeadBranch: stringValue(value["head_branch"]), + BaseCommit: stringValue(value["base_commit"]), HeadCommit: stringValue(value["head_commit"]), + Upstream: stringValue(value["upstream"]), Relation: stringValue(value["upstream_relation"]), + JourneyManifestSHA256: stringValue(value["journey_manifest_sha256"]), + }, } if paths, ok := stringSlice(value["baseline_changed_paths"]); ok { receipt.BaselineChangedPaths = paths } else if receipt.SchemaVersion == 2 { return ApprovalReceipt{}, fmt.Errorf("approval receipt baseline_changed_paths must be a string list") } - if receipt.SchemaVersion != 1 && receipt.SchemaVersion != 2 { - return ApprovalReceipt{}, fmt.Errorf("approval receipt schema_version must be 1 or 2") + if receipt.SchemaVersion != 1 && receipt.SchemaVersion != 2 && receipt.SchemaVersion != 3 { + return ApprovalReceipt{}, fmt.Errorf("approval receipt schema_version must be 1, 2, or 3") } if receipt.Status != "APPROVED" { return ApprovalReceipt{}, fmt.Errorf("approval receipt status must be APPROVED") @@ -913,6 +1001,18 @@ func LoadApprovalReceipt(path string) (ApprovalReceipt, error) { if strings.TrimSpace(receipt.Fingerprint) == "" { return ApprovalReceipt{}, fmt.Errorf("approval receipt fingerprint is required") } + if receipt.SchemaVersion == 3 && (receipt.Readiness.Fingerprint == "" || receipt.Readiness.BaseCommit == "" || + receipt.Readiness.HeadCommit == "" || receipt.Readiness.BaseBranch == "" || receipt.Readiness.HeadBranch == "" || + receipt.Readiness.Relation == "" || receipt.Readiness.JourneyManifestSHA256 == "") { + return ApprovalReceipt{}, fmt.Errorf("schema-v3 approval receipt requires complete readiness evidence") + } + if receipt.SchemaVersion == 3 { + receipt.Readiness.PlanFingerprint = receipt.Fingerprint + expected, err := readinessFingerprint(receipt.Readiness) + if err != nil || expected != receipt.Readiness.Fingerprint { + return ApprovalReceipt{}, fmt.Errorf("schema-v3 approval receipt readiness fingerprint does not match its fields") + } + } return receipt, nil } @@ -932,6 +1032,19 @@ func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, er if receipt.Fingerprint != planCheck.Fingerprint { return ApprovalReceipt{}, fmt.Errorf("stale approval receipt: fingerprint does not match the current source plan, spec, and plan") } + if version, _ := planCheck.Plan["schema_version"].(float64); version >= 3 && receipt.SchemaVersion < 3 { + return ApprovalReceipt{}, fmt.Errorf("legacy approval receipt has no readiness evidence; refresh approval against the current schema-v3 plan") + } + if receipt.SchemaVersion == 3 { + receipt.Readiness.PlanFingerprint = receipt.Fingerprint + current, readinessErr := CheckPlanReadiness(planCheck.PlanPath) + if readinessErr != nil { + return ApprovalReceipt{}, readinessErr + } + if current.Fingerprint != receipt.Readiness.Fingerprint { + return ApprovalReceipt{}, fmt.Errorf("stale approval receipt: readiness fingerprint changed after approval") + } + } repo, err := ResolveRepository(filepath.Dir(planCheck.PlanPath)) if err != nil { return ApprovalReceipt{}, err @@ -1019,6 +1132,13 @@ func ActivatePlan(options ActivationOptions) error { OutputPath: options.OutputPath, BaselineDiffSHA256: baseline.DiffSHA256, BaselineChangedPaths: baseline.ChangedPaths, + Readiness: receipt.Readiness, + } + if version, _ := check.Plan["schema_version"].(float64); version >= 3 && authorizationMode == "policy" { + approval.Readiness, err = CheckPlanReadiness(options.PlanPath) + if err != nil { + return err + } } if fileExists(options.OutputPath) { if err := CheckApprovalLock(approval); err == nil { @@ -1050,7 +1170,7 @@ func ActivatePlan(options ActivationOptions) error { if err := guardReactivationPreservesProgress(repo, stringValue(check.Plan["feature_id"]), options.PlanPath); err != nil { return err } - // Assemble the single activation MutationSet: the compiled trio plus the plan + // Assemble the single activation MutationSet: the compiled set plus the plan // lock, all promoted all-or-nothing through the transactional boundary so no // crash or failed post-write check can leave a compiled graph without its lock // (or a lock without its graph). The bytes are built before the atomic promote; @@ -1069,16 +1189,26 @@ func ActivatePlan(options ActivationOptions) error { if currentBaseline.DiffSHA256 != baseline.DiffSHA256 || strings.Join(currentBaseline.ChangedPaths, "\x00") != strings.Join(baseline.ChangedPaths, "\x00") { return fmt.Errorf("pre-activation product baseline drifted before the plan lock could be created; expected paths %s, observed paths %s", strings.Join(baseline.ChangedPaths, ", "), strings.Join(currentBaseline.ChangedPaths, ", ")) } + if approval.Readiness.Fingerprint != "" { + currentReadiness, readinessErr := CheckPlanReadiness(options.PlanPath) + if readinessErr != nil { + return readinessErr + } + if currentReadiness.Fingerprint != approval.Readiness.Fingerprint { + return fmt.Errorf("pre-activation readiness drifted before the immutable plan lock could be created") + } + } if _, err := ApplyMutation(repo, mutation); err != nil { return err } return initializeDeliveryState(repo, stringValue(check.Plan["feature_id"]), options.PlanPath, options.OutputPath) } -// activationMutation assembles the four-artifact activation MutationSet: the -// compiled trio (tasks.json, test-matrix.json, evidence.md) and the plan lock. +// activationMutation assembles the five-artifact activation MutationSet: the +// compiled set (tasks.json, test-matrix.json, evidence.md, journey-oracles.json) +// and the plan lock. // The lock binds the compiled task graph hash from the in-memory candidate bytes -// so all four land in a single atomic promote, and its PostCheck verifies the +// so all five land in a single atomic promote, and its PostCheck verifies the // promoted lock against the plan/spec/source-plan and the promoted task graph. func activationMutation(repoRoot string, options ActivationOptions, structuredPlanStatus string, approval ApprovalOptions) (MutationSet, error) { artifacts, err := compileArtifacts(repoRoot, options.PlanPath, options.OutDir, structuredPlanStatus) @@ -1194,6 +1324,18 @@ func buildApprovalLock(options ApprovalOptions, tasksSHA256 string) ([]byte, err "baseline_diff_sha256": options.BaselineDiffSHA256, "baseline_changed_paths": baselinePaths, } + if options.Readiness.Fingerprint != "" { + lock["schema_version"] = 3 + lock["readiness_fingerprint"] = options.Readiness.Fingerprint + lock["readiness_plan_fingerprint"] = options.Readiness.PlanFingerprint + lock["base_branch"] = options.Readiness.BaseBranch + lock["head_branch"] = options.Readiness.HeadBranch + lock["base_commit"] = options.Readiness.BaseCommit + lock["head_commit"] = options.Readiness.HeadCommit + lock["upstream"] = options.Readiness.Upstream + lock["upstream_relation"] = options.Readiness.Relation + lock["journey_manifest_sha256"] = options.Readiness.JourneyManifestSHA256 + } if mode == "human" { lock["approved_by"] = options.ApprovedBy lock["approved_at"] = approvedAt @@ -1239,7 +1381,7 @@ func CheckApprovalLock(options ApprovalOptions) error { schemaVersion := intValue(lock["schema_version"]) mode := strings.ToLower(stringValue(lock["authorization_mode"])) validStatus := schemaVersion == 1 && stringValue(lock["status"]) == "APPROVED" - if schemaVersion == 2 { + if schemaVersion == 2 || schemaVersion == 3 { validStatus = stringValue(lock["status"]) == "LOCKED" && (mode == "human" || mode == "policy") } if !validStatus || lock["invalidated_at"] != nil { @@ -1251,7 +1393,7 @@ func CheckApprovalLock(options ApprovalOptions) error { } } expectedMode := strings.ToLower(strings.TrimSpace(options.AuthorizationMode)) - if expectedMode != "" && schemaVersion == 2 && mode != expectedMode { + if expectedMode != "" && (schemaVersion == 2 || schemaVersion == 3) && mode != expectedMode { mismatches = append(mismatches, "authorization_mode") } if expectedMode == "policy" && schemaVersion == 1 { @@ -1260,7 +1402,34 @@ func CheckApprovalLock(options ApprovalOptions) error { if options.BaselineDiffSHA256 != "" && stringValue(lock["baseline_diff_sha256"]) != options.BaselineDiffSHA256 { mismatches = append(mismatches, "baseline_diff") } - if schemaVersion != 1 && schemaVersion != 2 { + if schemaVersion == 3 { + for _, field := range []string{"readiness_fingerprint", "readiness_plan_fingerprint", "base_branch", "head_branch", "base_commit", "head_commit", "upstream_relation", "journey_manifest_sha256"} { + if stringValue(lock[field]) == "" { + mismatches = append(mismatches, field) + } + } + storedReadiness := ReadinessReceipt{ + Fingerprint: stringValue(lock["readiness_fingerprint"]), PlanFingerprint: stringValue(lock["readiness_plan_fingerprint"]), + BaseBranch: stringValue(lock["base_branch"]), HeadBranch: stringValue(lock["head_branch"]), + BaseCommit: stringValue(lock["base_commit"]), HeadCommit: stringValue(lock["head_commit"]), + Upstream: stringValue(lock["upstream"]), Relation: stringValue(lock["upstream_relation"]), + JourneyManifestSHA256: stringValue(lock["journey_manifest_sha256"]), + } + if fingerprint, fingerprintErr := readinessFingerprint(storedReadiness); fingerprintErr != nil || fingerprint != storedReadiness.Fingerprint { + mismatches = append(mismatches, "readiness_fingerprint") + } + repo, repoErr := ResolveRepository(filepath.Dir(options.PlanPath)) + plan, planErr := LoadPlan(options.PlanPath) + if repoErr != nil || planErr != nil { + mismatches = append(mismatches, "journey_manifest") + } else { + manifest, manifestErr := loadJourneyManifest(journeyManifestPath(repo, stringValue(plan["feature_id"]))) + if manifestErr != nil || stringValue(manifest["manifest_sha256"]) != stringValue(lock["journey_manifest_sha256"]) { + mismatches = append(mismatches, "journey_manifest") + } + } + } + if schemaVersion != 1 && schemaVersion != 2 && schemaVersion != 3 { mismatches = append(mismatches, "schema_version") } if len(mismatches) > 0 { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/plan_test.go b/labs/12-product-engineering-loop/product-engineering-loop/plan_test.go index e25611407..08900e792 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/plan_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/plan_test.go @@ -225,18 +225,19 @@ func activatePolicyPlan(t *testing.T) (root, planPath, compiled, lock, feature s return root, planPath, compiled, lock, feature } -// TestActivationPromotesFourArtifactsAtomically proves activation lands the -// compiled trio and the plan lock as one transactional mutation: a single +// TestActivationPromotesFiveArtifactsAtomically proves activation lands the +// compiled artifact set and the plan lock as one transactional mutation: a single // receipt whose four recorded post-images match the bytes on disk. Because // ApplyMutation is all-or-nothing (proven at the primitive level), one receipt -// covering all four files is the structural guarantee that no partial set can be +// covering all five files is the structural guarantee that no partial set can be // left behind. -func TestActivationPromotesFourArtifactsAtomically(t *testing.T) { +func TestActivationPromotesFiveArtifactsAtomically(t *testing.T) { root, _, compiled, lock, _ := activatePolicyPlan(t) artifacts := []string{ filepath.Join(compiled, "tasks.json"), filepath.Join(compiled, "test-matrix.json"), filepath.Join(compiled, "evidence.md"), + filepath.Join(compiled, "journey-oracles.json"), lock, } for _, path := range artifacts { @@ -258,8 +259,8 @@ func TestActivationPromotesFourArtifactsAtomically(t *testing.T) { if activation == nil { t.Fatalf("no APPLIED plan-activation receipt among %d receipts", len(receipts)) } - if len(activation.Changes) != 4 { - t.Fatalf("expected one atomic mutation over four artifacts, got %d changes", len(activation.Changes)) + if len(activation.Changes) != 5 { + t.Fatalf("expected one atomic mutation over five artifacts, got %d changes", len(activation.Changes)) } for _, change := range activation.Changes { hash, err := SHA256File(filepath.Join(root, filepath.FromSlash(change.Path))) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/planning.go b/labs/12-product-engineering-loop/product-engineering-loop/planning.go index f47536f77..813812789 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/planning.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/planning.go @@ -304,15 +304,32 @@ func RecordApproval(options ApprovalRecordOptions) error { } return fmt.Errorf("baseline product diff drifted after it was displayed") } - payload, err := MarshalJSON(map[string]any{ - "schema_version": 2, + schemaVersion := 2 + payloadValue := map[string]any{ + "schema_version": schemaVersion, "status": "APPROVED", "approved_by": strings.TrimSpace(options.ApprovedBy), "approved_at": approvedAt.Format(time.RFC3339), "approval_fingerprint": check.Fingerprint, "baseline_diff_sha256": baseline.DiffSHA256, "baseline_changed_paths": baseline.ChangedPaths, - }) + } + if version, _ := check.Plan["schema_version"].(float64); version >= 3 { + readiness, readinessErr := CheckPlanReadiness(options.PlanPath) + if readinessErr != nil { + return readinessErr + } + payloadValue["schema_version"] = 3 + payloadValue["readiness_fingerprint"] = readiness.Fingerprint + payloadValue["base_branch"] = readiness.BaseBranch + payloadValue["head_branch"] = readiness.HeadBranch + payloadValue["base_commit"] = readiness.BaseCommit + payloadValue["head_commit"] = readiness.HeadCommit + payloadValue["upstream"] = readiness.Upstream + payloadValue["upstream_relation"] = readiness.Relation + payloadValue["journey_manifest_sha256"] = readiness.JourneyManifestSHA256 + } + payload, err := MarshalJSON(payloadValue) if err != nil { return err } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/post_publish_prescribe_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/post_publish_prescribe_conformance_test.go index 166f9323a..c84357e07 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/post_publish_prescribe_conformance_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/post_publish_prescribe_conformance_test.go @@ -66,7 +66,7 @@ func TestMergedTerminalPrescribesPostPublishSteps(t *testing.T) { wantVerb: "record-change", wantTransition: "delivery.record_change", wantInCommand: "--source-stage ci", - wantOwed: []string{"--message", "--classification"}, + wantOwed: []string{"--message", "--classification", "--mechanism"}, }, { name: "merge_eligible_prescribes_gh_merge", diff --git a/labs/12-product-engineering-loop/product-engineering-loop/pr.go b/labs/12-product-engineering-loop/product-engineering-loop/pr.go index 1b846cec2..903caf5c4 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/pr.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/pr.go @@ -437,9 +437,6 @@ func managedPRSources(repo, feature string) ([]PRSource, map[string]string, erro authorizationMode := "policy" if config.Workflow.HumanPlanApproval { authorizationMode = "human" - if _, err := CheckApprovalReceipt(approvalPath, check); err != nil { - return nil, nil, fmt.Errorf("managed PR requires current approval: %w", err) - } } tasksPath := featureArtifactPath(directory, filepath.Join("compiled", "tasks.json"), "tasks.json") if err := CheckApprovalLock(ApprovalOptions{ @@ -452,6 +449,10 @@ func managedPRSources(repo, feature string) ([]PRSource, map[string]string, erro }); err != nil { return nil, nil, fmt.Errorf("managed PR requires a current build lock: %w", err) } + // After activation the immutable lock, rather than the mutable pre-activation + // working-tree representation, is the authorization oracle. This preserves + // authority when approved dirty content is merely committed, while plan or + // compiled graph drift still fails the hash checks above. evidencePath := featureEvidencePath(directory) if err := checkNonEmptyFile(evidencePath, "feature evidence"); err != nil { return nil, nil, err diff --git a/labs/12-product-engineering-loop/product-engineering-loop/readiness.go b/labs/12-product-engineering-loop/product-engineering-loop/readiness.go new file mode 100644 index 000000000..e1360fcac --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/readiness.go @@ -0,0 +1,115 @@ +package boatstack + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type ReadinessReceipt struct { + Fingerprint string + PlanFingerprint string + BaseBranch string + HeadBranch string + BaseCommit string + HeadCommit string + Upstream string + Relation string + JourneyManifestSHA256 string +} + +func readinessFingerprint(receipt ReadinessReceipt) (string, error) { + canonical, err := MarshalJSON(map[string]any{ + "schema_version": 1, "plan_fingerprint": receipt.PlanFingerprint, + "base_branch": receipt.BaseBranch, "head_branch": receipt.HeadBranch, + "base_commit": receipt.BaseCommit, "head_commit": receipt.HeadCommit, + "upstream": receipt.Upstream, "relation": receipt.Relation, + "journey_manifest_sha256": receipt.JourneyManifestSHA256, + }) + if err != nil { + return "", err + } + return SHA256Bytes(canonical), nil +} + +func CheckPlanReadiness(planPath string) (ReadinessReceipt, error) { + check, err := CheckPlan(planPath) + if err != nil { + return ReadinessReceipt{}, err + } + repo, err := ResolveRepository(filepath.Dir(planPath)) + if err != nil { + return ReadinessReceipt{}, err + } + config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + if err != nil { + return ReadinessReceipt{}, fmt.Errorf("readiness requires valid Boatstack configuration: %w", err) + } + if err := guardManagedActivationWorktree(repo, config, stringValue(check.Plan["feature_id"])); err != nil { + return ReadinessReceipt{}, err + } + preflight := CheckRunPreflight(repo, "") + if preflight.VerificationStatus != "VERIFIED" { + return ReadinessReceipt{}, fmt.Errorf("readiness blocked (%s): %s; recover the workspace and retry approval", preflight.Relation, preflight.Reason) + } + manifest, err := CompileJourneyManifest(check.Plan) + if err != nil { + return ReadinessReceipt{}, fmt.Errorf("journey capability is not ready: %w", err) + } + if err := checkJourneyCapabilities(repo, check.Plan); err != nil { + return ReadinessReceipt{}, err + } + var manifestValue map[string]any + if err := DecodeJSON("compiled journey oracle manifest", "journey-oracles.json", manifest, &manifestValue); err != nil { + return ReadinessReceipt{}, err + } + baseCommit, err := runGitCommand(repo, "rev-parse", "refs/remotes/origin/"+preflight.BaseBranch+"^{commit}") + if err != nil { + return ReadinessReceipt{}, err + } + headCommit, err := runGitCommand(repo, "rev-parse", "HEAD^{commit}") + if err != nil { + return ReadinessReceipt{}, err + } + receipt := ReadinessReceipt{ + PlanFingerprint: check.Fingerprint, BaseBranch: preflight.BaseBranch, + HeadBranch: preflight.HeadBranch, BaseCommit: strings.TrimSpace(baseCommit), + HeadCommit: strings.TrimSpace(headCommit), Upstream: preflight.Upstream, + Relation: preflight.Relation, JourneyManifestSHA256: stringValue(manifestValue["manifest_sha256"]), + } + fingerprint, err := readinessFingerprint(receipt) + if err != nil { + return ReadinessReceipt{}, err + } + receipt.Fingerprint = fingerprint + return receipt, nil +} + +func checkJourneyCapabilities(repo string, plan map[string]any) error { + decision, _ := plan["journey_evidence"].(map[string]any) + if strings.ToLower(stringValue(decision["relevance"])) != "relevant" { + return nil + } + oracles, _ := objectSlice(decision["oracles"]) + for _, oracle := range oracles { + fields := strings.Fields(stringValue(oracle["run"])) + if len(fields) == 0 { + return fmt.Errorf("journey capability %s has no runnable command", stringValue(oracle["id"])) + } + command := fields[0] + if strings.Contains(command, "/") { + if !filepath.IsAbs(command) { + command = filepath.Join(repo, command) + } + info, err := os.Stat(command) + if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return fmt.Errorf("journey capability %s is missing executable %s", stringValue(oracle["id"]), fields[0]) + } + } else if _, err := exec.LookPath(command); err != nil { + return fmt.Errorf("journey capability %s is missing command %s", stringValue(oracle["id"]), command) + } + } + return nil +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/readiness_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/readiness_conformance_test.go new file mode 100644 index 000000000..e3f48b074 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/readiness_conformance_test.go @@ -0,0 +1,125 @@ +package boatstack + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// control-law: activation-requires-current-readiness-bound-to-exact-authority +func TestApprovalAndActivationBindSameReadinessFingerprint(t *testing.T) { + repo := runTestRepo(t) + remote := filepath.Join(t.TempDir(), "origin.git") + if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v: %s", err, output) + } + runGit(t, repo, "remote", "add", "origin", remote) + runGit(t, repo, "push", "-u", "origin", "main") + runGit(t, repo, "switch", "-c", "readiness-feature") + + feature := "readiness-feature" + dir := filepath.Join(repo, ".product-loop", "features", feature) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "source-plan.md"), []byte("# source\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "feature-spec.md"), []byte("# spec\n"), 0o644); err != nil { + t.Fatal(err) + } + plan := validPlan() + plan["schema_version"] = float64(3) + plan["feature_id"] = feature + plan["spec_path"] = "feature-spec.md" + plan["architecture_facts"] = []any{} + plan["architecture_unknowns"] = []any{} + task := plan["tasks"].([]any)[0].(map[string]any) + task["requires_facts"] = []any{} + task["affected_paths"] = []any{"README.md"} + task["rollback_boundary"] = "revert the change" + task["side_effects"] = []any{} + plan["journey_evidence"] = map[string]any{ + "relevance": "not_relevant", "reason": "internal control-only change", "oracles": []any{}, + } + planPath := filepath.Join(dir, "plan.md") + writeMarkdownPlan(t, planPath, plan, true) + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + readiness, err := CheckPlanReadiness(planPath) + if err != nil { + t.Fatal(err) + } + approvalPath := filepath.Join(dir, "approval.md") + runGit(t, repo, "remote", "rename", "origin", "temporarily-unavailable") + if err := RecordApproval(ApprovalRecordOptions{ + PlanPath: planPath, OutputPath: approvalPath, ApprovedBy: "Test Human", + ApprovedAt: "2026-07-29T12:00:00Z", Fingerprint: check.Fingerprint, + }); err == nil { + t.Fatal("missing readiness allowed approval") + } + if fileExists(approvalPath) { + t.Fatal("blocked readiness created an approval artifact") + } + runGit(t, repo, "remote", "rename", "temporarily-unavailable", "origin") + writeApprovalReceipt(t, approvalPath, check.Fingerprint) + if _, err := CheckApprovalReceipt(approvalPath, check); err == nil { + t.Fatal("unactivated legacy approval must not authorize a schema-v3 plan") + } + if err := RecordApproval(ApprovalRecordOptions{ + PlanPath: planPath, OutputPath: approvalPath, ApprovedBy: "Test Human", + ApprovedAt: "2026-07-29T12:00:00Z", Fingerprint: check.Fingerprint, + }); err != nil { + t.Fatal(err) + } + receipt, err := LoadApprovalReceipt(approvalPath) + if err != nil { + t.Fatal(err) + } + if receipt.SchemaVersion != 3 || receipt.Readiness.Fingerprint != readiness.Fingerprint { + t.Fatalf("approval readiness mismatch: %+v vs %+v", receipt.Readiness, readiness) + } + lockPath := filepath.Join(dir, "plan.lock.json") + if err := ActivatePlan(ActivationOptions{ + PlanPath: planPath, ApprovalPath: approvalPath, + OutDir: filepath.Join(dir, "compiled"), OutputPath: lockPath, + }); err != nil { + t.Fatal(err) + } + lockValue, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + var lock map[string]any + if err := json.Unmarshal(lockValue, &lock); err != nil { + t.Fatal(err) + } + if intValue(lock["schema_version"]) != 3 || stringValue(lock["readiness_fingerprint"]) != readiness.Fingerprint { + t.Fatalf("activation lock did not preserve approval readiness: %+v", lock) + } + manifestPath := filepath.Join(dir, "compiled", "journey-oracles.json") + manifestValue, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + var manifest map[string]any + if err := json.Unmarshal(manifestValue, &manifest); err != nil { + t.Fatal(err) + } + manifest["reason"] = "tampered after activation" + tampered, _ := MarshalJSON(manifest) + if err := os.WriteFile(manifestPath, tampered, 0o644); err != nil { + t.Fatal(err) + } + if err := CheckApprovalLock(ApprovalOptions{ + SourcePlanPath: filepath.Join(dir, "source-plan.md"), SpecPath: filepath.Join(dir, "feature-spec.md"), + PlanPath: planPath, TasksPath: filepath.Join(dir, "compiled", "tasks.json"), + AuthorizationMode: "human", OutputPath: lockPath, + }); err == nil { + t.Fatal("tampered journey manifest bypassed the immutable lock") + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md b/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md index 9e61e0abe..4b8b32391 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md @@ -13,9 +13,11 @@ Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Com | Markdown plan | Human-readable plan plus its one marked structured block; canonical before and during build | A spec is resolved enough to propose tasks and checks | | Approval receipt | Named human, timestamp, and fingerprint in Markdown; not executable state | The exact draft is explicitly approved in Plan mode | | Compiled tasks | Deterministic dependency graph generated from the approved Markdown plan | Build activation succeeds | +| Journey oracle manifest | Fingerprinted typed journey oracles compiled from the plan-level decision | Build activation succeeds | +| Journey results | PASS/FAIL and evidence bound to the oracle manifest, head commit, and diff | Before a relevant journey reaches test or review gate | | Delivery state | Ignored worktree-local Git active-slice state bound to the approved plan lock; never an approval artifact | Build activation and successful slice publication | | `changes.md` | Append-only, reviewable post-build observations with exact user message, expected/actual behavior, classification, evidence, and resolution | Controlled `record-change` transition | -| Repair state | Ignored delivery mode, resume stage, active observation, attempt count, and superseded receipt references | Controlled repair and gate transitions | +| Repair state | Ignored delivery mode, resume stage, class-specific attempt counters, active mechanism observation, and superseded receipt references | Controlled repair and gate transitions | | Recovery status | Read-only active/published delivery, PR lifecycle, branch/SHA identity, ambiguity, and safe next transition | Before responding to CI, review, publication denial, or ordinary correction language | | Operation receipt | Ignored Git-common identity, fingerprinted authority, lease, durable attempt budget, expected postcondition, and secret-free completion observation | Before and after each managed mutation or external side effect | | Installation repair receipt | Ignored Git-common installed/target version, direction, owned-state classifications, exact path hashes, repair fingerprint, and backup location | An update discovers or repairs Boatstack-owned control drift | @@ -89,7 +91,7 @@ ledger while the publisher rechecks the matching receipts. ## Planning boundary -`auto-plan` and `plan-gate` create or update Markdown only. `plan.md` is the canonical structured input and schema-v2 `approval.md` binds human approval to both the plan fingerprint and the displayed pre-activation product-diff baseline. Compiled JSON and `plan.lock.json` begin only at `build` activation, after the receipt and unchanged baseline are verified. Schema-v1 receipts remain compatible only when that baseline is clean. This keeps planning compatible with hosts that intentionally restrict Plan mode to documents while preserving edits that predated managed authority. +`auto-plan` and `plan-gate` create or update Markdown only. `plan.md` is the canonical structured input. New schema-v3 plans require a journey-evidence decision, and schema-v3 `approval.md` binds human approval to the plan, displayed product baseline, exact branch/base/head relation, and compiled journey-manifest fingerprint. Activation repeats readiness and stores it in the immutable lock. Schema-v1/v2 receipts and active locks remain readable, but an unactivated legacy approval has no readiness authority and must be refreshed for a schema-v3 plan. ## Safety boundary diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md index 6ea0bfc73..41bb5a8ef 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md @@ -36,7 +36,7 @@ A published delivery cannot be reset. Its correction uses a deterministic new fe `repair-state` is the bounded recovery for the one state `recovery-status` cannot resolve: an unregistered feature draft whose `plan.md` never passed through the helper, so it has no plan lock and no delivery state. A malformed such draft makes the guard fail closed at `INVALID_STATE` and deny every product mutation. `repair-state` quarantines that directory out of `.product-loop/features/` into `/boatstack/quarantine//` — reversible, never a hard delete — and returns the workflow to `auto-plan`. It resolves the sole malformed candidate when `--feature` is omitted and refuses ambiguity. It acts only on a directory carrying no durable authority: it refuses any feature with a valid saved plan, a plan lock, a `pr.md`, a managed delivery state, git-tracked files, or an active or published delivery. The guard allowlists it independent of stage but still rejects shell metacharacters and non-helper executables; gating for registered, active, or published deliveries is unchanged. -The **transactional mutation boundary** is the standing form of the same principle `repair-state` applies once: a supervisor that removes an actuator must still expose a bounded actuator capable of reaching every valid state, including reversing its own last move. When Boatstack promotes a managed artifact that spans files which must land together, the candidate bytes are submitted as a mutation set that the runtime confirms against per-file base hashes and a supervisor-authority token, writes atomically all-or-nothing, verifies after the write, and rolls back automatically on failure — recording a reversible receipt with per-file before/after hashes. A rejected mutation persists no identity, so a candidate recomputed against the current base and authority applies cleanly: refusal is fail-closed but never a deadlock. Plan activation promotes all four of its managed artifacts — the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, and the `plan.lock.json` — through a single mutation, so they land or fail together. +The **transactional mutation boundary** is the standing form of the same principle `repair-state` applies once: a supervisor that removes an actuator must still expose a bounded actuator capable of reaching every valid state, including reversing its own last move. When Boatstack promotes a managed artifact that spans files which must land together, the candidate bytes are submitted as a mutation set that the runtime confirms against per-file base hashes and a supervisor-authority token, writes atomically all-or-nothing, verifies after the write, and rolls back automatically on failure — recording a reversible receipt with per-file before/after hashes. A rejected mutation persists no identity, so a candidate recomputed against the current base and authority applies cleanly: refusal is fail-closed but never a deadlock. Plan activation promotes all five of its managed artifacts — the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, `journey-oracles.json`, and the `plan.lock.json` — through a single mutation, so they land or fail together. The boundary is **closed under inversion**: every receipt carries its own inverse bytes and *is* the undo command. Undo re-applies that inverse as an ordinary mutation through the same boundary (an explicit absent operation expresses a delete, so the inverse of a create is a first-class mutation), which makes undo atomic and verified, makes the base precondition the conflict guard (undo refuses rather than clobbering later work), and makes **redo just an undo of the undo receipt**. Two bounded verbs expose this and are allowlisted by the guard at any stage (like `repair-state`, still rejecting shell metacharacters and non-helper executables): `mutation-status` (read-only) lists or inspects receipts so an agent can find the one to reverse, and `undo --mutation ` reverses it. `undo` is state-aware — it refuses to reverse a plan activation once a delivery gate receipt exists, so it can never strand delivery state without its lock. This boundary governs Boatstack's own generated managed artifacts only, never coding-agent source-code editing. @@ -294,8 +294,8 @@ At the host's normal Build transition, first confirm the host is in an execution 1. parse and validate the marked structured block in `plan.md`; 2. hash the complete source plan, spec, `plan.md`, and pre-activation product baseline, matching them to `approval.md` when human approval is enabled; 3. compile the task graph, requirement-test traceability rows, and evidence skeleton without adding semantics; -4. record authorization mode, timestamp, source commit, artifact hashes (the compiled task-graph hash bound from the in-memory candidate), and baseline diff/path provenance in plan-lock schema v2, plus approver provenance only for human authorization; -5. promote all four artifacts (`compiled/tasks.json`, `compiled/test-matrix.json`, `compiled/evidence.md`, and `plan.lock.json`) through the transactional mutation boundary as one mutation, whose post-write check re-validates the compiled JSON, asserts the evidence ledger is non-empty, and rechecks the promoted lock before permitting implementation — so all four land all-or-nothing and the reversible receipt can undo the whole activation. +4. record authorization mode, timestamp, source commit, artifact hashes (the compiled task-graph hash bound from the in-memory candidate), readiness fingerprint, and baseline diff/path provenance in plan-lock schema v3, plus approver provenance only for human authorization; +5. promote all five artifacts (`compiled/tasks.json`, `compiled/test-matrix.json`, `compiled/evidence.md`, `compiled/journey-oracles.json`, and `plan.lock.json`) through the transactional mutation boundary as one mutation, whose post-write check re-validates the compiled JSON, asserts the evidence ledger is non-empty, and rechecks the promoted lock before permitting implementation — so all five land all-or-nothing and the reversible receipt can undo the whole activation. Activation also initializes ignored, worktree-local Git delivery state bound to the lock. One implicit `delivery` slice preserves the ordinary one-feature/one-PR flow. An @@ -461,9 +461,12 @@ is explicit and never resolved by recency. Receipts are ignored Git-common state shared by linked worktrees. They store hashes and bounded facts, not commands, secrets, user content, or autonomous execution intent. -`boatstack-run` consults this state before advancing. Its three-cycle repair budget -is the delivery state's durable `repair_attempt`, not a counter reset by a new -conversation, process, host, or async notification. +`boatstack-run` consults this state before advancing. Its three-cycle repair +budgets are the delivery state's durable schema-v2 counters for implementation, +verification, and review failures, not a counter reset by a new conversation, +process, host, or async notification. Every repair records its mechanism. An +identical class, evidence, and mechanism retry is denied; requirement amendments +and readiness recovery consume no counter. ## Existing and ad-hoc PRs diff --git a/labs/12-product-engineering-loop/product-engineering-loop/repair_budget_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/repair_budget_conformance_test.go new file mode 100644 index 000000000..5a2dc2b42 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/repair_budget_conformance_test.go @@ -0,0 +1,73 @@ +package boatstack + +import ( + "encoding/json" + "strings" + "testing" +) + +// control-law: repair-authority-is-class-specific-and-mechanism-sensitive +func TestTypedRepairBudgetsAndDuplicateMechanisms(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + for _, class := range []string{"implementation_repair", "verification_repair"} { + for attempt := 1; attempt <= 3; attempt++ { + _, state, err := RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "repair", SourceStage: "test", + Classification: class, Evidence: "failure", Mechanism: class + "-" + string(rune('0'+attempt)), + }) + if err != nil { + t.Fatalf("%s attempt %d rejected: %v", class, attempt, err) + } + if state.RepairCounters[class] != attempt { + t.Fatalf("%s counter=%d, want %d", class, state.RepairCounters[class], attempt) + } + } + } + _, before, err := RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "duplicate", SourceStage: "test", + Classification: "review_repair", Evidence: "same", Mechanism: "same mechanism", + }) + if err != nil { + t.Fatal(err) + } + _, _, err = RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "duplicate", SourceStage: "test", + Classification: "review_repair", Evidence: "same", Mechanism: "same mechanism", + }) + if err == nil || !strings.Contains(err.Error(), "identical") { + t.Fatalf("duplicate mechanism must be denied as friction: %v", err) + } + after, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + if after.RepairCounters["review_repair"] != before.RepairCounters["review_repair"] { + t.Fatal("denied duplicate must not consume repair authority") + } + _, changed, err := RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "changed mechanism", SourceStage: "test", + Classification: "review_repair", Evidence: "same", Mechanism: "different mechanism", + }) + if err != nil || changed.RepairCounters["review_repair"] != 2 { + t.Fatalf("changed mechanism must consume the next class-specific attempt: state=%+v err=%v", changed.RepairCounters, err) + } +} + +// control-law: legacy-exhaustion-cannot-gain-new-retry-authority +func TestLegacyRepairAttemptMigratesToEveryClass(t *testing.T) { + raw := []byte(`{"schema_version":1,"repair_attempt":3}`) + upgraded, changed, err := migrateDeliveryStateBytes(raw) + if err != nil || !changed { + t.Fatalf("legacy delivery state migration failed: changed=%v err=%v", changed, err) + } + var value map[string]any + if err := json.Unmarshal(upgraded, &value); err != nil { + t.Fatal(err) + } + counters := value["repair_counters"].(map[string]any) + for _, class := range []string{"implementation_repair", "verification_repair", "review_repair"} { + if intValue(counters[class]) != 3 { + t.Fatalf("%s inherited %v, want 3", class, counters[class]) + } + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety_corpus_test.go b/labs/12-product-engineering-loop/product-engineering-loop/safety_corpus_test.go index c5f786ce6..44baa99fc 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety_corpus_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety_corpus_test.go @@ -120,7 +120,7 @@ func TestGuardCorpusDualReward(t *testing.T) { {"pick-undo", "", `boatstack-helper undo --mutation abc123`, "routine", false}, {"pick-discard-delivery", "", `boatstack-helper discard-delivery --feature stale`, "routine", false}, {"pick-operation-status", "", `boatstack-helper operation-status`, "routine", false}, - {"pick-record-change", "", `boatstack-helper record-change --feature demo --slice s1 --message fix --source-stage ci --classification implementation_repair`, "routine", false}, + {"pick-record-change", "", `boatstack-helper record-change --feature demo --slice s1 --message fix --source-stage ci --classification implementation_repair --mechanism retry-with-corrected-state`, "routine", false}, {"pick-next-status", "", `boatstack-helper next-status`, "routine", false}, }