From d433f8ca57695a909366d6a024589bea60f49de3 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 10 Aug 2026 17:40:34 +0100 Subject: [PATCH] fix: keep configuration mutations verified --- .../config_authority_conformance_test.go | 251 ++++++++++++++ boatstack/config_event_registry_test.go | 62 +++- boatstack/config_write.go | 310 ++++++++++++++++++ boatstack/delivery.go | 41 +-- boatstack/provision.go | 115 +------ boatstack/references/failure-moves.md | 2 +- ...0-configuration-mutations-stay-verified.md | 2 + 7 files changed, 648 insertions(+), 135 deletions(-) create mode 100644 boatstack/config_write.go create mode 100644 release-notes/2026-08-10-configuration-mutations-stay-verified.md diff --git a/boatstack/config_authority_conformance_test.go b/boatstack/config_authority_conformance_test.go index a3880e6..3cc1535 100644 --- a/boatstack/config_authority_conformance_test.go +++ b/boatstack/config_authority_conformance_test.go @@ -4,7 +4,10 @@ import ( "fmt" "os" "path/filepath" + "reflect" + "sort" "strings" + "sync" "testing" ) @@ -139,6 +142,254 @@ func TestCapabilityRegistrationCannotInvalidateItsRepositoryBinding(t *testing.T } } +// Failure-state conformance for: +// control-law: successful-config-mutations-preserve-detached-verification. +func TestIgnoreDeliveryCannotInvalidateItsRepositoryBinding(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/ignore-authority.git") + writeRepositoryConfig(t, repo, "ignore-authority") + if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + + for _, feature := range []string{"old-one", "old-two"} { + added, err := IgnoreDelivery(repo, feature) + if err != nil || !added { + t.Fatalf("ignore %s: added=%v err=%v", feature, added, err) + } + status, statusErr := DetachedStatus(repo) + if statusErr != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch { + t.Fatalf("ignore %s invalidated detached binding: %+v %v", feature, status, statusErr) + } + } + + repository, _, err := LoadConfig(filepath.Join(repo, sourceConfigName)) + if err != nil || !reflect.DeepEqual(repository.Workflow.IgnoredDeliveries, []string{"old-one", "old-two"}) { + t.Fatalf("repository authority was not updated: %+v %v", repository.Workflow.IgnoredDeliveries, err) + } +} + +// Positive and relation conformance for: +// control-law: successful-config-mutations-preserve-declared-authority. +func TestConfigurationMutationPreservesEveryAuthorityMode(t *testing.T) { + t.Run("embedded-source", func(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/embedded-source.git") + raw := writeRepositoryConfig(t, repo, "embedded-source") + config, _, err := LoadConfig(filepath.Join(repo, sourceConfigName)) + if err != nil { + t.Fatal(err) + } + bundle, err := BuildExportBundle(filepath.Join(repo, sourceConfigName), config, embeddedConfigBytes(raw), "boatstack") + if err != nil || WriteExport(repo, bundle.Files) != nil { + t.Fatalf("embedded fixture: %v", err) + } + if added, err := IgnoreDelivery(repo, "old-embedded"); err != nil || !added { + t.Fatalf("ignore: added=%v err=%v", added, err) + } + for _, path := range []string{filepath.Join(repo, sourceConfigName), filepath.Join(repo, productLoopDirName, "project.json")} { + loaded, _, err := LoadConfig(path) + if err != nil || !reflect.DeepEqual(loaded.Workflow.IgnoredDeliveries, []string{"old-embedded"}) { + t.Fatalf("%s did not receive mutation: %+v %v", path, loaded.Workflow.IgnoredDeliveries, err) + } + } + }) + + t.Run("detached-repository-hybrid", func(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/hybrid-authority.git") + raw := writeRepositoryConfig(t, repo, "hybrid-authority") + config, _, err := LoadConfig(filepath.Join(repo, sourceConfigName)) + if err != nil { + t.Fatal(err) + } + bundle, err := BuildExportBundle(filepath.Join(repo, sourceConfigName), config, embeddedConfigBytes(raw), "boatstack") + if err != nil || WriteExport(repo, bundle.Files) != nil { + t.Fatalf("hybrid fixture: %v", err) + } + if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + if added, err := IgnoreDelivery(repo, "old-hybrid"); err != nil || !added { + t.Fatalf("ignore: added=%v err=%v", added, err) + } + status, statusErr := DetachedStatus(repo) + if statusErr != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch { + t.Fatalf("hybrid mutation invalidated binding: %+v %v", status, statusErr) + } + repository, _, repositoryErr := LoadConfig(filepath.Join(repo, productLoopDirName, "project.json")) + controller, _, controllerErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + if repositoryErr != nil || controllerErr != nil { + t.Fatalf("load hybrid projections: repository=%v controller=%v", repositoryErr, controllerErr) + } + if !reflect.DeepEqual(repository.Workflow.IgnoredDeliveries, []string{"old-hybrid"}) || + !reflect.DeepEqual(controller.Workflow.IgnoredDeliveries, []string{"old-hybrid"}) { + t.Fatalf("hybrid projections diverged: repo=%v controller=%v", repository.Workflow.IgnoredDeliveries, controller.Workflow.IgnoredDeliveries) + } + }) + + for _, test := range []struct { + name string + authority string + configPath func(*testing.T) string + }{ + {name: "detached-external", authority: ConfigAuthorityExternalSnapshot, configPath: func(t *testing.T) string { + path, _ := externalConfigFixture(t, "external-ignore", "go test ./...") + return path + }}, + {name: "detached-synthesized", authority: ConfigAuthoritySynthesized}, + } { + t.Run(test.name, func(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/"+test.name+".git") + options := AttachOptions{Repo: repo} + if test.configPath != nil { + options.ConfigPath = test.configPath(t) + } + before := filesystemSnapshot(t, repo) + attached, err := AttachDetached(options) + if err != nil || attached.VerificationStatus != "VERIFIED" || attached.ConfigAuthority != test.authority { + t.Fatalf("attach: %+v %v", attached, err) + } + if added, err := IgnoreDelivery(repo, "old-local"); err != nil || !added { + t.Fatalf("ignore: added=%v err=%v", added, err) + } + if after := filesystemSnapshot(t, repo); after != before { + t.Fatal("controller-authority mutation changed repository bytes") + } + status, statusErr := DetachedStatus(repo) + if statusErr != nil || !status.Verified || status.ConfigAuthority != test.authority || status.ConfigRelation == ConfigRelationDiverged { + t.Fatalf("controller-authority mutation invalidated binding: %+v %v", status, statusErr) + } + controller, _, err := LoadConfig(WorkspaceFor(repo).SourceConfigPath()) + if err != nil || !reflect.DeepEqual(controller.Workflow.IgnoredDeliveries, []string{"old-local"}) { + t.Fatalf("controller source was not updated: %+v %v", controller.Workflow.IgnoredDeliveries, err) + } + }) + } +} + +// Failure-state conformance for: +// control-law: failed-config-mutations-restore-every-projection. +func TestConfigurationMutationRollsBackEveryDetachedCheckpoint(t *testing.T) { + for _, checkpoint := range []string{ + "controller-projection-written", + "repository-source-written", + "controller-source-written", + "binding-written", + } { + t.Run(checkpoint, func(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/rollback-"+checkpoint+".git") + writeRepositoryConfig(t, repo, "rollback") + attached, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || attached.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", attached, err) + } + ctx := WorkspaceFor(repo) + beforeRepo := filesystemSnapshot(t, repo) + beforeController := filesystemSnapshot(t, ctx.controlRoot) + previous := configMutationCheckpoint + configMutationCheckpoint = func(stage string) error { + if stage == checkpoint { + return fmt.Errorf("simulated interruption at %s", stage) + } + return nil + } + t.Cleanup(func() { configMutationCheckpoint = previous }) + + if added, err := IgnoreDelivery(repo, "must-rollback"); err == nil || added { + t.Fatalf("checkpoint did not fail: added=%v err=%v", added, err) + } + if after := filesystemSnapshot(t, repo); after != beforeRepo { + t.Fatalf("repository bytes changed after rollback at %s", checkpoint) + } + if after := filesystemSnapshot(t, ctx.controlRoot); after != beforeController { + t.Fatalf("controller bytes changed after rollback at %s", checkpoint) + } + status, statusErr := DetachedStatus(repo) + if statusErr != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch { + t.Fatalf("rollback left controller invalid: %+v %v", status, statusErr) + } + }) + } +} + +// Correlation and relation conformance for: +// control-law: concurrent-config-mutations-cannot-lose-accepted-updates. +func TestConfigurationMutationSerializesConcurrentWriters(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/concurrent-config.git") + writeRepositoryConfig(t, repo, "concurrent-config") + if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + + features := []string{"old-alpha", "old-beta"} + errors := make(chan error, len(features)) + var wait sync.WaitGroup + for _, feature := range features { + feature := feature + wait.Add(1) + go func() { + defer wait.Done() + added, err := IgnoreDelivery(repo, feature) + if err != nil { + errors <- err + return + } + if !added { + errors <- fmt.Errorf("%s was not added", feature) + } + }() + } + wait.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + config, _, err := LoadConfig(filepath.Join(repo, sourceConfigName)) + if err != nil { + t.Fatal(err) + } + got := append([]string(nil), config.Workflow.IgnoredDeliveries...) + sort.Strings(got) + if !reflect.DeepEqual(got, features) { + t.Fatalf("concurrent update lost a slug: %v", got) + } + status, statusErr := DetachedStatus(repo) + if statusErr != nil || !status.Verified { + t.Fatalf("concurrent update invalidated binding: %+v %v", status, statusErr) + } +} + +// Negative and failure-state conformance for: +// control-law: ambiguous-config-authority-is-refused-before-writes. +func TestConfigurationMutationRefusesLegacyAuthorityBeforeWrites(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/legacy-config-writer.git") + writeRepositoryConfig(t, repo, "legacy-config-writer") + attached, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || attached.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", attached, err) + } + stateRoot, _ := detachedStateRoot() + binding, err := loadBinding(stateRoot, attached.RepoID) + if err != nil { + t.Fatal(err) + } + binding.SchemaVersion = detachedSchemaVersionWithConfigDigest + binding.ConfigAuthority = "" + raw, _ := MarshalJSON(binding) + if err := atomicWrite(bindingPath(stateRoot, attached.RepoID), raw); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + beforeRepo := filesystemSnapshot(t, repo) + beforeController := filesystemSnapshot(t, WorkspaceFor(repo).controlRoot) + if added, err := IgnoreDelivery(repo, "old-legacy"); err == nil || added || !strings.Contains(err.Error(), "CONFIG_REBIND_REQUIRED") { + t.Fatalf("legacy authority was not refused: added=%v err=%v", added, err) + } + if filesystemSnapshot(t, repo) != beforeRepo || filesystemSnapshot(t, WorkspaceFor(repo).controlRoot) != beforeController { + t.Fatal("legacy refusal changed configuration bytes") + } +} + // Negative and bypass conformance for: // control-law: generic-writers-never-cross-configuration-authority. func TestMigrationAndExportCannotCrossOrGuessAuthority(t *testing.T) { diff --git a/boatstack/config_event_registry_test.go b/boatstack/config_event_registry_test.go index 8cb90b1..c29253f 100644 --- a/boatstack/config_event_registry_test.go +++ b/boatstack/config_event_registry_test.go @@ -28,6 +28,12 @@ var configEventClasses = map[string]string{ "ResolveConfigurationTopology": "resolver", "RequireManagedConfiguration": "admission", "ConfigRebind": "writer", + "mutateManagedConfiguration": "writer", +} + +var ordinaryConfigurationWriters = map[string]bool{ + "IgnoreDelivery": true, + "RegisterCapabilityCommand": true, } func calledName(call *ast.CallExpr) string { @@ -80,9 +86,63 @@ func TestConfigurationEventRegistryIsComplete(t *testing.T) { } sort.Strings(entries) digest := SHA256Bytes([]byte(strings.Join(entries, "\n"))) - const expected = "dfcbdc50fdfbda0d505ce7b04f55f6289622adad74a8fd01fc5fb13e6a00979b" + const expected = "ed524f110b7ade6e3a5795c5f26ee20db87153a0c91865c8e7cd63a9ee133f0f" if digest != expected { _ = os.WriteFile(filepath.Join(t.TempDir(), "config-events.txt"), []byte(strings.Join(entries, "\n")+"\n"), 0o644) t.Fatalf("configuration event registry changed: got %s; classify the new or removed site and update the reviewed digest", digest) } } + +// Ordinary command handlers may describe a configuration change, but only the +// shared mutation boundary may resolve sources, render projections, or write +// accepted bytes. This prevents another generated-only writer from recreating +// the detached self-invalidation class. +func TestOrdinaryConfigurationWritersUseManagedBoundary(t *testing.T) { + found := map[string]bool{} + set := token.NewFileSet() + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + parsed, err := parser.ParseFile(set, path, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, declaration := range parsed.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil || !ordinaryConfigurationWriters[function.Name.Name] { + continue + } + found[function.Name.Name] = true + usesBoundary := false + rawWriters := []string{} + ast.Inspect(function.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + name := calledName(call) + if name == "mutateManagedConfiguration" { + usesBoundary = true + } + switch name { + case "atomicWrite", "atomicWriteMode", "WriteExport", "writeExport", "GeneratedJSON", "BuildExportBundle": + rawWriters = append(rawWriters, name) + } + return true + }) + if !usesBoundary || len(rawWriters) > 0 { + t.Errorf("%s must use only mutateManagedConfiguration; boundary=%v raw=%v", function.Name.Name, usesBoundary, rawWriters) + } + } + } + for name := range ordinaryConfigurationWriters { + if !found[name] { + t.Errorf("ordinary configuration writer %s was not found", name) + } + } +} diff --git a/boatstack/config_write.go b/boatstack/config_write.go new file mode 100644 index 0000000..ccb5ebb --- /dev/null +++ b/boatstack/config_write.go @@ -0,0 +1,310 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// configMutationCheckpoint is a failure-injection seam for proving that every +// partial projection is restored before a configuration mutation returns. +var configMutationCheckpoint = func(string) error { return nil } + +type configMutationResult struct { + Changed bool + Source string // source-and-export | generated-only +} + +type configMutationProjection struct { + name string + root string + files map[string][]byte +} + +type configSourceWrite struct { + name string + path string + value []byte +} + +// withConfigurationMutationLock serializes configuration changes across every +// worktree in one clone. Detached aliases share the same Git common directory, +// so two helpers cannot read the same base configuration and lose one update. +func withConfigurationMutationLock(repo string, apply func() error) error { + common, err := gitCommonDir(repo) + if err != nil { + return err + } + lock := filepath.Join(common, "boatstack-configuration-mutation.lock") + if err := rejectSymlinkComponents(common, lock); err != nil { + return err + } + for attempt := 0; attempt < 100; attempt++ { + file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if openErr == nil { + _, _ = fmt.Fprintf(file, "%d %s\n", os.Getpid(), operationTimestamp()) + _ = file.Close() + defer os.Remove(lock) + return apply() + } + if !isLockContention(openErr, lock) { + return openErr + } + if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute { + _ = os.Remove(lock) + continue + } + time.Sleep(10 * time.Millisecond) + } + return fmt.Errorf("configuration mutation is busy") +} + +func projectionTransactionPaths(projection configMutationProjection) []string { + paths := make([]string, 0, len(projection.files)) + seen := map[string]bool{} + for relative := range projection.files { + path := filepath.Join(projection.root, filepath.FromSlash(relative)) + paths = append(paths, path) + seen[relative] = true + } + // WriteExport may remove generated paths that disappeared from the new + // bundle. Capture those pre-images too so rollback remains complete. + for relative := range previousFiles(projection.root) { + if seen[relative] { + continue + } + paths = append(paths, filepath.Join(projection.root, filepath.FromSlash(relative))) + } + return paths +} + +func verifyConfigurationSource(write configSourceWrite) error { + current, err := os.ReadFile(write.path) + if err != nil { + return err + } + if string(current) != string(write.value) { + return fmt.Errorf("%s configuration source did not match the accepted bytes", write.name) + } + return nil +} + +// Boundary: ordinary Boatstack project-configuration mutation. +// Control law: a successful mutation preserves declared authority and leaves +// every bound source and generated projection verified. +// Authorized actor: command handlers admitted through this function. +// Required evidence: current topology, valid candidate bytes, collision-free +// projections, and successful post-write verification. +// Failure behavior: reject before writing or restore exact pre-images. +// Release condition: every selected projection verifies; detached acceptance is +// recorded by promoting the binding last. +func mutateManagedConfiguration(repoPath string, mutate func(*ProjectConfig) (bool, error)) (result configMutationResult, returnErr error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return configMutationResult{}, err + } + returnErr = withConfigurationMutationLock(repo, func() (transactionErr error) { + topology, err := RequireManagedConfiguration(repo) + if err != nil { + return err + } + if topology.Mode == string(SupervisionDetached) && topology.Authority == ConfigAuthorityLegacyUnknown { + return fmt.Errorf("CONFIG_REBIND_REQUIRED: configuration mutation needs an explicit detached configuration authority") + } + + sourcePath := topology.RepositorySourcePath + generatedOnly := false + if topology.Mode == string(SupervisionDetached) && + (topology.Authority == ConfigAuthorityExternalSnapshot || topology.Authority == ConfigAuthoritySynthesized) { + sourcePath = topology.ControllerSourcePath + } + if topology.Mode == string(SupervisionEmbedded) && !fileExists(sourcePath) { + sourcePath = WorkspaceFor(repo).ProjectConfigPath() + generatedOnly = true + } + if !fileExists(sourcePath) { + return fmt.Errorf("CONFIG_REBIND_REQUIRED: declared configuration source is missing: %s", sourcePath) + } + + config, _, err := LoadConfig(sourcePath) + if err != nil { + return err + } + changed, err := mutate(&config) + if err != nil { + return err + } + result.Source = "source-and-export" + if generatedOnly { + result.Source = "generated-only" + } + if !changed { + return nil + } + if err := ValidateConfig(config); err != nil { + return err + } + rawConfig, err := MarshalJSON(config) + if err != nil { + return err + } + + if generatedOnly { + project, err := GeneratedJSON(config) + if err != nil { + return err + } + saved, err := snapshotFiles([]string{sourcePath}) + if err != nil { + return err + } + defer func() { + if transactionErr != nil { + if rollbackErr := restoreFiles(saved); rollbackErr != nil { + transactionErr = fmt.Errorf("%v; configuration rollback failed: %w", transactionErr, rollbackErr) + } + } + }() + if err := atomicWriteMode(sourcePath, project, 0o644); err != nil { + return err + } + if err := configMutationCheckpoint("generated-only-written"); err != nil { + return err + } + loaded, _, err := LoadConfig(sourcePath) + if err != nil || !equalProjectConfig(loaded, config) { + return fmt.Errorf("generated-only configuration postcondition failed: %v", err) + } + result.Changed = true + return nil + } + + projections := []configMutationProjection{} + sources := []configSourceWrite{} + if topology.Mode == string(SupervisionEmbedded) || topology.Authority == ConfigAuthorityRepository { + if topology.Mode == string(SupervisionEmbedded) || topology.RepositoryPackagePresent { + bundle, buildErr := BuildExportBundle(topology.RepositorySourcePath, config, embeddedConfigBytes(rawConfig), "boatstack") + if buildErr != nil { + return buildErr + } + projections = append(projections, configMutationProjection{name: "repository", root: topology.RepositoryBundleRoot, files: bundle.Files}) + } + sources = append(sources, configSourceWrite{name: "repository", path: topology.RepositorySourcePath, value: rawConfig}) + } + if topology.Mode == string(SupervisionDetached) { + controller, buildErr := BuildExportBundle(topology.ControllerSourcePath, config, rawConfig, "boatstack") + if buildErr != nil { + return buildErr + } + projections = append(projections, configMutationProjection{name: "controller", root: topology.ControllerBundleRoot, files: controller.Files}) + sources = append(sources, configSourceWrite{name: "controller", path: topology.ControllerSourcePath, value: rawConfig}) + } + + var binding DetachedBinding + bindingTarget := "" + if topology.Mode == string(SupervisionDetached) { + stateRoot, stateErr := detachedStateRoot() + if stateErr != nil { + return stateErr + } + bindingTarget = bindingPath(stateRoot, topology.RepoID) + binding, err = loadBinding(stateRoot, topology.RepoID) + if err != nil { + return err + } + } + + paths := []string{} + for _, projection := range projections { + if problems := ExportCollisions(projection.root, projection.files); len(problems) > 0 { + return fmt.Errorf("refusing to overwrite user-owned files: %s", strings.Join(problems, ", ")) + } + paths = append(paths, projectionTransactionPaths(projection)...) + } + for _, source := range sources { + paths = append(paths, source.path) + } + if bindingTarget != "" { + paths = append(paths, bindingTarget) + } + saved, err := snapshotFiles(paths) + if err != nil { + return err + } + defer func() { + if transactionErr != nil { + if rollbackErr := restoreFiles(saved); rollbackErr != nil { + transactionErr = fmt.Errorf("%v; configuration rollback failed: %w", transactionErr, rollbackErr) + } + invalidateWorkspaceCache() + } + }() + + for _, projection := range projections { + if err := WriteExport(projection.root, projection.files); err != nil { + return err + } + if err := configMutationCheckpoint(projection.name + "-projection-written"); err != nil { + return err + } + } + for _, source := range sources { + if err := atomicWriteMode(source.path, source.value, 0o644); err != nil { + return err + } + if err := configMutationCheckpoint(source.name + "-source-written"); err != nil { + return err + } + } + + if bindingTarget != "" { + binding.SchemaVersion = detachedSchemaVersion + binding.ConfigSHA256 = SHA256Bytes(rawConfig) + binding.ConfigAuthority = topology.Authority + binding.CreatedByVersion = Version + bindingRaw, err := MarshalJSON(binding) + if err != nil { + return err + } + // The binding is the detached acceptance record and is always last. + if err := atomicWrite(bindingTarget, bindingRaw); err != nil { + return err + } + invalidateWorkspaceCache() + if err := configMutationCheckpoint("binding-written"); err != nil { + return err + } + } + + for _, projection := range projections { + if err := CheckExport(projection.root, projection.files); err != nil { + return err + } + } + for _, source := range sources { + if err := verifyConfigurationSource(source); err != nil { + return err + } + } + if topology.Mode == string(SupervisionDetached) { + status, err := DetachedStatus(repo) + if err != nil { + return err + } + if !status.Verified || status.ConfigRelation == ConfigRelationDiverged { + return fmt.Errorf("detached configuration postcondition failed: %s", status.Reason) + } + } + result.Changed = true + return nil + }) + return result, returnErr +} + +func equalProjectConfig(left, right ProjectConfig) bool { + leftBytes, leftErr := MarshalJSON(left) + rightBytes, rightErr := MarshalJSON(right) + return leftErr == nil && rightErr == nil && string(leftBytes) == string(rightBytes) +} diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 73409e2..6f92ca2 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -1376,12 +1376,9 @@ func withoutIgnoredDeliveryStates(states []DeliveryState, ignored []string) []De return kept } -// IgnoreDelivery appends a feature slug to workflow.ignored_deliveries in the -// repository's project.json, deduplicating and preserving all other config. It -// is the bounded, provenance-safe write behind the ignore-delivery helper -// subcommand: the config round-trips through LoadConfig -> GeneratedJSON so the -// serialization contract and generator metadata are preserved. It returns -// whether the slug was newly added. +// IgnoreDelivery appends a feature slug to workflow.ignored_deliveries through +// the authority-aware configuration mutation boundary. It deduplicates while +// preserving order and returns whether the slug was newly added. func IgnoreDelivery(repo, feature string) (bool, error) { feature = strings.TrimSpace(feature) if feature == "" { @@ -1390,33 +1387,19 @@ func IgnoreDelivery(repo, feature string) (bool, error) { if !featureSlugPattern.MatchString(feature) { return false, fmt.Errorf("feature slug %q is not a valid Boatstack feature slug", feature) } - resolved, err := ResolveRepository(repo) - if err != nil { - return false, err - } - ctx, err := ResolveWorkspaceContext(resolved) - if err != nil { - return false, err - } - configPath := ctx.ProjectConfigPath() - config, _, err := LoadConfig(configPath) - if err != nil { - return false, err - } - for _, existing := range config.Workflow.IgnoredDeliveries { - if existing == feature { - return false, nil + mutation, err := mutateManagedConfiguration(repo, func(config *ProjectConfig) (bool, error) { + for _, existing := range config.Workflow.IgnoredDeliveries { + if existing == feature { + return false, nil + } } - } - config.Workflow.IgnoredDeliveries = append(config.Workflow.IgnoredDeliveries, feature) - value, err := GeneratedJSON(config) + config.Workflow.IgnoredDeliveries = append(config.Workflow.IgnoredDeliveries, feature) + return true, nil + }) if err != nil { return false, err } - if err := atomicWriteMode(configPath, value, 0o644); err != nil { - return false, err - } - return true, nil + return mutation.Changed, nil } // DiscardDeliveryResult is the host-neutral outcome of discarding one managed diff --git a/boatstack/provision.go b/boatstack/provision.go index 29c7269..7fdd0c3 100644 --- a/boatstack/provision.go +++ b/boatstack/provision.go @@ -206,11 +206,8 @@ type RegisteredCapability struct { Source string `json:"source"` // source-and-export | generated-only } -// RegisterCapabilityCommand records a repository-owned command for a capability. -// When a canonical .boatstack-project.json source exists it mutates the source -// and regenerates the full export so the source and every generated file stay in -// sync; otherwise it round-trips the generated project.json alone, matching the -// IgnoreDelivery idiom. +// RegisterCapabilityCommand records a repository-owned command for a capability +// through the shared authority-aware configuration mutation boundary. func RegisterCapabilityCommand(repo, name, surface, command string) (RegisteredCapability, error) { resolved, err := ResolveRepository(repo) if err != nil { @@ -234,108 +231,18 @@ func RegisterCapabilityCommand(repo, name, surface, command string) (RegisteredC alias = capability.Name + ":" + surface } - topology, err := RequireManagedConfiguration(resolved) - if err != nil { - return RegisteredCapability{}, err - } - if topology.Mode == string(SupervisionDetached) && topology.Authority == ConfigAuthorityLegacyUnknown { - return RegisteredCapability{}, fmt.Errorf("CONFIG_REBIND_REQUIRED: capability registration needs an explicit configuration authority; run boatstack-helper config-rebind --repo %q --source repository --json or choose controller", resolved) - } - sourcePath := topology.RepositorySourcePath - targetRoot := topology.RepositoryBundleRoot - if topology.Authority == ConfigAuthorityExternalSnapshot || topology.Authority == ConfigAuthoritySynthesized { - sourcePath = topology.ControllerSourcePath - targetRoot = topology.ControllerBundleRoot - } - if fileExists(sourcePath) { - config, _, err := LoadConfig(sourcePath) - if err != nil { - return RegisteredCapability{}, err - } - setCapabilityCommand(&config, alias, command) - if err := ValidateConfig(config); err != nil { - return RegisteredCapability{}, err - } - rawConfig, err := MarshalJSON(config) - if err != nil { - return RegisteredCapability{}, err - } - bundleRaw := rawConfig - if targetRoot == topology.RepositoryBundleRoot { - bundleRaw = embeddedConfigBytes(rawConfig) - } - bundle, err := BuildExportBundle(sourcePath, config, bundleRaw, "boatstack") - if err != nil { - return RegisteredCapability{}, err - } - writePrimaryBundle := !(topology.Mode == string(SupervisionDetached) && topology.Authority == ConfigAuthorityRepository && !topology.RepositoryPackagePresent) - if writePrimaryBundle { - if err := WriteExport(targetRoot, bundle.Files); err != nil { - return RegisteredCapability{}, err - } - } - if err := atomicWriteMode(sourcePath, rawConfig, 0o644); err != nil { - return RegisteredCapability{}, err - } - if topology.Mode == string(SupervisionDetached) && topology.Authority == ConfigAuthorityRepository { - controllerBundle, buildErr := BuildExportBundle(topology.ControllerSourcePath, config, rawConfig, "boatstack") - if buildErr != nil { - return RegisteredCapability{}, buildErr - } - if err := WriteExport(topology.ControllerBundleRoot, controllerBundle.Files); err != nil { - return RegisteredCapability{}, err - } - if err := atomicWriteMode(topology.ControllerSourcePath, rawConfig, 0o644); err != nil { - return RegisteredCapability{}, err - } + mutation, err := mutateManagedConfiguration(resolved, func(config *ProjectConfig) (bool, error) { + if config.Project.Commands == nil { + config.Project.Commands = map[string]string{} } - if topology.Mode == string(SupervisionDetached) { - stateRoot, stateErr := detachedStateRoot() - if stateErr != nil { - return RegisteredCapability{}, stateErr - } - binding, bindingErr := loadBinding(stateRoot, topology.RepoID) - if bindingErr != nil { - return RegisteredCapability{}, bindingErr - } - binding.SchemaVersion = detachedSchemaVersion - binding.ConfigSHA256 = SHA256Bytes(rawConfig) - binding.ConfigAuthority = topology.Authority - binding.CreatedByVersion = Version - bindingRaw, marshalErr := MarshalJSON(binding) - if marshalErr != nil { - return RegisteredCapability{}, marshalErr - } - if err := atomicWrite(bindingPath(stateRoot, topology.RepoID), bindingRaw); err != nil { - return RegisteredCapability{}, err - } - invalidateWorkspaceCache() + if config.Project.Commands[alias] == command { + return false, nil } - return RegisteredCapability{Capability: capability.Name, Alias: alias, Command: command, Source: "source-and-export"}, nil - } - - configPath := filepath.Join(targetRoot, productLoopDirName, "project.json") - config, _, err := LoadConfig(configPath) - if err != nil { - return RegisteredCapability{}, err - } - setCapabilityCommand(&config, alias, command) - if err := ValidateConfig(config); err != nil { - return RegisteredCapability{}, err - } - value, err := GeneratedJSON(config) + config.Project.Commands[alias] = command + return true, nil + }) if err != nil { return RegisteredCapability{}, err } - if err := atomicWriteMode(configPath, value, 0o644); err != nil { - return RegisteredCapability{}, err - } - return RegisteredCapability{Capability: capability.Name, Alias: alias, Command: command, Source: "generated-only"}, nil -} - -func setCapabilityCommand(config *ProjectConfig, alias, command string) { - if config.Project.Commands == nil { - config.Project.Commands = map[string]string{} - } - config.Project.Commands[alias] = command + return RegisteredCapability{Capability: capability.Name, Alias: alias, Command: command, Source: mutation.Source}, nil } diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index 716016b..93f1268 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -19,7 +19,7 @@ The `root-cause` operation operationalizes this taxonomy for a single bug: it cl | Update self-lockout | An installed helper, stale hook event, or damaged owned receipt blocks its own updater | Let the verified target helper classify state; migrate exact provenance automatically or offer fingerprinted `--repair` | Reinstalling blindly, overwriting user settings, or treating `--repair` as downgrade authority | | Controller-root split | A detached controller path is resolved under external state, then an effectful caller independently validates it against the repository or Git directory and rejects its own owned path as an escape | Carry the target and its owning boundary as one typed value; derive child paths from it; make every effect validate that value; test embedded/detached and worktree/shared storage classes | Broadening the boundary to bypass validation, or letting another caller reconstruct the root independently | | Recovery provenance self-dependency | A damaged local install lock is the first candidate used to decide whether that same lock may be repaired, so a development or malformed identity blocks the verified target helper before recovery begins | Treat the local lock as evidence when valid; otherwise derive the prior stable identity from the committed generated pin and let the verified target helper classify the exact owned repair | Trusting an uncommitted generated lock, inferring an arbitrary version, or overwriting mixed/user-owned state | -| Ownership projection contradiction | Update admission classifies a path as Boatstack-owned, then final validation rejects the controller's own bounded mutation | Build one semantic ownership projection before execution; reuse it for admission, mutation, final verification, staging, and preview | Path-only allowlists accepting user content or independently maintained validators disagreeing after a side effect | +| Ownership projection contradiction | Admission classifies a path as Boatstack-owned, or a configuration command writes one generated projection, then final validation rejects the controller's own bounded mutation | Build one semantic ownership projection before execution; route configuration changes through one authority-aware transaction; reuse it for admission, mutation, final verification, staging, and preview | Path-only allowlists accepting user content, generated-only configuration writers, or independently maintained validators disagreeing after a side effect | | Security/tenancy | Trust boundary or data scope violated | Specialist review; invariant test; deny-by-default guard | Generic prompt mistaken for enforcement | | Integration/deploy | Local pass but runtime fails | Environment parity; canary; health checks; rollback | Treating staging as identical to production | | Documentation drift | Durable behavior and docs disagree | Update source-of-truth artifact; drift check | Growing instructions with unverified rules | diff --git a/release-notes/2026-08-10-configuration-mutations-stay-verified.md b/release-notes/2026-08-10-configuration-mutations-stay-verified.md new file mode 100644 index 0000000..8f5c976 --- /dev/null +++ b/release-notes/2026-08-10-configuration-mutations-stay-verified.md @@ -0,0 +1,2 @@ +### Keep configuration mutations verified +Commands that update Boatstack configuration now preserve the declared source and every generated projection, so repeated delivery exclusions and capability registrations no longer invalidate detached supervision.