diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abcb259..09bf763 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ # Contributing -Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/e3430a3bb67d28b0f3bd540d90787f88dc018056/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/aefd94091a7e1e52b2bb6bd8da177905dde07cd7/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index f1f50c0..2afed63 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "196fa0c593ee9f0c2148af1f40651a74a61aefcc8c453c411190ecaf5c2d64a2", + "CONTRIBUTING.md": "640e496e306313eb79700c7c99d11bca233b75f771779d44280f5c2e638bcc6f", "README.md": "590821a4cf3c4b196d3cfb39c972ccbebef7d4d558df817c1f1e88b4fd7ceb07", "assets/boatstack-journey.svg": "9f016fbfd2881675551a44dafa282cbc7510a7091131005be490c2dbe5e4e051", "assets/boatstack-mark.svg": "ec96165583b15cfd446c27049d49217973f3e9b1defa5771cc08eec0c9542ce4", @@ -34,29 +34,32 @@ "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", "boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "0b682aa4fc528c6c4e736ae6afa2a8c7c33aab65dca49ce1f5340521357225be", + "boatstack/cmd/boatstack-helper/main.go": "3ba7d63640e6e29ab8df1612b05ae7e616b12041b59c44748cfc74f22ba2100b", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", "boatstack/delivery.go": "565bc8465252a798124a29874e1aeffff16e9027d1a86715e11b3e4bc0743bd2", "boatstack/delivery_test.go": "2545c26389d330a92c11e7b09fb2342101db5d09a825a2a9e81b4ea9eea9407b", - "boatstack/export.go": "16bce2e599baa62b1fb46806c5f126215e32a196e1909f57cbdf1852e81ba82b", + "boatstack/export.go": "52124d6d813588ff743ce3352c7392b2ef10f9c51c86ae1422e3053c7e0a697c", "boatstack/export_test.go": "b38875e004c1a0003f8e377b25ac14e9f4898cf949e91f3d9bfd9531a2867d76", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", "boatstack/hooks.go": "2b7d899f92efef8a68a160c423a46bcd6fd37daa0eba2ac8ad44ffa3dc5f88c9", "boatstack/hooks_test.go": "d71271c0b9ea59b907cc0407a7173d3542281fb2984e6e3c3062ac39b131ec59", - "boatstack/init.go": "c29904ff437aa063eb1ecb1870aa2e6b019afb09210c227dce85e34ef00c7ea8", + "boatstack/init.go": "018252a3112818191d69ee78dbf8816664e6ef7affde2cb576dbca7a67459c9e", "boatstack/init_test.go": "fa48be69d07691fa7842224ec831e5f290504d8b6565263c9735d2dabd9b43b8", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", + "boatstack/migrate.go": "eaf589e2b266238068e42c6d78e01dc040266d28e342cb24f09e33e8541749b3", + "boatstack/migrate_test.go": "b5c2dad2ab762aa202c5c99a8445774bedd982d280ebbb8dd713e7e9f43299d2", "boatstack/next.go": "df4164280ecd77a417a02fe4bc3b1f16d7012ea22b6fc8988faf8c71f41bc8e6", "boatstack/next_test.go": "57828f76257ad7344c383084a97399f6523cbbced28ac2bbe4c14b9da84ccf32", "boatstack/plan.go": "2497e1d32d88a8e433bc5996b57d175bda4e6ba334cf2510efb7c84fd88a98f3", "boatstack/plan_test.go": "006cdc6681f77e579c5a0f709e30ede759c337132d4f2f5193b7b79b29bd7149", - "boatstack/planning.go": "1237ae552a72d5312c1ef61e30f430976f526d5473d91c7fbfcb9148eb7079b9", + "boatstack/planning.go": "4b86ae9dc16393f099ca26812bf3e62909fee42a541b33c33275cc16a80262aa", "boatstack/planning_test.go": "6b156a64182ed76d4c3d392b4c5a26abe5d8b81cea27ee12ac7c4627c827e186", "boatstack/pr.go": "ba537fc35cc8d2aaf584c5ba6de1194fdd1c4255dfb227a09478457752e2b9ad", "boatstack/pr_test.go": "ae23130d9d96cf214cf272227aa572dd09e2fe3adac22921949f541ba99ecb23", "boatstack/references/artifacts.md": "8f2e79b8af4bd3ad2e32aa3e8c07f10812555d0a3247e4991db75cc1cda395c3", + "boatstack/references/config-schema.md": "894c001601246ad87411756902aa7b2962314e4395e2075f94d5c7c93c99e013", "boatstack/references/failure-moves.md": "1d35126348d0b681976e8819665e16fd745fd65eca271492603cb80aab75bf49", "boatstack/references/host-hook-contracts.md": "1382213ad004389de6da5a03af43ec28ace6329c9e7a3f07148566cf2ea12727", "boatstack/references/irreversible-operation-boundary.md": "631743991ace65977586e4537f8dd50f8ae88f8e16f27cf7baad93b2791a73df", @@ -76,17 +79,17 @@ "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1", - "boatstack/update.go": "d538c3f44aa869a2c7c20b9552f51fadbe4ec82ebf40f7130bc90ed3b04690f3", + "boatstack/update.go": "7a7654e1eccd593a5e1840af06c83098cca8fdf11313f93931258ed011059a95", "boatstack/update_test.go": "aa0c2ca97038aad661c46321600034e206e88639f89e0216b3c4cf597312cbae", "boatstack/workspace.go": "91b343400b3506a6f516c28fabc3f1575f22024a5b19f934a020be660a20482e", "boatstack/workspace_test.go": "d99ee417954f529a333b8330c32104d96b0368c1b07223a42cbf304d5363ad0d", "docs/account-recovery-walkthrough.md": "676034974594a7d1a559b24dbed31d7ccc429eb81404b203ca07bbdaa19ec3d3", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/evidence-engineered-coding.md": "2b9d1824f5a5a88ca0c521e0f89dd320d863a75a8a0f3ebe08860adf6b10cac6", + "docs/evidence-engineered-coding.md": "12f3116604c0fe2a60e79463a063161a8f57fa1da0c0f8d0baffb2e915397f6a", "docs/generated-files.md": "136422baf0c7fc2bd5100cfe0ebdb3d9d0705dfd7e7d54bf745dd1037e63492c", "docs/getting-started.md": "eacc814fdffdfa3c7d8052b7cd99a79c04da5c75d88d8b44f3fb68d9afec0316", - "docs/public-claims.json": "696c580ce9147afe25b07392b550858e335719baa0cae1cce717907947f5dfdd", + "docs/public-claims.json": "c16189d867ffbb18bbee414e2325c15a93d8b0f48b4fa7d8b57f44188f97724d", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -100,7 +103,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "35268055d4a6b4fe357a462ded9a87e0a6a9b85a276aa9878c3e887ec41a2092", + "labs/diagram-json/plan.lock.json": "38a8a7dcb9d432f21f790f5f3c29c09a5209fd799380ae4cf3d3073851481b65", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -132,6 +135,7 @@ "release-notes/2026-07-19-new-worktree-intent.md": "969beaf8ef7c0635fb45a816cf762a7f66e5ea20b45847a564ee53153863b120", "release-notes/2026-07-19-optional-repository-changelog.md": "7ae6bed436c269335ac5055e04438489451bef00836fecb3e5fcb06a18501853", "release-notes/2026-07-19-valid-skill-frontmatter.md": "a0a002f7891474d7d1eea49e3234cae5ddbd941158726b83555dc48474181a92", + "release-notes/2026-07-20-config-schema-migration.md": "99be8fcc408689f559c78bdf39f7161fa7d948846bfc136e3fceed5015841be0", "release-notes/2026-07-20-gemini-cli-integration.md": "d829b4fafb3a02dd766e018558b20bf1bc948dd5e337911d6a7c8b54c83c9942", "release-notes/2026-07-20-managed-workspace-lifecycle.md": "e84e9c5a393ca88f9fc14d1f2a99ee9a2a1c525a16d141e9f9981bc9354d8f73", "release-notes/2026-07-20-portability-diagram-dark-theme.md": "b06bebec1361b21ef6deb8c8f43168d189803a32588aeb803ea4b441525a77e2", @@ -140,7 +144,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "e3430a3bb67d28b0f3bd540d90787f88dc018056", + "commit": "aefd94091a7e1e52b2bb6bd8da177905dde07cd7", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 7c370a3..b21ace4 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -1,10 +1,12 @@ package main import ( + "encoding/json" "flag" "fmt" "io" "os" + "path/filepath" "sort" "strings" "time" @@ -513,6 +515,67 @@ func checkSafetyCommand(arguments []string) int { return 0 } +type MigrateConfigReport struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + FromVersion int `json:"from_version"` + ToVersion int `json:"to_version"` + Changed bool `json:"changed"` +} + +func migrateConfigCommand(arguments []string) int { + flags := flag.NewFlagSet("migrate-config", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose configuration should be migrated") + check := flags.Bool("check", false, "dry-run check mode") + if err := flags.Parse(arguments); err != nil { + return 2 + } + configPath := filepath.Join(*repo, ".boatstack-project.json") + raw, err := os.ReadFile(configPath) + if err != nil { + report := MigrateConfigReport{ + Status: "FAIL", + Message: fmt.Sprintf("failed to read config: %v", err), + } + value, _ := json.Marshal(report) + fmt.Print(string(value)) + return 1 + } + upgraded, fromVer, toVer, changed, err := boatstack.MigrateConfigBytes(raw) + if err != nil { + report := MigrateConfigReport{ + Status: "FAIL", + Message: fmt.Sprintf("migration failed: %v", err), + } + value, _ := json.Marshal(report) + fmt.Print(string(value)) + return 1 + } + if changed && !*check { + if err := os.WriteFile(configPath, upgraded, 0o644); err != nil { + report := MigrateConfigReport{ + Status: "FAIL", + Message: fmt.Sprintf("failed to write migrated config: %v", err), + } + value, _ := json.Marshal(report) + fmt.Print(string(value)) + return 1 + } + } + report := MigrateConfigReport{ + Status: "PASS", + FromVersion: fromVer, + ToVersion: toVer, + Changed: changed, + } + value, err := json.Marshal(report) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + return 0 +} + func prContextCommand(arguments []string) int { flags := flag.NewFlagSet("pr-context", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose branch should be projected") @@ -665,7 +728,7 @@ func workspaceStatusCommand(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] { @@ -723,6 +786,8 @@ func run() int { return workspaceCleanupCommand(os.Args[2:]) case "workspace-status": return workspaceStatusCommand(os.Args[2:]) + case "migrate-config": + return migrateConfigCommand(os.Args[2:]) case "version": fmt.Printf("Boatstack %s (%s)\n", boatstack.Version, boatstack.SourceCommit) return 0 diff --git a/boatstack/export.go b/boatstack/export.go index 3fde1cb..001b376 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -96,8 +96,14 @@ func LoadConfig(path string) (ProjectConfig, []byte, error) { } func ValidateConfig(config ProjectConfig) error { - if config.SchemaVersion != 1 { - return fmt.Errorf("project config schema_version must be 1") + if config.SchemaVersion < 1 { + return fmt.Errorf("project config schema_version must be >= 1") + } + if config.SchemaVersion > currentSchemaVersion() { + return fmt.Errorf("config was written by a newer Boatstack; update Boatstack") + } + if config.SchemaVersion < currentSchemaVersion() { + return fmt.Errorf("config schema is behind; run /boatstack-update") } if strings.TrimSpace(config.Project.Name) == "" { return fmt.Errorf("project.name is required") @@ -217,7 +223,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } } - for _, name := range []string{"workflow.md", "artifacts.md", "failure-moves.md", "irreversible-operation-boundary.md", "host-hook-contracts.md"} { + for _, name := range []string{"workflow.md", "artifacts.md", "failure-moves.md", "irreversible-operation-boundary.md", "host-hook-contracts.md", "config-schema.md"} { value, err := readCanonical("references/" + name) if err != nil { return ExportBundle{}, err diff --git a/boatstack/init.go b/boatstack/init.go index e298744..48cfb29 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -313,9 +313,26 @@ func RunInit(options InitOptions) (returnErr error) { } var config ProjectConfig var rawConfig []byte + var migrationFrom, migrationTo int + var migrationChanged bool if configExists { - config, rawConfig, err = LoadConfig(configPath) + var err error + rawConfig, err = os.ReadFile(configPath) if err != nil { + return err + } + var upgraded []byte + upgraded, migrationFrom, migrationTo, migrationChanged, err = MigrateConfigBytes(rawConfig) + if err != nil { + return fmt.Errorf("failed to migrate project config: %w", err) + } + if migrationChanged { + rawConfig = upgraded + } + if err := DecodeJSON("load project configuration", configPath, rawConfig, &config); err != nil { + return fmt.Errorf("existing Boatstack config is invalid: %w", err) + } + if err := ValidateConfig(config); err != nil { return fmt.Errorf("existing Boatstack config is invalid: %w", err) } } else { @@ -508,7 +525,11 @@ func RunInit(options InitOptions) (returnErr error) { return scopeErr } fmt.Fprintf(options.Output, "\nPASS: Boatstack updated to %s on a dedicated infrastructure branch.\n", Version) - fmt.Fprintln(options.Output, "PASS: no product files changed.") + if migrationChanged { + fmt.Fprintf(options.Output, "PASS: migrated .boatstack-project.json from schema version %d to %d.\n", migrationFrom, migrationTo) + } else { + fmt.Fprintln(options.Output, "PASS: no product files changed.") + } fmt.Fprintln(options.Output, "Changed Boatstack paths:") for _, path := range changed { fmt.Fprintln(options.Output, " "+path) diff --git a/boatstack/migrate.go b/boatstack/migrate.go new file mode 100644 index 0000000..ddc5b22 --- /dev/null +++ b/boatstack/migrate.go @@ -0,0 +1,101 @@ +package boatstack + +import ( + "encoding/json" + "fmt" + "strings" +) + +const CurrentConfigSchemaVersion = 1 + +var currentConfigSchemaVersionOverride = CurrentConfigSchemaVersion + +type configMigration struct { + from int + to int + apply func(map[string]any) (map[string]any, error) +} + +var configMigrations []configMigration + +func currentSchemaVersion() int { + return currentConfigSchemaVersionOverride +} + +// MigrateConfigBytes migrates raw JSON configuration bytes to the latest CurrentConfigSchemaVersion. +// It returns the upgraded JSON bytes, the original schema version, the final schema version, +// a boolean indicating whether the content actually changed, and any error encountered. +func MigrateConfigBytes(raw []byte) (upgraded []byte, from, to int, changed bool, err error) { + if len(strings.TrimSpace(string(raw))) == 0 { + return raw, 0, 0, false, nil + } + + var partial map[string]any + if err := json.Unmarshal(raw, &partial); err != nil { + return nil, 0, 0, false, fmt.Errorf("failed to parse config JSON: %w", err) + } + + targetVer := currentSchemaVersion() + + var fromVer int + if v, exists := partial["schema_version"]; exists { + switch val := v.(type) { + case float64: + fromVer = int(val) + case int: + fromVer = val + default: + return nil, 0, 0, false, fmt.Errorf("schema_version must be an integer") + } + } else { + // Default to 1 if schema_version is missing + fromVer = 1 + } + + if fromVer > targetVer { + return nil, fromVer, 0, false, fmt.Errorf("config was written by a newer Boatstack; update Boatstack") + } + + if fromVer == targetVer { + return raw, fromVer, fromVer, false, nil + } + + currentVer := fromVer + data := partial + + for currentVer < targetVer { + var found *configMigration + for i := range configMigrations { + if configMigrations[i].from == currentVer { + found = &configMigrations[i] + break + } + } + + if found == nil { + return nil, fromVer, 0, false, fmt.Errorf("no migration found from version %d to %d", currentVer, currentVer+1) + } + + var err error + data, err = found.apply(data) + if err != nil { + return nil, fromVer, 0, false, fmt.Errorf("failed to apply migration from %d to %d: %w", found.from, found.to, err) + } + + if found.to <= currentVer { + return nil, fromVer, 0, false, fmt.Errorf("invalid migration path from %d to %d", found.from, found.to) + } + + currentVer = found.to + } + + data["schema_version"] = targetVer + + upgraded, err = json.MarshalIndent(data, "", " ") + if err != nil { + return nil, fromVer, 0, false, fmt.Errorf("failed to marshal migrated config: %w", err) + } + upgraded = append(upgraded, '\n') + + return upgraded, fromVer, targetVer, true, nil +} diff --git a/boatstack/migrate_test.go b/boatstack/migrate_test.go new file mode 100644 index 0000000..35a31a0 --- /dev/null +++ b/boatstack/migrate_test.go @@ -0,0 +1,274 @@ +package boatstack + +import ( + "bytes" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestMigrateConfigBytes_SyntheticChain(t *testing.T) { + // Backup and restore + oldMigrations := configMigrations + oldOverride := currentConfigSchemaVersionOverride + defer func() { + configMigrations = oldMigrations + currentConfigSchemaVersionOverride = oldOverride + }() + + // Simulate current schema version is 3 + currentConfigSchemaVersionOverride = 3 + + // Register synthetic v1->v2 and v2->v3 migrations + configMigrations = []configMigration{ + { + from: 1, + to: 2, + apply: func(data map[string]any) (map[string]any, error) { + data["v1_to_v2_applied"] = true + return data, nil + }, + }, + { + from: 2, + to: 3, + apply: func(data map[string]any) (map[string]any, error) { + data["v2_to_v3_applied"] = true + return data, nil + }, + }, + } + + raw := []byte(`{ + "schema_version": 1, + "project": { + "name": "test-project" + } + }`) + + upgraded, from, to, changed, err := MigrateConfigBytes(raw) + if err != nil { + t.Fatalf("MigrateConfigBytes failed: %v", err) + } + + if !changed { + t.Error("expected config to be changed") + } + if from != 1 { + t.Errorf("expected from version 1, got %d", from) + } + if to != 3 { + t.Errorf("expected to version 3, got %d", to) + } + + var parsed map[string]any + if err := json.Unmarshal(upgraded, &parsed); err != nil { + t.Fatalf("failed to unmarshal upgraded config: %v", err) + } + + if parsed["v1_to_v2_applied"] != true { + t.Error("expected v1->v2 migration to be applied") + } + if parsed["v2_to_v3_applied"] != true { + t.Error("expected v2->v3 migration to be applied") + } + if int(parsed["schema_version"].(float64)) != 3 { + t.Errorf("expected upgraded schema_version to be 3, got %v", parsed["schema_version"]) + } +} + +func TestMigrateConfigBytes_NoOp(t *testing.T) { + oldOverride := currentConfigSchemaVersionOverride + defer func() { currentConfigSchemaVersionOverride = oldOverride }() + + currentConfigSchemaVersionOverride = 1 + + raw := []byte(`{ + "schema_version": 1, + "project": { + "name": "test-project" + } + }`) + + upgraded, from, to, changed, err := MigrateConfigBytes(raw) + if err != nil { + t.Fatalf("MigrateConfigBytes failed: %v", err) + } + + if changed { + t.Error("expected config to be unchanged (no-op)") + } + if from != 1 || to != 1 { + t.Errorf("expected from=1 and to=1, got from=%d, to=%d", from, to) + } + if !bytes.Equal(raw, upgraded) { + t.Error("expected upgraded bytes to match raw bytes exactly") + } +} + +func TestMigrateConfigBytes_GapDetection(t *testing.T) { + oldMigrations := configMigrations + oldOverride := currentConfigSchemaVersionOverride + defer func() { + configMigrations = oldMigrations + currentConfigSchemaVersionOverride = oldOverride + }() + + currentConfigSchemaVersionOverride = 3 + + // Missing v2->v3 migration + configMigrations = []configMigration{ + { + from: 1, + to: 2, + apply: func(data map[string]any) (map[string]any, error) { + return data, nil + }, + }, + } + + raw := []byte(`{"schema_version": 1}`) + _, _, _, _, err := MigrateConfigBytes(raw) + if err == nil { + t.Fatal("expected error due to missing migration (gap), got nil") + } + if !strings.Contains(err.Error(), "no migration found from version 2 to 3") { + t.Errorf("expected gap error message, got: %v", err) + } +} + +func TestMigrateConfigBytes_RejectNewer(t *testing.T) { + oldOverride := currentConfigSchemaVersionOverride + defer func() { currentConfigSchemaVersionOverride = oldOverride }() + + currentConfigSchemaVersionOverride = 1 + + raw := []byte(`{"schema_version": 2}`) + _, _, _, _, err := MigrateConfigBytes(raw) + if err == nil { + t.Fatal("expected error rejecting newer version, got nil") + } + if !strings.Contains(err.Error(), "config was written by a newer Boatstack; update Boatstack") { + t.Errorf("expected reject newer error message, got: %v", err) + } +} + +func TestValidateConfig_AcceptanceTable(t *testing.T) { + oldOverride := currentConfigSchemaVersionOverride + defer func() { currentConfigSchemaVersionOverride = oldOverride }() + + currentConfigSchemaVersionOverride = 2 + + tests := []struct { + name string + schemaVersion int + wantErr string + }{ + { + name: "current version passes", + schemaVersion: 2, + wantErr: "", + }, + { + name: "older version is behind", + schemaVersion: 1, + wantErr: "config schema is behind; run /boatstack-update", + }, + { + name: "newer version is ahead", + schemaVersion: 3, + wantErr: "config was written by a newer Boatstack; update Boatstack", + }, + { + name: "invalid version < 1", + schemaVersion: 0, + wantErr: "project config schema_version must be >= 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := ProjectConfig{ + SchemaVersion: tt.schemaVersion, + Project: Project{ + Name: "test-project", + Commands: map[string]string{ + "test": "go test ./...", + }, + }, + } + err := ValidateConfig(cfg) + if tt.wantErr == "" { + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + } else { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got: %v", tt.wantErr, err) + } + } + }) + } +} + +func TestDoctor_SchemaBehindAndAhead(t *testing.T) { + oldOverride := currentConfigSchemaVersionOverride + defer func() { currentConfigSchemaVersionOverride = oldOverride }() + + currentConfigSchemaVersionOverride = 2 + + errBehind := fmt.Errorf("config schema is behind; run /boatstack-update") + errAhead := fmt.Errorf("config was written by a newer Boatstack; update Boatstack") + + hintBehind := DoctorRepairHint(errBehind) + hintAhead := DoctorRepairHint(errAhead) + + if !strings.Contains(hintBehind.Error(), "remediation: run /boatstack-update to migrate project configuration") { + t.Errorf("expected behind hint, got: %v", hintBehind) + } + if !strings.Contains(hintAhead.Error(), "remediation: update your Boatstack installation to load this configuration") { + t.Errorf("expected ahead hint, got: %v", hintAhead) + } +} + +func TestValidateUpdateWorkspace_ConformanceBlock(t *testing.T) { + oldOverride := currentConfigSchemaVersionOverride + oldVersion := Version + oldSourceCommit := SourceCommit + defer func() { + currentConfigSchemaVersionOverride = oldOverride + Version = oldVersion + SourceCommit = oldSourceCommit + }() + + currentConfigSchemaVersionOverride = 1 + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + + now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.5.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + + // Create a behind config (version 1) + currentConfigSchemaVersionOverride = 1 + config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if err != nil { + t.Fatal(err) + } + currentConfigSchemaVersionOverride = 2 + + // Set the current checked-out branch to the update branch + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + + // ValidateUpdateWorkspace with config.SchemaVersion = 1, while current is overridden to 2. + err = ValidateUpdateWorkspace(repo, config) + if err == nil { + t.Fatal("expected ValidateUpdateWorkspace to fail for schema behind, got nil") + } + if !strings.Contains(err.Error(), "config schema is behind; run /boatstack-update") { + t.Errorf("expected error to contain behind message, got: %v", err) + } +} diff --git a/boatstack/planning.go b/boatstack/planning.go index de1cc8f..5887f1d 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -258,5 +258,12 @@ func DoctorRepairHint(err error) error { if err == nil { return nil } + errStr := err.Error() + if strings.Contains(errStr, "config schema is behind") { + return fmt.Errorf("%s; remediation: run /boatstack-update to migrate project configuration", errStr) + } + if strings.Contains(errStr, "config was written by a newer Boatstack") { + return fmt.Errorf("%s; remediation: update your Boatstack installation to load this configuration", errStr) + } return fmt.Errorf("%w; repair: rerun the verified Boatstack installer once from any checkout in this Git clone, then reload the coding host", err) } diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md new file mode 100644 index 0000000..5607693 --- /dev/null +++ b/boatstack/references/config-schema.md @@ -0,0 +1,39 @@ +# Boatstack Configuration Schema + +This reference document defines the schema and version history of `.boatstack-project.json`. + +## Current Schema Version + +- **schema_version**: `1` + +## Field Reference + +### Root Fields + +- `schema_version` (integer, required): Must be exactly `1`. +- `project` (object, required): General project definition. +- `workflow` (object, required): Flags controlling state machine transitions and safety gates. +- `adapters` (array of strings, optional): Enabled host environment adapters. If empty, defaults to enabling all. +- `integrations` (object, optional): Explicit configurations for individual third-party integrations. + +### project Fields + +- `name` (string, required): The human-readable name of the project. +- `default_branch` (string, optional): The canonical development/default branch (e.g. `main` or `master`). +- `context` (array of strings, optional): Paths to persistent project directories or contextual documents. +- `commands` (object, required): Custom development commands: + - `test` (string, required): The exact command to execute project-local tests. +- `high_risk_paths` (array of strings, optional): Glob patterns of files requiring independent reviewer sign-off before shipping. + +### workflow Fields + +- `human_plan_approval` (boolean, optional): Whether a parent plan requires explicit human approval before building. +- `independent_review_for_high_risk` (boolean, optional): Whether modifications to high-risk files require a distinct peer review gate. +- `allow_pass_with_gaps` (boolean, optional): Whether the delivery verification allows outstanding questions or gaps. +- `maintain_changelog` (boolean, optional): Whether a release-notes fragment is required for each delivery slice. + +## Version Changelog + +### Version 1 + +- Initial schema with `project`, `workflow`, `adapters`, and `integrations`. diff --git a/boatstack/update.go b/boatstack/update.go index 02def25..e23d85f 100644 --- a/boatstack/update.go +++ b/boatstack/update.go @@ -357,5 +357,12 @@ func ValidateUpdateWorkspace(repo string, config ProjectConfig) error { if err := CheckInstalledHostHooks(repo, config.Adapters); err != nil { return fmt.Errorf("host-hook drift blocks update: %w", err) } + var schemaProblems []string + if config.SchemaVersion != currentSchemaVersion() { + schemaProblems = append(schemaProblems, fmt.Sprintf("schema_version %d is behind current %d", config.SchemaVersion, currentSchemaVersion())) + } + if len(schemaProblems) > 0 { + return fmt.Errorf("config schema is behind; run /boatstack-update: %s", strings.Join(schemaProblems, ", ")) + } return CheckExistingInstallProvenance(repo) } diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 52e83ae..ed5ba3a 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`e3430a3bb67d28b0f3bd540d90787f88dc018056`](https://github.com/operatorstack/intelligence-flow/tree/e3430a3bb67d28b0f3bd540d90787f88dc018056/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`aefd94091a7e1e52b2bb6bd8da177905dde07cd7`](https://github.com/operatorstack/intelligence-flow/tree/aefd94091a7e1e52b2bb6bd8da177905dde07cd7/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index 3a463f3..f7d7b0a 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "e3430a3bb67d28b0f3bd540d90787f88dc018056", + "source_commit": "aefd94091a7e1e52b2bb6bd8da177905dde07cd7", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "human-decisions", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "validation-provenance", @@ -34,7 +34,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "irreversible-operations", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:e3430a3bb67d28b0f3bd540d90787f88dc018056" + "last_verified_version": "source:aefd94091a7e1e52b2bb6bd8da177905dde07cd7" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 077f8fc..b9c75a0 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "e3430a3bb67d28b0f3bd540d90787f88dc018056", + "source_commit": "aefd94091a7e1e52b2bb6bd8da177905dde07cd7", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-20-config-schema-migration.md b/release-notes/2026-07-20-config-schema-migration.md new file mode 100644 index 0000000..5153a06 --- /dev/null +++ b/release-notes/2026-07-20-config-schema-migration.md @@ -0,0 +1,3 @@ +### Versioned configuration schema migration system + +Introduced a non-breaking, versioned configuration migration system for `.boatstack-project.json`. Older configurations are automatically upgraded to the latest schema version during `/boatstack-update`, with full dry-run reporting, schema gap detection, and helpful error messages for newer configuration versions. A generated configuration schema reference document (`CONFIG_SCHEMA.md`) is now embedded and distributed in the product bundle.