diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index e45cd99..d4a6721 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -52,6 +52,7 @@ type State struct { RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` RuntimeSource string `json:"runtime_source_revision,omitempty"` PlanFingerprint string `json:"plan_fingerprint,omitempty"` + ApprovalFingerprint string `json:"approval_fingerprint,omitempty"` WorkspaceBranch string `json:"workspace_branch,omitempty"` WorkspacePath string `json:"workspace_path,omitempty"` WorkspaceBaseRef string `json:"workspace_base_ref,omitempty"` diff --git a/boatstack/internal/softwaredelivery/durable/state_facet.go b/boatstack/internal/softwaredelivery/durable/state_facet.go index a92a2c8..5413ed1 100644 --- a/boatstack/internal/softwaredelivery/durable/state_facet.go +++ b/boatstack/internal/softwaredelivery/durable/state_facet.go @@ -21,7 +21,7 @@ var stateFieldFacets = map[string]model.StateFacet{ "ConfigFingerprint": model.StateFacetControl, "PlanApprovalPolicy": model.StateFacetControl, "VisualEvidencePolicy": model.StateFacetControl, "ExternalEffectPolicy": model.StateFacetControl, "IndependentReview": model.StateFacetControl, "EnabledHosts": model.StateFacetControl, "RuntimeVersion": model.StateFacetInstallation, "RuntimeFingerprint": model.StateFacetInstallation, "RuntimeSource": model.StateFacetInstallation, - "PlanFingerprint": model.StateFacetProduct, + "PlanFingerprint": model.StateFacetProduct, "ApprovalFingerprint": model.StateFacetProduct, "WorkspaceBranch": model.StateFacetProduct, "WorkspacePath": model.StateFacetProduct, "WorkspaceBaseRef": model.StateFacetProduct, "WorkspaceSourcePath": model.StateFacetProduct, "WorkspaceSourceID": model.StateFacetProduct, "WorkspaceSourceRef": model.StateFacetProduct, "PublicationID": model.StateFacetProduct, "PublicationURL": model.StateFacetProduct, "PreviewFingerprint": model.StateFacetProduct, diff --git a/boatstack/internal/softwaredelivery/durable/state_schema_test.go b/boatstack/internal/softwaredelivery/durable/state_schema_test.go index dd214ac..bcb820b 100644 --- a/boatstack/internal/softwaredelivery/durable/state_schema_test.go +++ b/boatstack/internal/softwaredelivery/durable/state_schema_test.go @@ -1,6 +1,11 @@ package durable -import "testing" +import ( + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) func TestStateRejectsPriorObjectiveSchema(t *testing.T) { state := State{SchemaVersion: StateSchemaVersion - 1} @@ -8,3 +13,24 @@ func TestStateRejectsPriorObjectiveSchema(t *testing.T) { t.Fatal("prior objective state schema was accepted") } } + +func TestStateSchemaPermitsLegacyApprovedStateWithoutApprovalFingerprint(t *testing.T) { + state := State{ + SchemaVersion: StateSchemaVersion, RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Revision: 1, + Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryApproved, Workspace: model.WorkspaceAbsent, + Plan: model.PlanApproved, Configuration: model.ConfigurationUnsupported, Runtime: model.RuntimeAbsent, Publication: model.PublicationNone, + Verification: model.VerificationUnverified, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, + Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"}, PlanFingerprint: "legacy-plan", UpdatedAt: time.Unix(1, 0).UTC(), + } + raw, err := EncodeState(state) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeState(raw) + if err != nil { + t.Fatal(err) + } + if decoded.ApprovalFingerprint != "" { + t.Fatalf("legacy approval fingerprint = %q", decoded.ApprovalFingerprint) + } +} diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 7ba02d2..685d066 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -151,6 +151,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio } mutations = append(mutations, approvalMutation) state.PlanFingerprint = fingerprint + state.ApprovalFingerprint = "" case "plan.validate": path := filepath.Join(artifactRoot, "plans", deliveryID+".source") raw, readErr := os.ReadFile(path) @@ -174,12 +175,14 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, mutationErr } mutations = append(mutations, mutation) + state.ApprovalFingerprint = sha256Bytes(raw) case "evidence.approval.revoke": mutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "approvals", deliveryID+".json"), nil, 0o644, false, true) if mutationErr != nil { return nil, mutationErr } mutations = append(mutations, mutation) + state.ApprovalFingerprint = "" case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record": revision, _ := admission.Parameters.Get("source_revision") evidencePath, _ := admission.Parameters.Get("evidence_path") @@ -299,6 +302,70 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return mutations, nil } +// prepareWorkspacePlanTransfer carries runtime-owned plan artifacts into a +// newly cut worktree. A run binds the plan bytes before the cut, so the target +// worktree must observe those exact bytes rather than fall back to the inbox +// and accidentally select new intent. +func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, expectedPlanFingerprint, expectedApprovalFingerprint string) ([]ports.ResourceMutation, error) { + if expectedPlanFingerprint == "" || deliveryID == "" { + return nil, nil + } + if workspacePath == "" || expectedApprovalFingerprint == "" { + return nil, fmt.Errorf("workspace plan transfer requires destination and exact approval for a bound plan") + } + deliveryID, err := safeSegment(deliveryID, "delivery identity") + if err != nil { + return nil, err + } + sourceRoot := filepath.Join(repositoryRoot, ".boatstack") + destinationRoot := filepath.Join(workspacePath, ".boatstack") + planPath := filepath.Join(sourceRoot, "plans", deliveryID+".source") + planRaw, err := readRegularWorkspacePlanArtifact(planPath) + if err != nil { + return nil, err + } + if actual := sha256Bytes(planRaw); actual != expectedPlanFingerprint { + return nil, fmt.Errorf("workspace plan artifact fingerprint changed: got %s", actual) + } + approvalPath := filepath.Join(sourceRoot, "approvals", deliveryID+".json") + approvalRaw, err := readRegularWorkspacePlanArtifact(approvalPath) + if err != nil { + return nil, err + } + if actual := sha256Bytes(approvalRaw); actual != expectedApprovalFingerprint { + return nil, fmt.Errorf("workspace approval artifact fingerprint changed: got %s", actual) + } + var approval approvalArtifact + if err := decodeStrictArtifact(approvalRaw, &approval); err != nil || approval.SchemaVersion != 1 || approval.DeliveryID != deliveryID || + approval.PlanFingerprint != expectedPlanFingerprint || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { + return nil, fmt.Errorf("workspace approval artifact does not bind the admitted plan") + } + planMutation, err := mutationFor(filepath.Join(destinationRoot, "plans", deliveryID+".source"), planRaw, 0o644, false, false) + if err != nil { + return nil, err + } + approvalMutation, err := mutationFor(filepath.Join(destinationRoot, "approvals", deliveryID+".json"), approvalRaw, 0o644, false, false) + if err != nil { + return nil, err + } + return []ports.ResourceMutation{planMutation, approvalMutation}, nil +} + +func readRegularWorkspacePlanArtifact(path string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("inspect workspace plan artifact %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("workspace plan artifact is not a regular file: %s", path) + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read workspace plan artifact %s: %w", path, err) + } + return raw, nil +} + func transitionUsesDeliveryArtifacts(id catalog.TransitionID) bool { switch id { case "plan.create", "plan.amend", "plan.validate", "plan.approve", "plan.approve-amendment", diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index b23fc29..cbf4b4d 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -239,7 +239,7 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "push", "--set-upstream", "origin", preview.HeadRef); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } - if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "create", "--base", preview.BaseRef, "--head", preview.HeadRef, "--body-file", preview.BodyPath); err != nil { + if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "create", "--base", preview.BaseRef, "--head", preview.HeadRef, "--fill-first", "--body-file", preview.BodyPath); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } case "publication.correct": diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index 2cc694d..c8287a8 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -176,6 +176,54 @@ func TestPublicationPreviewRejectsFieldTamperingUnderAnOldFingerprint(t *testing } } +func TestPublicationExecutionUsesBoundBodyAndNoninteractiveTitle(t *testing.T) { + runner := &boundaryRunner{} + boundary, err := NewNativeBoundaryWithRunner(runner) + if err != nil { + t.Fatal(err) + } + transition, _ := testprogram.StandardRegistry().Lookup("publication.execute") + layout := writeBoundaryConfig(t, "go test ./...") + bodyPath := filepath.Join(layout.RepositoryRoot, "body.md") + body := []byte("reviewed body") + if err := os.WriteFile(bodyPath, body, 0o600); err != nil { + t.Fatal(err) + } + preview := publicationPreview{SchemaVersion: 1, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: time.Unix(10, 0).UTC()} + identity := preview + identity.CreatedAt = time.Time{} + raw, err := json.Marshal(identity) + if err != nil { + t.Fatal(err) + } + preview.Fingerprint = sha256Bytes(raw) + previewPath := filepath.Join(layout.RepositoryRoot, ".boatstack", "publication", "delivery.preview.json") + if err := os.MkdirAll(filepath.Dir(previewPath), 0o700); err != nil { + t.Fatal(err) + } + encoded, err := encodeJSON(preview) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(previewPath, encoded, 0o600); err != nil { + t.Fatal(err) + } + admission := protocol.Admission{ + Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, + Objective: model.Objective{DeliveryID: "delivery"}, + Parameters: protocol.Parameters{{Name: "preview_fingerprint", Value: preview.Fingerprint}}, + } + admission.RequiredCapabilities = catalog.RequiredCapabilities(transition) + admission.EffectiveCapabilities = admission.RequiredCapabilities + if _, err := boundary.Execute(context.Background(), admission, transition, layout, durable.State{}); err != nil { + t.Fatal(err) + } + want := []string{"pr", "create", "--base", "main", "--head", "feature", "--fill-first", "--body-file", bodyPath} + if runner.name != "gh" || strings.Join(runner.arguments, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("publication command = %s %q, want gh %q", runner.name, runner.arguments, want) + } +} + func TestPublicationCorrectionRejectsBodyDriftBeforeProviderCall(t *testing.T) { runner := &boundaryRunner{} boundary, _ := NewNativeBoundaryWithRunner(runner) diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index 95e4368..f4b49a2 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -169,6 +169,13 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if err != nil { return nil, err } + if transition.ID == "workspace.cut" { + transferMutations, transferErr := prepareWorkspacePlanTransfer(layout.RepositoryRoot, next.WorkspacePath, admission.Objective.DeliveryID, next.PlanFingerprint, next.ApprovalFingerprint) + if transferErr != nil { + return nil, transferErr + } + mutations = append(mutations, transferMutations...) + } if transitionSetsRuntimePin(transition.ID) || transition.ID == "catalog.reconcile" { pinMutation, pinErr := prepareRuntimePinMutation(layout.RepositoryRoot, next) if pinErr != nil { diff --git a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go index b5207b6..be2ff89 100644 --- a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go +++ b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go @@ -43,3 +43,87 @@ func TestPlanEffectRejectsSourceChangedAfterEntryBinding(t *testing.T) { t.Fatalf("replaced plan created a managed effect: %v", statErr) } } + +func TestWorkspacePlanTransferCopiesOnlyRegularRuntimeOwnedArtifacts(t *testing.T) { + repository := t.TempDir() + workspace := t.TempDir() + plan := "# Bound plan\n" + for path, contents := range map[string]string{ + filepath.Join(repository, ".boatstack", "plans", "delivery-one.source"): plan, + filepath.Join(repository, ".boatstack", "approvals", "delivery-one.json"): `{"schema_version":1,"delivery_id":"delivery-one","plan_fingerprint":"` + sha256Bytes([]byte(plan)) + `","actor":"reviewer","admission_id":"adm-1","approved_at":"2026-01-01T00:00:00Z"}`, + } { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } + approvalRaw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "approvals", "delivery-one.json")) + if err != nil { + t.Fatal(err) + } + mutations, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery-one", sha256Bytes([]byte(plan)), sha256Bytes(approvalRaw)) + if err != nil { + t.Fatal(err) + } + if len(mutations) != 2 { + t.Fatalf("transfer mutations = %#v", mutations) + } + for _, mutation := range mutations { + if !strings.HasPrefix(mutation.Path, filepath.Join(workspace, ".boatstack")+string(filepath.Separator)) || !mutation.PriorExists && len(mutation.Target) == 0 { + t.Fatalf("invalid transfer mutation: %#v", mutation) + } + } +} + +func TestWorkspacePlanTransferRejectsStaleOrMissingBoundArtifacts(t *testing.T) { + repository := t.TempDir() + workspace := t.TempDir() + planPath := filepath.Join(repository, ".boatstack", "plans", "delivery-one.source") + approvalPath := filepath.Join(repository, ".boatstack", "approvals", "delivery-one.json") + if err := os.MkdirAll(filepath.Dir(planPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(approvalPath), 0o700); err != nil { + t.Fatal(err) + } + bound := []byte("# Bound plan\n") + if err := os.WriteFile(planPath, bound, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(approvalPath, []byte(`{"schema_version":1,"delivery_id":"delivery-one","plan_fingerprint":"`+sha256Bytes(bound)+`","actor":"reviewer","admission_id":"adm-1","approved_at":"2026-01-01T00:00:00Z"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(planPath, []byte("# Substituted plan\n"), 0o600); err != nil { + t.Fatal(err) + } + approvalRaw, err := os.ReadFile(approvalPath) + if err != nil { + t.Fatal(err) + } + mutations, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery-one", sha256Bytes(bound), sha256Bytes(approvalRaw)) + if err == nil || len(mutations) != 0 { + t.Fatalf("stale plan transfer = %#v, %v", mutations, err) + } + if _, err := os.Stat(filepath.Join(workspace, ".boatstack", "plans", "delivery-one.source")); !os.IsNotExist(err) { + t.Fatalf("stale plan created destination artifact: %v", err) + } + if err := os.WriteFile(planPath, bound, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(approvalPath, []byte(`{"schema_version":1,"delivery_id":"delivery-one","plan_fingerprint":"`+sha256Bytes(bound)+`","actor":"substitute","admission_id":"adm-1","approved_at":"2026-01-01T00:00:00Z"}`), 0o600); err != nil { + t.Fatal(err) + } + mutations, err = prepareWorkspacePlanTransfer(repository, workspace, "delivery-one", sha256Bytes(bound), sha256Bytes(approvalRaw)) + if err == nil || len(mutations) != 0 { + t.Fatalf("substituted approval transfer = %#v, %v", mutations, err) + } + if err := os.Remove(approvalPath); err != nil { + t.Fatal(err) + } + mutations, err = prepareWorkspacePlanTransfer(repository, workspace, "delivery-one", sha256Bytes(bound), sha256Bytes(approvalRaw)) + if err == nil || len(mutations) != 0 { + t.Fatalf("missing approval transfer = %#v, %v", mutations, err) + } +} diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer.go b/boatstack/internal/softwaredelivery/effects/state_reducer.go index f1a82ad..df72e81 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer.go @@ -372,7 +372,7 @@ func resetDeliveryState(state *durable.State) { state.Delivery, state.Plan = model.DeliveryUninitialized, model.PlanAbsent state.Workspace = model.WorkspaceAbsent state.Publication, state.Verification = model.PublicationNone, model.VerificationUnverified - state.PlanFingerprint, state.PublicationID, state.PublicationURL, state.PreviewFingerprint = "", "", "", "" + state.PlanFingerprint, state.ApprovalFingerprint, state.PublicationID, state.PublicationURL, state.PreviewFingerprint = "", "", "", "", "" state.WorkspaceBranch, state.WorkspacePath, state.WorkspaceBaseRef = "", "", "" state.WorkspaceSourcePath, state.WorkspaceSourceID, state.WorkspaceSourceRef = "", "", "" state.Gates = nil diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index 0ef2f12..0f5541e 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -520,19 +520,19 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta } if state.Plan == model.PlanApproved || state.Plan == model.PlanLocked { path := filepath.Join(layout.RepositoryRoot, ".boatstack", "approvals", deliveryID+".json") - evidence, _, exists, err := fileEvidence(path, "approval", now) + evidence, fingerprint, exists, err := fileEvidence(path, "approval", now) if err != nil { return plan, verification, terminal, nil, nil, err } planEvidence = append(planEvidence, evidence) - valid := exists - if exists { + valid := exists && state.ApprovalFingerprint != "" && fingerprint == state.ApprovalFingerprint + if valid { raw, readErr := os.ReadFile(path) if readErr != nil { return plan, verification, terminal, nil, nil, readErr } var approval observedApproval - valid = decodeStrictJSON(raw, &approval) == nil && approval.SchemaVersion == 1 && + valid = valid && decodeStrictJSON(raw, &approval) == nil && approval.SchemaVersion == 1 && approval.DeliveryID == deliveryID && approval.PlanFingerprint == state.PlanFingerprint && approval.Actor != "" && approval.AdmissionID != "" && !approval.ApprovedAt.IsZero() } diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index a3d393e..f99fda8 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -209,6 +209,51 @@ func TestDoubleStarMatchesRootAndNestedPaths(t *testing.T) { } } +func TestObserverMarksApprovalByteSubstitutionStale(t *testing.T) { + // control-law: an approval remains authoritative only while its exact admitted bytes remain present. + repository := t.TempDir() + planPath := filepath.Join(repository, ".boatstack", "plans", "delivery.source") + approvalPath := filepath.Join(repository, ".boatstack", "approvals", "delivery.json") + if err := os.MkdirAll(filepath.Dir(planPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(approvalPath), 0o700); err != nil { + t.Fatal(err) + } + planRaw := []byte("# Approved plan\n") + approvalRaw := []byte(`{"schema_version":1,"delivery_id":"delivery","plan_fingerprint":"pending","actor":"reviewer","admission_id":"adm-1","approved_at":"2026-01-01T00:00:00Z"}`) + if err := os.WriteFile(planPath, planRaw, 0o600); err != nil { + t.Fatal(err) + } + _, planFingerprint, _, err := fileEvidence(planPath, "plan", time.Unix(1, 0).UTC()) + if err != nil { + t.Fatal(err) + } + approvalRaw = []byte(`{"schema_version":1,"delivery_id":"delivery","plan_fingerprint":"` + planFingerprint + `","actor":"reviewer","admission_id":"adm-1","approved_at":"2026-01-01T00:00:00Z"}`) + if err := os.WriteFile(approvalPath, approvalRaw, 0o600); err != nil { + t.Fatal(err) + } + _, approvalFingerprint, _, err := fileEvidence(approvalPath, "approval", time.Unix(1, 0).UTC()) + if err != nil { + t.Fatal(err) + } + state := durable.State{ + Plan: model.PlanApproved, Verification: model.VerificationCurrent, Terminal: model.TerminalNonterminal, + Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"}, + PlanFingerprint: planFingerprint, ApprovalFingerprint: approvalFingerprint, + } + if err := os.WriteFile(approvalPath, []byte(`{"schema_version":1,"delivery_id":"delivery","plan_fingerprint":"`+planFingerprint+`","actor":"substitute","admission_id":"adm-1","approved_at":"2026-01-01T00:00:00Z"}`), 0o600); err != nil { + t.Fatal(err) + } + plan, _, terminal, _, _, err := observeRepositoryArtifacts(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(2, 0).UTC()) + if err != nil { + t.Fatal(err) + } + if plan != model.PlanStale || terminal != model.TerminalStale { + t.Fatalf("substituted approval observed as plan=%s terminal=%s", plan, terminal) + } +} + func TestObserverDerivesHighRiskChangeFromCommittedAndWorkingTreePaths(t *testing.T) { repository := t.TempDir() runGit(t, repository, "init", "-q") diff --git a/release-notes/2026-08-13-worktree-publication.md b/release-notes/2026-08-13-worktree-publication.md new file mode 100644 index 0000000..abc18cd --- /dev/null +++ b/release-notes/2026-08-13-worktree-publication.md @@ -0,0 +1,3 @@ +### Reliable delegated worktree publication + +Delivery runs now carry their approved plan into a created worktree and can create pull requests noninteractively with the reviewed preview body.