From c7d66292e5732ab84f15d4676432740da84d08b0 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 13:21:59 +0100 Subject: [PATCH 1/9] fix: transfer locked plan artifacts to worktrees --- .../softwaredelivery/effects/artifacts.go | 43 +++++++++++++++++++ .../softwaredelivery/effects/driver.go | 13 ++++++ .../effects/plan_fingerprint_test.go | 28 ++++++++++++ 3 files changed, 84 insertions(+) diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 7ba02d2..a04cc23 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -299,6 +299,49 @@ 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 string) ([]ports.ResourceMutation, error) { + if workspacePath == "" || deliveryID == "" { + return nil, nil + } + deliveryID, err := safeSegment(deliveryID, "delivery identity") + if err != nil { + return nil, err + } + sourceRoot := filepath.Join(repositoryRoot, ".boatstack") + destinationRoot := filepath.Join(workspacePath, ".boatstack") + var mutations []ports.ResourceMutation + for _, relative := range []string{ + filepath.Join("plans", deliveryID+".source"), + filepath.Join("approvals", deliveryID+".json"), + } { + source := filepath.Join(sourceRoot, relative) + info, statErr := os.Lstat(source) + if os.IsNotExist(statErr) { + continue + } + if statErr != nil { + return nil, fmt.Errorf("inspect workspace plan artifact %s: %w", source, statErr) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("workspace plan artifact is not a regular file: %s", source) + } + raw, readErr := os.ReadFile(source) + if readErr != nil { + return nil, fmt.Errorf("read workspace plan artifact %s: %w", source, readErr) + } + mutation, mutationErr := mutationFor(filepath.Join(destinationRoot, relative), raw, 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, mutation) + } + return mutations, 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/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index 95e4368..c33e3cd 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -169,6 +169,19 @@ 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) + if transferErr != nil { + return nil, transferErr + } + mutations = append(mutations, transferMutations...) + } else if transition.ID == "workspace.activate" { + transferMutations, transferErr := prepareWorkspacePlanTransfer(next.WorkspaceSourcePath, layout.RepositoryRoot, admission.Objective.DeliveryID) + 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..6aee902 100644 --- a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go +++ b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go @@ -43,3 +43,31 @@ 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() + for path, contents := range map[string]string{ + filepath.Join(repository, ".boatstack", "plans", "delivery-one.source"): "# Bound plan\n", + filepath.Join(repository, ".boatstack", "approvals", "delivery-one.json"): "{\"approved\":true}\n", + } { + 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) + } + } + mutations, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery-one") + 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) + } + } +} From a438ecf0dff6b47499e809b659ec9f4884286974 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 13:25:16 +0100 Subject: [PATCH 2/9] fix: create pull requests noninteractively --- .../effects/command_boundary.go | 2 +- .../effects/command_boundary_test.go | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) 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) From 45114bd5852a926bf7e92c94e549fb79a2b24365 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 13:26:04 +0100 Subject: [PATCH 3/9] docs: note delegated worktree publication --- release-notes/2026-08-13-worktree-publication.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 release-notes/2026-08-13-worktree-publication.md 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. From 1b0cb425ca810e58e5aa18008a603ff98498f32a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 13:37:44 +0100 Subject: [PATCH 4/9] fix: bind worktree plan transfer to admission --- .../softwaredelivery/effects/artifacts.go | 73 +++++++++++-------- .../softwaredelivery/effects/driver.go | 4 +- .../effects/plan_fingerprint_test.go | 44 ++++++++++- 3 files changed, 87 insertions(+), 34 deletions(-) diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index a04cc23..b35c687 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -303,9 +303,9 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio // 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 string) ([]ports.ResourceMutation, error) { - if workspacePath == "" || deliveryID == "" { - return nil, nil +func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, expectedPlanFingerprint string) ([]ports.ResourceMutation, error) { + if workspacePath == "" || deliveryID == "" || expectedPlanFingerprint == "" { + return nil, fmt.Errorf("workspace plan transfer requires workspace, delivery, and bound plan fingerprint") } deliveryID, err := safeSegment(deliveryID, "delivery identity") if err != nil { @@ -313,33 +313,48 @@ func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID stri } sourceRoot := filepath.Join(repositoryRoot, ".boatstack") destinationRoot := filepath.Join(workspacePath, ".boatstack") - var mutations []ports.ResourceMutation - for _, relative := range []string{ - filepath.Join("plans", deliveryID+".source"), - filepath.Join("approvals", deliveryID+".json"), - } { - source := filepath.Join(sourceRoot, relative) - info, statErr := os.Lstat(source) - if os.IsNotExist(statErr) { - continue - } - if statErr != nil { - return nil, fmt.Errorf("inspect workspace plan artifact %s: %w", source, statErr) - } - if !info.Mode().IsRegular() { - return nil, fmt.Errorf("workspace plan artifact is not a regular file: %s", source) - } - raw, readErr := os.ReadFile(source) - if readErr != nil { - return nil, fmt.Errorf("read workspace plan artifact %s: %w", source, readErr) - } - mutation, mutationErr := mutationFor(filepath.Join(destinationRoot, relative), raw, 0o644, false, false) - if mutationErr != nil { - return nil, mutationErr - } - mutations = append(mutations, mutation) + planPath := filepath.Join(sourceRoot, "plans", deliveryID+".source") + planRaw, err := readRegularWorkspacePlanArtifact(planPath) + if err != nil { + return nil, err } - return mutations, nil + 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 + } + 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 { diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index c33e3cd..bdc9ec9 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -170,13 +170,13 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans return nil, err } if transition.ID == "workspace.cut" { - transferMutations, transferErr := prepareWorkspacePlanTransfer(layout.RepositoryRoot, next.WorkspacePath, admission.Objective.DeliveryID) + transferMutations, transferErr := prepareWorkspacePlanTransfer(layout.RepositoryRoot, next.WorkspacePath, admission.Objective.DeliveryID, next.PlanFingerprint) if transferErr != nil { return nil, transferErr } mutations = append(mutations, transferMutations...) } else if transition.ID == "workspace.activate" { - transferMutations, transferErr := prepareWorkspacePlanTransfer(next.WorkspaceSourcePath, layout.RepositoryRoot, admission.Objective.DeliveryID) + transferMutations, transferErr := prepareWorkspacePlanTransfer(next.WorkspaceSourcePath, layout.RepositoryRoot, admission.Objective.DeliveryID, next.PlanFingerprint) if transferErr != nil { return nil, transferErr } diff --git a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go index 6aee902..ff5ed94 100644 --- a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go +++ b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go @@ -47,9 +47,10 @@ func TestPlanEffectRejectsSourceChangedAfterEntryBinding(t *testing.T) { 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"): "# Bound plan\n", - filepath.Join(repository, ".boatstack", "approvals", "delivery-one.json"): "{\"approved\":true}\n", + 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) @@ -58,7 +59,7 @@ func TestWorkspacePlanTransferCopiesOnlyRegularRuntimeOwnedArtifacts(t *testing. t.Fatal(err) } } - mutations, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery-one") + mutations, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery-one", sha256Bytes([]byte(plan))) if err != nil { t.Fatal(err) } @@ -71,3 +72,40 @@ func TestWorkspacePlanTransferCopiesOnlyRegularRuntimeOwnedArtifacts(t *testing. } } } + +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) + } + 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) + } + mutations, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery-one", sha256Bytes(bound)) + 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.Remove(approvalPath); err != nil { + t.Fatal(err) + } + mutations, err = prepareWorkspacePlanTransfer(repository, workspace, "delivery-one", sha256Bytes(bound)) + if err == nil || len(mutations) != 0 { + t.Fatalf("missing approval transfer = %#v, %v", mutations, err) + } +} From 4e3f295ac54a207c71fe4ae83191efa92f58af9a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 13:40:00 +0100 Subject: [PATCH 5/9] fix: preserve planless workspace cuts --- boatstack/internal/softwaredelivery/effects/artifacts.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index b35c687..ef19b93 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -304,8 +304,11 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio // worktree must observe those exact bytes rather than fall back to the inbox // and accidentally select new intent. func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, expectedPlanFingerprint string) ([]ports.ResourceMutation, error) { - if workspacePath == "" || deliveryID == "" || expectedPlanFingerprint == "" { - return nil, fmt.Errorf("workspace plan transfer requires workspace, delivery, and bound plan fingerprint") + if expectedPlanFingerprint == "" { + return nil, nil + } + if workspacePath == "" || deliveryID == "" { + return nil, fmt.Errorf("workspace plan transfer requires workspace and delivery for a bound plan") } deliveryID, err := safeSegment(deliveryID, "delivery identity") if err != nil { From 3a59a2787708ee1b25fedfa162fb3570c6c2db10 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 13:41:40 +0100 Subject: [PATCH 6/9] test: cover planless workspace transfer --- boatstack/internal/softwaredelivery/effects/artifacts.go | 6 +++--- .../softwaredelivery/effects/plan_fingerprint_test.go | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index ef19b93..9f533ee 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -304,11 +304,11 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio // worktree must observe those exact bytes rather than fall back to the inbox // and accidentally select new intent. func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, expectedPlanFingerprint string) ([]ports.ResourceMutation, error) { - if expectedPlanFingerprint == "" { + if expectedPlanFingerprint == "" || deliveryID == "" { return nil, nil } - if workspacePath == "" || deliveryID == "" { - return nil, fmt.Errorf("workspace plan transfer requires workspace and delivery for a bound plan") + if workspacePath == "" { + return nil, fmt.Errorf("workspace plan transfer requires a destination for a bound plan") } deliveryID, err := safeSegment(deliveryID, "delivery identity") if err != nil { diff --git a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go index ff5ed94..e568388 100644 --- a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go +++ b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go @@ -81,6 +81,9 @@ func TestWorkspacePlanTransferRejectsStaleOrMissingBoundArtifacts(t *testing.T) 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) From 69a2f6215ed7a4f84332a16d3e4f197f61299d05 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 13:44:04 +0100 Subject: [PATCH 7/9] fix: bind approval bytes across worktrees --- .../softwaredelivery/durable/state.go | 6 +++++- .../softwaredelivery/durable/state_facet.go | 2 +- .../softwaredelivery/effects/artifacts.go | 12 ++++++++--- .../softwaredelivery/effects/driver.go | 4 ++-- .../effects/plan_fingerprint_test.go | 21 ++++++++++++++++--- .../softwaredelivery/effects/state_reducer.go | 2 +- .../softwaredelivery/plant/observer.go | 4 ++-- 7 files changed, 38 insertions(+), 13 deletions(-) diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index e45cd99..595b3ec 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -12,7 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const StateSchemaVersion = 4 +const StateSchemaVersion = 5 type GateEvidence struct { Gate string `json:"gate"` @@ -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"` @@ -127,6 +128,9 @@ func (s State) Validate() error { return fmt.Errorf("verified configuration policy: %w", err) } } + if (s.Plan == model.PlanApproved || s.Plan == model.PlanLocked) && (s.PlanFingerprint == "" || s.ApprovalFingerprint == "") { + return fmt.Errorf("approved or locked plan requires plan and approval fingerprints") + } switch s.Workspace { case model.WorkspaceCut, model.WorkspaceActive, model.WorkspacePublished, model.WorkspaceLanded, model.WorkspaceAttentionRequired, model.WorkspaceAbandoned: if s.WorkspacePath == "" || s.WorkspaceBranch == "" || s.WorkspaceSourcePath == "" || s.WorkspaceSourceID == "" || s.WorkspaceSourceRef == "" { 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/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 9f533ee..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") @@ -303,12 +306,12 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio // 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 string) ([]ports.ResourceMutation, error) { +func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, expectedPlanFingerprint, expectedApprovalFingerprint string) ([]ports.ResourceMutation, error) { if expectedPlanFingerprint == "" || deliveryID == "" { return nil, nil } - if workspacePath == "" { - return nil, fmt.Errorf("workspace plan transfer requires a destination for a bound plan") + 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 { @@ -329,6 +332,9 @@ func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, exp 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() { diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index bdc9ec9..cbbb12d 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -170,13 +170,13 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans return nil, err } if transition.ID == "workspace.cut" { - transferMutations, transferErr := prepareWorkspacePlanTransfer(layout.RepositoryRoot, next.WorkspacePath, admission.Objective.DeliveryID, next.PlanFingerprint) + transferMutations, transferErr := prepareWorkspacePlanTransfer(layout.RepositoryRoot, next.WorkspacePath, admission.Objective.DeliveryID, next.PlanFingerprint, next.ApprovalFingerprint) if transferErr != nil { return nil, transferErr } mutations = append(mutations, transferMutations...) } else if transition.ID == "workspace.activate" { - transferMutations, transferErr := prepareWorkspacePlanTransfer(next.WorkspaceSourcePath, layout.RepositoryRoot, admission.Objective.DeliveryID, next.PlanFingerprint) + transferMutations, transferErr := prepareWorkspacePlanTransfer(next.WorkspaceSourcePath, layout.RepositoryRoot, admission.Objective.DeliveryID, next.PlanFingerprint, next.ApprovalFingerprint) if transferErr != nil { return nil, transferErr } diff --git a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go index e568388..be2ff89 100644 --- a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go +++ b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go @@ -59,7 +59,11 @@ func TestWorkspacePlanTransferCopiesOnlyRegularRuntimeOwnedArtifacts(t *testing. t.Fatal(err) } } - mutations, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery-one", sha256Bytes([]byte(plan))) + 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) } @@ -94,7 +98,11 @@ func TestWorkspacePlanTransferRejectsStaleOrMissingBoundArtifacts(t *testing.T) if err := os.WriteFile(planPath, []byte("# Substituted plan\n"), 0o600); err != nil { t.Fatal(err) } - mutations, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery-one", sha256Bytes(bound)) + 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) } @@ -104,10 +112,17 @@ func TestWorkspacePlanTransferRejectsStaleOrMissingBoundArtifacts(t *testing.T) 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)) + 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..613997f 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -520,12 +520,12 @@ 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 + valid := exists && state.ApprovalFingerprint != "" && fingerprint == state.ApprovalFingerprint if exists { raw, readErr := os.ReadFile(path) if readErr != nil { From 22a37db5a39957ae07625556c9e2ec292313f62a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 13:50:51 +0100 Subject: [PATCH 8/9] fix: preserve approved worktree handoff --- .../softwaredelivery/durable/state.go | 2 +- .../softwaredelivery/effects/driver.go | 6 --- .../softwaredelivery/plant/observer.go | 4 +- .../softwaredelivery/plant/observer_test.go | 45 +++++++++++++++++++ 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index 595b3ec..d7748dd 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -12,7 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const StateSchemaVersion = 5 +const StateSchemaVersion = 4 type GateEvidence struct { Gate string `json:"gate"` diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index cbbb12d..f4b49a2 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -175,12 +175,6 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans return nil, transferErr } mutations = append(mutations, transferMutations...) - } else if transition.ID == "workspace.activate" { - transferMutations, transferErr := prepareWorkspacePlanTransfer(next.WorkspaceSourcePath, layout.RepositoryRoot, 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) diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index 613997f..0f5541e 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -526,13 +526,13 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta } planEvidence = append(planEvidence, evidence) valid := exists && state.ApprovalFingerprint != "" && fingerprint == state.ApprovalFingerprint - if exists { + 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") From d63b4ac1fd601306c0e9f3965ee7f4d81be03e33 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 13:52:23 +0100 Subject: [PATCH 9/9] fix: observe legacy approvals as stale --- .../softwaredelivery/durable/state.go | 3 -- .../durable/state_schema_test.go | 28 ++++++++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index d7748dd..d4a6721 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -128,9 +128,6 @@ func (s State) Validate() error { return fmt.Errorf("verified configuration policy: %w", err) } } - if (s.Plan == model.PlanApproved || s.Plan == model.PlanLocked) && (s.PlanFingerprint == "" || s.ApprovalFingerprint == "") { - return fmt.Errorf("approved or locked plan requires plan and approval fingerprints") - } switch s.Workspace { case model.WorkspaceCut, model.WorkspaceActive, model.WorkspacePublished, model.WorkspaceLanded, model.WorkspaceAttentionRequired, model.WorkspaceAbandoned: if s.WorkspacePath == "" || s.WorkspaceBranch == "" || s.WorkspaceSourcePath == "" || s.WorkspaceSourceID == "" || s.WorkspaceSourceRef == "" { 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) + } +}