From ea41d50101c21ca6189a455e33a9006df0029bf7 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 18:24:09 +0100 Subject: [PATCH 1/2] Fix durable state schema migration --- boatstack/internal/softwaredelivery/durable/state.go | 4 ++-- .../softwaredelivery/durable/state_schema_test.go | 10 +++++----- .../softwaredelivery/effects/integration_test.go | 9 ++++----- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index ba2e88e..8d3cbbe 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -15,7 +15,7 @@ import ( const ( StateSchemaVersion = 7 - priorStateSchemaVersion = 4 + priorStateSchemaVersion = 6 ) // CanReadStateSchema reports the current schema and its single supported @@ -252,7 +252,7 @@ func DecodeState(value []byte) (State, error) { return State{}, fmt.Errorf("durable state contains trailing JSON") } if state.SchemaVersion == priorStateSchemaVersion { - if state.PlanningPackageFingerprint != "" || state.ControlBundleFingerprint != "" || state.ProgramHumanIdentityRole != "" { + if state.ProgramHumanIdentityRole != "" { return State{}, fmt.Errorf("durable state schema %d contains later identity fields", priorStateSchemaVersion) } state.SchemaVersion = StateSchemaVersion diff --git a/boatstack/internal/softwaredelivery/durable/state_schema_test.go b/boatstack/internal/softwaredelivery/durable/state_schema_test.go index 9516f26..ed086c3 100644 --- a/boatstack/internal/softwaredelivery/durable/state_schema_test.go +++ b/boatstack/internal/softwaredelivery/durable/state_schema_test.go @@ -37,7 +37,7 @@ func TestStateSchemaPermitsLegacyApprovedStateWithoutApprovalFingerprint(t *test } } -func TestDecodeStatePromotesReleasedSchemaFourWithoutChangingPriorBytes(t *testing.T) { +func TestDecodeStatePromotesExactBaseSchemaWithoutChangingPriorBytes(t *testing.T) { state := State{ SchemaVersion: StateSchemaVersion, RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Revision: 7, Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryApproved, Workspace: model.WorkspaceAbsent, @@ -54,7 +54,7 @@ func TestDecodeStatePromotesReleasedSchemaFourWithoutChangingPriorBytes(t *testi t.Fatal(err) } legacy["schema_version"] = float64(priorStateSchemaVersion) - delete(legacy, "control_bundle_fingerprint") + delete(legacy, "program_human_identity_role") prior, err := json.MarshalIndent(legacy, "", " ") if err != nil { t.Fatal(err) @@ -73,10 +73,10 @@ func TestDecodeStatePromotesReleasedSchemaFourWithoutChangingPriorBytes(t *testi } } -func TestDecodeStateRejectsSchemaFourWithLaterIdentityFields(t *testing.T) { - raw := []byte(`{"schema_version":4,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","planning_package_fingerprint":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","control_bundle_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"updated_at":"1970-01-01T00:00:01Z"}`) +func TestDecodeStateRejectsBaseSchemaWithNewHumanIdentityRole(t *testing.T) { + raw := []byte(`{"schema_version":6,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","program_human_identity_role":"developer","planning_package_fingerprint":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","control_bundle_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"updated_at":"1970-01-01T00:00:01Z"}`) if _, err := DecodeState(raw); err == nil { - t.Fatal("schema-4 state smuggled later identity fields") + t.Fatal("base schema state smuggled the new human identity role") } } diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index def01bc..a643e85 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -814,9 +814,8 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if err := json.Unmarshal(afterSuccess, &legacyState); err != nil { t.Fatal(err) } - legacyState["schema_version"] = float64(4) - delete(legacyState, "planning_package_fingerprint") - delete(legacyState, "control_bundle_fingerprint") + legacyState["schema_version"] = float64(6) + delete(legacyState, "program_human_identity_role") legacyRaw, err := json.MarshalIndent(legacyState, "", " ") if err != nil { t.Fatal(err) @@ -834,7 +833,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if err != nil { t.Fatal(err) } - legacyPin.StateSchemaVersion = 4 + legacyPin.StateSchemaVersion = 6 legacyPinRaw, err = boatstackruntime.EncodePin(legacyPin) if err != nil { t.Fatal(err) @@ -869,7 +868,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { updatedState.Delivery != priorState.Delivery || updatedState.Workspace != priorState.Workspace || updatedState.Plan != priorState.Plan || updatedState.Configuration != priorState.Configuration || updatedState.Publication != priorState.Publication || updatedState.Verification != priorState.Verification || updatedState.Terminal != priorState.Terminal || updatedState.PlanFingerprint != priorState.PlanFingerprint || updatedState.ApprovalFingerprint != priorState.ApprovalFingerprint { - t.Fatalf("schema-4 update changed existing product facets: before=%#v after=%#v", priorState, updatedState) + t.Fatalf("base-schema update changed existing product facets: before=%#v after=%#v", priorState, updatedState) } updatedPinRaw, err := os.ReadFile(legacyPinPath) if err != nil { From beb67c244b7dfd87cf9c60487588793a2e22548e Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 18:52:47 +0100 Subject: [PATCH 2/2] Document durable state migration fix --- release-notes/2026-08-17-durable-state-schema-migration.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 release-notes/2026-08-17-durable-state-schema-migration.md diff --git a/release-notes/2026-08-17-durable-state-schema-migration.md b/release-notes/2026-08-17-durable-state-schema-migration.md new file mode 100644 index 0000000..d31d9f1 --- /dev/null +++ b/release-notes/2026-08-17-durable-state-schema-migration.md @@ -0,0 +1,6 @@ +### Preserve durable state during named-role upgrades + +Boatstack now promotes the immediately preceding durable state schema 6 to +schema 7 while preserving its planning and control-bundle fingerprints. This +allows existing installations to adopt named human identity roles without +misclassifying valid schema-6 state as an unsupported older version.