From 758515ad8e06c4341391ae1d7d503472dfe67218 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 10 Aug 2026 12:44:02 +0100 Subject: [PATCH] fix: separate detached configuration authority --- boatstack/attach.go | 49 ++- .../cmd/boatstack-helper/command_trace.go | 2 +- .../coverage_conformance_test.go | 1 + boatstack/cmd/boatstack-helper/main.go | 93 ++--- .../config_authority_conformance_test.go | 216 ++++++++++++ boatstack/config_event_registry_test.go | 88 +++++ boatstack/config_mutation.go | 156 +++++++++ boatstack/config_rebind.go | 321 ++++++++++++++++++ boatstack/config_topology.go | 195 +++++++++++ boatstack/detached.go | 34 +- boatstack/init.go | 121 ++++++- boatstack/planning.go | 17 + boatstack/provision.go | 63 +++- ...-08-10-detached-configuration-authority.md | 2 + 14 files changed, 1279 insertions(+), 79 deletions(-) create mode 100644 boatstack/config_authority_conformance_test.go create mode 100644 boatstack/config_event_registry_test.go create mode 100644 boatstack/config_mutation.go create mode 100644 boatstack/config_rebind.go create mode 100644 boatstack/config_topology.go create mode 100644 release-notes/2026-08-10-detached-configuration-authority.md diff --git a/boatstack/attach.go b/boatstack/attach.go index ee834b8..cba8a3a 100644 --- a/boatstack/attach.go +++ b/boatstack/attach.go @@ -35,6 +35,7 @@ type AttachResult struct { ControlRoot string `json:"control_root,omitempty"` WorktreeID string `json:"worktree_id,omitempty"` ConfigSHA256 string `json:"config_sha256,omitempty"` + ConfigAuthority string `json:"config_authority,omitempty"` Reason string `json:"reason"` FeatureMigrations []DetachedFeatureMigration `json:"feature_migrations,omitempty"` } @@ -114,6 +115,13 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { return blockedAttach("Boatstack could not load the detached project configuration: " + err.Error()), nil } configSHA256 := SHA256Bytes(rawConfig) + configAuthority := ConfigAuthorityExternalSnapshot + if strings.TrimSpace(opts.ConfigPath) == "" { + configAuthority = ConfigAuthoritySynthesized + if fileExists(filepath.Join(root, sourceConfigName)) { + configAuthority = ConfigAuthorityRepository + } + } imports, migrationResults, migrationErr := planDetachedFeatureImports(root, ctx) if migrationErr != nil { result := blockedAttach("Boatstack refused detached feature migration: " + migrationErr.Error()) @@ -156,6 +164,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { InitialCommit: identity.InitialCommit, NormalizedOrigin: identity.NormalizedOrigin, ConfigSHA256: configSHA256, + ConfigAuthority: configAuthority, CreatedByVersion: Version, CreatedAt: nowRFC3339(), } @@ -201,6 +210,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { ControlRoot: ctx.controlRoot, WorktreeID: identity.WorktreeID, ConfigSHA256: configSHA256, + ConfigAuthority: configAuthority, FeatureMigrations: migrationResults, Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.", }, nil @@ -269,16 +279,22 @@ func DetachDetached(opts DetachOptions) (DetachResult, error) { // DetachedStatusResult reports whether a repository is attached in detached mode // and whether its binding verifies. type DetachedStatusResult struct { - SchemaVersion int `json:"schema_version"` - Attached bool `json:"attached"` - Verified bool `json:"verified"` - Mode string `json:"mode"` - RepoID string `json:"repo_id,omitempty"` - RepoRoot string `json:"repo_root,omitempty"` - ControlRoot string `json:"control_root,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - ConfigSHA256 string `json:"config_sha256,omitempty"` - Reason string `json:"reason"` + SchemaVersion int `json:"schema_version"` + Attached bool `json:"attached"` + Verified bool `json:"verified"` + Mode string `json:"mode"` + RepoID string `json:"repo_id,omitempty"` + RepoRoot string `json:"repo_root,omitempty"` + ControlRoot string `json:"control_root,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + ConfigSHA256 string `json:"config_sha256,omitempty"` + ConfigAuthority string `json:"config_authority,omitempty"` + ConfigRelation string `json:"config_relation,omitempty"` + RepositoryConfigSHA256 string `json:"repository_config_sha256,omitempty"` + ControllerConfigSHA256 string `json:"controller_config_sha256,omitempty"` + AffectedWorktrees []string `json:"affected_worktrees,omitempty"` + NextOperation string `json:"next_operation,omitempty"` + Reason string `json:"reason"` } // DetachedStatus reports the detached attachment state for a repository. It is @@ -309,14 +325,21 @@ func DetachedStatus(repoPath string) (DetachedStatusResult, error) { return DetachedStatusResult{ SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached), RepoID: ctx.RepoID, RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID, - ConfigSHA256: configSHA256, Reason: verifyErr.Error(), + ConfigSHA256: configSHA256, ControllerConfigSHA256: configSHA256, Reason: verifyErr.Error(), }, nil } + topology, topologyErr := ResolveConfigurationTopology(root) + if topologyErr != nil { + return DetachedStatusResult{SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached), RepoID: ctx.RepoID, RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID, Reason: topologyErr.Error()}, nil + } return DetachedStatusResult{ SchemaVersion: detachedSchemaVersion, Attached: true, Verified: true, Mode: string(SupervisionDetached), RepoID: ctx.RepoID, RepoRoot: ctx.RepoRoot, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID, - ConfigSHA256: bindingConfigSHA256(ctx), - Reason: "This repository is attached in detached mode and its binding verifies.", + ConfigSHA256: topology.ControllerConfigSHA256, ConfigAuthority: topology.Authority, + ConfigRelation: topology.Relation, RepositoryConfigSHA256: topology.RepositoryConfigSHA256, + ControllerConfigSHA256: topology.ControllerConfigSHA256, AffectedWorktrees: topology.AffectedWorktrees, + NextOperation: topology.NextOperation, + Reason: "This repository is attached in detached mode and its binding verifies.", }, nil } diff --git a/boatstack/cmd/boatstack-helper/command_trace.go b/boatstack/cmd/boatstack-helper/command_trace.go index 23fe8ec..db5b2c0 100644 --- a/boatstack/cmd/boatstack-helper/command_trace.go +++ b/boatstack/cmd/boatstack-helper/command_trace.go @@ -18,7 +18,7 @@ type commandTracePolicy struct { // to the enforcement path; every other dispatch is recorded once by run(). var commandTracePolicies = map[string]commandTracePolicy{ "attach": {Category: "supervision"}, "detach": {Category: "supervision"}, - "detached-status": {Category: "supervision"}, "context": {Category: "supervision"}, + "detached-status": {Category: "supervision"}, "config-rebind": {Category: "supervision"}, "context": {Category: "supervision"}, "activate": {Category: "supervision"}, "deactivate": {Category: "supervision"}, "init": {Category: "installation"}, "update": {Category: "installation"}, "check-update": {Category: "installation"}, "repair-status": {Category: "installation"}, diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go index 76ecf79..13f31cf 100644 --- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -75,6 +75,7 @@ var nonDeliveryVerbs = map[string]bool{ "attach": true, "detach": true, "detached-status": true, + "config-rebind": true, "context": true, "activate": true, "deactivate": true, diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 67d1b3f..56eda08 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -155,6 +155,42 @@ func detachedStatusCommand(arguments []string) int { return emitJSON(result) } +func configRebindCommand(arguments []string) int { + flags := flag.NewFlagSet("config-rebind", flag.ContinueOnError) + repo := flags.String("repo", ".", "attached repository whose configuration authority should be rebound") + source := flags.String("source", "", "authoritative source: repository, controller, or file") + configPath := flags.String("config", "", "external configuration path used with --source file") + apply := flags.Bool("apply", false, "apply the fingerprinted preview") + expectedFingerprint := flags.String("expected-fingerprint", "", "exact preview fingerprint required by --apply") + jsonOutput := flags.Bool("json", false, "render the result as JSON") + if err := flags.Parse(arguments); err != nil { + return 2 + } + result, err := boatstack.ConfigRebind(boatstack.ConfigRebindOptions{ + Repo: *repo, Source: *source, ConfigPath: *configPath, + Apply: *apply, ExpectedFingerprint: *expectedFingerprint, + }) + if err != nil { + return fail(err) + } + value, err := json.Marshal(result) + if err != nil { + return fail(err) + } + if *jsonOutput { + fmt.Println(string(value)) + } else { + fmt.Println(result.Reason) + if result.NextOperation != "" { + fmt.Println("NEXT=" + result.NextOperation) + } + } + if result.VerificationStatus != "VERIFIED" { + return 1 + } + return 0 +} + func activateCommand(arguments []string) int { flags := flag.NewFlagSet("activate", flag.ContinueOnError) repo := flags.String("repo", ".", "attached repository to activate") @@ -392,6 +428,9 @@ func exportCommand(arguments []string) int { if *repo == "" || *configPath == "" || (*write && *check) { return fail(fmt.Errorf("export requires --repo and --config; --write and --check are mutually exclusive")) } + if err := boatstack.ValidateConfigurationExport(*repo, *configPath, *write); err != nil { + return fail(err) + } config, raw, err := boatstack.LoadConfig(*configPath) if err != nil { return fail(err) @@ -1337,64 +1376,26 @@ 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") + target := flags.String("target", "", "configuration projection: repository or controller; required for hybrid installations") check := flags.Bool("check", false, "dry-run check mode") if err := flags.Parse(arguments); err != nil { return 2 } - configPath := boatstack.WorkspaceFor(*repo).SourceConfigPath() - raw, err := os.ReadFile(configPath) + report, err := boatstack.MigrateManagedConfiguration(*repo, *target, *check) 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, + return fail(err) } value, err := json.Marshal(report) if err != nil { return fail(err) } fmt.Print(string(value)) + if report.Status != "PASS" { + return 1 + } return 0 } @@ -1613,7 +1614,7 @@ func workspaceSyncCommand(arguments []string) int { func run() (result int) { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } if complete := commandTraceCompletion(os.Args[1], os.Args[2:]); complete != nil { @@ -1626,6 +1627,8 @@ func run() (result int) { return detachCommand(os.Args[2:]) case "detached-status": return detachedStatusCommand(os.Args[2:]) + case "config-rebind": + return configRebindCommand(os.Args[2:]) case "context": return contextCommand(os.Args[2:]) case "activate": diff --git a/boatstack/config_authority_conformance_test.go b/boatstack/config_authority_conformance_test.go new file mode 100644 index 0000000..a3880e6 --- /dev/null +++ b/boatstack/config_authority_conformance_test.go @@ -0,0 +1,216 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeRepositoryConfig(t *testing.T, repo, name string) []byte { + t.Helper() + raw := []byte(`{"schema_version":1,"project":{"name":"` + name + `","commands":{"test":"go test ./..."}}}` + "\n") + if err := os.WriteFile(filepath.Join(repo, sourceConfigName), raw, 0o644); err != nil { + t.Fatal(err) + } + return raw +} + +// Positive, relation, and failure-state conformance for: +// control-law: detached-config-divergence-never-controls-ordinary-tools. +func TestRepositoryDivergenceRequiresOnlyExplicitRebind(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/config-authority.git") + writeRepositoryConfig(t, repo, "configuration-a") + attached, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || attached.VerificationStatus != "VERIFIED" || attached.ConfigAuthority != ConfigAuthorityRepository { + t.Fatalf("attach: %+v %v", attached, err) + } + rawB := writeRepositoryConfig(t, repo, "configuration-b") + + topology, err := ResolveConfigurationTopology(repo) + if err != nil || topology.Relation != ConfigRelationDiverged || topology.Authority != ConfigAuthorityRepository { + t.Fatalf("topology did not expose repository divergence: %+v %v", topology, err) + } + if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`)}); denied { + t.Fatalf("ordinary inspection was blocked by configuration divergence: %s", output) + } + if _, err := RequireManagedConfiguration(repo); err == nil || !strings.Contains(err.Error(), "CONFIG_REBIND_REQUIRED") { + t.Fatalf("explicit managed operation did not require rebind: %v", err) + } + + preview, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository}) + if err != nil || preview.VerificationStatus != "VERIFIED" || preview.Applied || preview.Fingerprint == "" { + t.Fatalf("preview: %+v %v", preview, err) + } + if !strings.Contains(preview.NextOperation, WorkspaceFor(repo).HelperPath()) { + t.Fatalf("preview did not prescribe the workspace-bound helper: %s", preview.NextOperation) + } + if current, _ := os.ReadFile(WorkspaceFor(repo).SourceConfigPath()); string(current) == string(rawB) { + t.Fatal("read-only preview changed controller configuration") + } + applied, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository, Apply: true, ExpectedFingerprint: preview.Fingerprint}) + if err != nil || applied.VerificationStatus != "VERIFIED" || !applied.Applied { + t.Fatalf("apply: %+v %v", applied, err) + } + status, err := DetachedStatus(repo) + if err != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch || status.ConfigAuthority != ConfigAuthorityRepository || status.ControllerConfigSHA256 != SHA256Bytes(rawB) { + t.Fatalf("rebound status: %+v %v", status, err) + } +} + +// Negative and bypass conformance for: +// control-law: config-rebind-apply-requires-current-preview. +func TestConfigRebindRejectsStaleFingerprintAndUnsafeSources(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/config-rebind-negative.git") + writeRepositoryConfig(t, repo, "configuration-a") + if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + writeRepositoryConfig(t, repo, "configuration-b") + preview, _ := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository}) + writeRepositoryConfig(t, repo, "configuration-c") + stale, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository, Apply: true, ExpectedFingerprint: preview.Fingerprint}) + if err != nil || stale.VerificationStatus != "BLOCKED" || stale.Applied { + t.Fatalf("stale fingerprint accepted: %+v %v", stale, err) + } + inside := filepath.Join(repo, "inside.json") + if err := os.WriteFile(inside, []byte(`{"schema_version":1,"project":{"name":"inside","commands":{"test":"go test ./..."}}}`), 0o644); err != nil { + t.Fatal(err) + } + unsafe, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceFile, ConfigPath: inside}) + if err != nil || unsafe.VerificationStatus != "BLOCKED" || !strings.Contains(unsafe.Reason, "outside the repository") { + t.Fatalf("unsafe file source accepted: %+v %v", unsafe, err) + } +} + +// Relation conformance for: +// control-law: external-snapshot-authority-is-independent-of-repository-source. +func TestExternalSnapshotIgnoresRepositoryConfigurationChanges(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/external-authority.git") + external, _ := externalConfigFixture(t, "external", "go test ./...") + result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: external}) + if err != nil || result.VerificationStatus != "VERIFIED" || result.ConfigAuthority != ConfigAuthorityExternalSnapshot { + t.Fatalf("attach: %+v %v", result, err) + } + writeRepositoryConfig(t, repo, "repository-change") + topology, err := RequireManagedConfiguration(repo) + if err != nil || topology.Relation != ConfigRelationIndependent { + t.Fatalf("repository change controlled external snapshot: %+v %v", topology, err) + } +} + +// Positive conformance for: +// control-law: detached-only-update-performs-zero-plant-writes. +func TestDetachedOnlyUpdateLeavesRepositoryBytesUnchanged(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/detached-update.git") + external, _ := externalConfigFixture(t, "detached-only", "go test ./...") + if result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: external}); err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + before := filesystemSnapshot(t, repo) + if err := RunUpdate(InitOptions{Repo: repo, Update: true, Yes: true}); err != nil { + t.Fatalf("detached-only update: %v", err) + } + if after := filesystemSnapshot(t, repo); after != before { + t.Fatal("detached-only update changed repository or Git bytes") + } +} + +// Relation conformance for: +// control-law: configuration-writers-preserve-the-declared-authority. +func TestCapabilityRegistrationCannotInvalidateItsRepositoryBinding(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/capability-authority.git") + writeRepositoryConfig(t, repo, "capability-authority") + if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + registered, err := RegisterCapabilityCommand(repo, "visual", "settings", "npm run capture:settings") + if err != nil || registered.Alias != "visual:settings" { + t.Fatalf("register: %+v %v", registered, err) + } + status, err := DetachedStatus(repo) + if err != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch { + t.Fatalf("capability registration invalidated binding: %+v %v", status, err) + } + repository, _, err := LoadConfig(filepath.Join(repo, sourceConfigName)) + if err != nil || repository.Project.Commands["visual:settings"] != "npm run capture:settings" { + t.Fatalf("repository authority was not updated: %+v %v", repository, err) + } +} + +// Negative and bypass conformance for: +// control-law: generic-writers-never-cross-configuration-authority. +func TestMigrationAndExportCannotCrossOrGuessAuthority(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/config-writer-boundary.git") + external, _ := externalConfigFixture(t, "external", "go test ./...") + if result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: external}); err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + writeRepositoryConfig(t, repo, "repository") + if err := os.MkdirAll(filepath.Join(repo, productLoopDirName), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, productLoopDirName, "generated.lock.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + report, err := MigrateManagedConfiguration(repo, "", true) + if err != nil || report.Status != "FAIL" || !strings.Contains(report.Message, "requires --target") { + t.Fatalf("hybrid migration guessed a target: %+v %v", report, err) + } + if err := ValidateConfigurationExport(repo, filepath.Join(repo, sourceConfigName), true); err == nil || !strings.Contains(err.Error(), "cannot cross") { + t.Fatalf("export crossed external authority: %v", err) + } +} + +// Failure-state conformance for: +// control-law: interrupted-rebind-never-accepts-a-mixed-projection. +func TestInterruptedConfigRebindRestoresAcceptedState(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/config-rebind-interrupted.git") + rawA := writeRepositoryConfig(t, repo, "configuration-a") + if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + writeRepositoryConfig(t, repo, "configuration-b") + preview, _ := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository}) + previousCheckpoint := configRebindCheckpoint + configRebindCheckpoint = func(stage string) error { return fmt.Errorf("simulated interruption at %s", stage) } + defer func() { configRebindCheckpoint = previousCheckpoint }() + if _, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository, Apply: true, ExpectedFingerprint: preview.Fingerprint}); err == nil { + t.Fatal("simulated interruption unexpectedly succeeded") + } + controller, err := os.ReadFile(WorkspaceFor(repo).SourceConfigPath()) + if err != nil || string(controller) != string(rawA) { + t.Fatalf("interrupted rebind left mixed controller source: %q %v", controller, err) + } + status, err := DetachedStatus(repo) + if err != nil || !status.Verified || status.ControllerConfigSHA256 != SHA256Bytes(rawA) { + t.Fatalf("interrupted rebind changed accepted binding: %+v %v", status, err) + } +} + +// Compatibility conformance for schema-v2 attachments. +func TestMatchingSchemaV2AttachmentContinuesWithoutMigration(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/config-schema-v2.git") + writeRepositoryConfig(t, repo, "legacy-matching") + result, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + stateRoot, _ := detachedStateRoot() + binding, err := loadBinding(stateRoot, result.RepoID) + if err != nil { + t.Fatal(err) + } + binding.SchemaVersion = detachedSchemaVersionWithConfigDigest + binding.ConfigAuthority = "" + raw, _ := MarshalJSON(binding) + if err := atomicWrite(bindingPath(stateRoot, result.RepoID), raw); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + status, err := DetachedStatus(repo) + if err != nil || !status.Verified || status.ConfigAuthority != ConfigAuthorityLegacyUnknown || status.ConfigRelation != ConfigRelationMatch { + t.Fatalf("matching schema-v2 attachment did not continue: %+v %v", status, err) + } +} diff --git a/boatstack/config_event_registry_test.go b/boatstack/config_event_registry_test.go new file mode 100644 index 0000000..640c6f7 --- /dev/null +++ b/boatstack/config_event_registry_test.go @@ -0,0 +1,88 @@ +package boatstack + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +// configEventClasses is the public event-completeness registry. Every call to a +// configuration reader, renderer, writer, resolver, or admission boundary is +// inventoried below by the AST digest. Adding an unreviewed call changes the +// digest and fails CI. +var configEventClasses = map[string]string{ + "LoadConfig": "reader", + "SourceConfigPath": "resolver", + "ProjectConfigPath": "resolver", + "BuildExportBundle": "renderer", + "WriteExport": "writer", + "WriteExportForRepair": "writer", + "writeExport": "writer", + "MigrateConfigBytes": "writer", + "ResolveConfigurationTopology": "resolver", + "RequireManagedConfiguration": "admission", + "ConfigRebind": "writer", +} + +func calledName(call *ast.CallExpr) string { + switch function := call.Fun.(type) { + case *ast.Ident: + return function.Name + case *ast.SelectorExpr: + return function.Sel.Name + default: + return "" + } +} + +func TestConfigurationEventRegistryIsComplete(t *testing.T) { + entries := []string{} + 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 { + continue + } + counts := map[string]int{} + ast.Inspect(function.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + name := calledName(call) + class, tracked := configEventClasses[name] + if !tracked { + return true + } + counts[name]++ + entries = append(entries, filepath.Base(path)+":"+function.Name.Name+":"+name+":"+class+":"+strconv.Itoa(counts[name])) + return true + }) + } + } + sort.Strings(entries) + digest := SHA256Bytes([]byte(strings.Join(entries, "\n"))) + const expected = "dbccf0d0263b056656e1626a56966b0c9dea5b67187b8e0f1980cd5745f4d6c5" + 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) + } +} diff --git a/boatstack/config_mutation.go b/boatstack/config_mutation.go new file mode 100644 index 0000000..e4b43b6 --- /dev/null +++ b/boatstack/config_mutation.go @@ -0,0 +1,156 @@ +package boatstack + +import ( + "fmt" + "os" + "strings" +) + +type ConfigMigrationResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + Target string `json:"target,omitempty"` + FromVersion int `json:"from_version"` + ToVersion int `json:"to_version"` + Changed bool `json:"changed"` +} + +func commitDetachedConfigBinding(topology ConfigurationTopology, raw []byte) error { + stateRoot, err := detachedStateRoot() + if err != nil { + return err + } + binding, err := loadBinding(stateRoot, topology.RepoID) + if err != nil { + return err + } + binding.SchemaVersion = detachedSchemaVersion + binding.ConfigSHA256 = SHA256Bytes(raw) + binding.ConfigAuthority = topology.Authority + binding.CreatedByVersion = Version + bindingRaw, err := MarshalJSON(binding) + if err != nil { + return err + } + if err := atomicWrite(bindingPath(stateRoot, topology.RepoID), bindingRaw); err != nil { + return err + } + invalidateWorkspaceCache() + return nil +} + +func MigrateManagedConfiguration(repoPath, requestedTarget string, check bool) (ConfigMigrationResult, error) { + topology, err := RequireManagedConfiguration(repoPath) + if err != nil { + return ConfigMigrationResult{Status: "FAIL", Message: err.Error()}, nil + } + target := strings.ToLower(strings.TrimSpace(requestedTarget)) + switch topology.Shape { + case ConfigShapeEmbeddedOnly: + if target == "" { + target = "repository" + } + case ConfigShapeDetachedOnly: + if target == "" { + target = "controller" + if topology.Authority == ConfigAuthorityRepository { + target = "repository" + } + } + case ConfigShapeHybrid: + if target == "" { + return ConfigMigrationResult{Status: "FAIL", Message: "migrate-config requires --target repository or --target controller for a hybrid installation"}, nil + } + } + if target != "repository" && target != "controller" { + return ConfigMigrationResult{Status: "FAIL", Message: "migrate-config --target must be repository or controller"}, nil + } + if topology.Authority == ConfigAuthorityLegacyUnknown && topology.Mode == string(SupervisionDetached) { + return ConfigMigrationResult{Status: "FAIL", Message: "CONFIG_REBIND_REQUIRED: migration needs an explicit detached configuration authority"}, nil + } + if target == "repository" && topology.Mode == string(SupervisionDetached) && topology.Authority != ConfigAuthorityRepository { + return ConfigMigrationResult{Status: "FAIL", Message: "CONFIG_REBIND_REQUIRED: repository migration would cross detached configuration authority"}, nil + } + if target == "controller" && topology.Shape == ConfigShapeHybrid && topology.Authority == ConfigAuthorityRepository { + return ConfigMigrationResult{Status: "FAIL", Message: "CONFIG_REBIND_REQUIRED: controller-only migration would split repository authority"}, nil + } + + sourcePath := topology.RepositorySourcePath + if target == "controller" { + sourcePath = topology.ControllerSourcePath + } + raw, err := osReadFile(sourcePath) + if err != nil { + return ConfigMigrationResult{Status: "FAIL", Message: fmt.Sprintf("failed to read config: %v", err), Target: target}, nil + } + upgraded, fromVersion, toVersion, changed, err := MigrateConfigBytes(raw) + if err != nil { + return ConfigMigrationResult{Status: "FAIL", Message: fmt.Sprintf("migration failed: %v", err), Target: target}, nil + } + result := ConfigMigrationResult{Status: "PASS", Target: target, FromVersion: fromVersion, ToVersion: toVersion, Changed: changed} + if check || !changed { + return result, nil + } + config, err := configFromBytes(sourcePath, upgraded) + if err != nil { + return ConfigMigrationResult{Status: "FAIL", Message: err.Error(), Target: target}, nil + } + if target == "repository" { + repositoryBundle, buildErr := BuildExportBundle(topology.RepositorySourcePath, config, embeddedConfigBytes(upgraded), "boatstack") + if buildErr != nil { + return ConfigMigrationResult{}, buildErr + } + if topology.RepositoryPackagePresent { + if err := WriteExport(topology.RepositoryBundleRoot, repositoryBundle.Files); err != nil { + return ConfigMigrationResult{}, err + } + } + if err := atomicWriteMode(topology.RepositorySourcePath, upgraded, 0o644); err != nil { + return ConfigMigrationResult{}, err + } + if topology.Mode == string(SupervisionDetached) { + controllerBundle, buildErr := BuildExportBundle(topology.ControllerSourcePath, config, upgraded, "boatstack") + if buildErr != nil { + return ConfigMigrationResult{}, buildErr + } + if err := WriteExport(topology.ControllerBundleRoot, controllerBundle.Files); err != nil { + return ConfigMigrationResult{}, err + } + if err := atomicWriteMode(topology.ControllerSourcePath, upgraded, 0o644); err != nil { + return ConfigMigrationResult{}, err + } + } + } else { + controllerBundle, buildErr := BuildExportBundle(topology.ControllerSourcePath, config, upgraded, "boatstack") + if buildErr != nil { + return ConfigMigrationResult{}, buildErr + } + if err := WriteExport(topology.ControllerBundleRoot, controllerBundle.Files); err != nil { + return ConfigMigrationResult{}, err + } + if err := atomicWriteMode(topology.ControllerSourcePath, upgraded, 0o644); err != nil { + return ConfigMigrationResult{}, err + } + } + if topology.Mode == string(SupervisionDetached) { + if err := commitDetachedConfigBinding(topology, upgraded); err != nil { + return ConfigMigrationResult{}, err + } + } + return result, nil +} + +// Small indirections keep migration tests able to exercise read failures without +// making the public mutation API stateful. +var osReadFile = os.ReadFile + +func configFromBytes(path string, raw []byte) (ProjectConfig, error) { + var config ProjectConfig + if err := DecodeJSON("load migrated project configuration", path, raw, &config); err != nil { + return ProjectConfig{}, err + } + if err := ValidateConfig(config); err != nil { + return ProjectConfig{}, err + } + return config, nil +} diff --git a/boatstack/config_rebind.go b/boatstack/config_rebind.go new file mode 100644 index 0000000..f7d8cd0 --- /dev/null +++ b/boatstack/config_rebind.go @@ -0,0 +1,321 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + ConfigRebindSourceRepository = "repository" + ConfigRebindSourceController = "controller" + ConfigRebindSourceFile = "file" +) + +var configRebindCheckpoint = func(string) error { return nil } + +type ConfigRebindOptions struct { + Repo string + Source string + ConfigPath string + Apply bool + ExpectedFingerprint string +} + +type ConfigRebindResult struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` + Applied bool `json:"applied"` + Source string `json:"source"` + OldAuthority string `json:"old_authority"` + NewAuthority string `json:"new_authority"` + OldConfigSHA256 string `json:"old_config_sha256"` + NewConfigSHA256 string `json:"new_config_sha256"` + RepositoryConfigSHA256 string `json:"repository_config_sha256,omitempty"` + ControllerConfigSHA256 string `json:"controller_config_sha256"` + AffectedWorktrees []string `json:"affected_worktrees"` + TouchedRoots []string `json:"touched_roots"` + Fingerprint string `json:"fingerprint"` + NextOperation string `json:"next_operation,omitempty"` + Reason string `json:"reason"` +} + +type configRebindPreview struct { + result ConfigRebindResult + repo string + stateRoot string + ctx WorkspaceContext + binding DetachedBinding + rawConfig []byte + config ProjectConfig + repoBundle map[string][]byte + ctrlBundle map[string][]byte + authority string + sourcePath string +} + +func previewConfigRebind(opts ConfigRebindOptions) (configRebindPreview, error) { + repo, err := ResolveRepository(opts.Repo) + if err != nil { + return configRebindPreview{}, err + } + topology, err := ResolveConfigurationTopology(repo) + if err != nil { + return configRebindPreview{}, err + } + if topology.Mode != string(SupervisionDetached) { + return configRebindPreview{}, fmt.Errorf("config-rebind requires a detached attachment") + } + stateRoot, err := detachedStateRoot() + if err != nil { + return configRebindPreview{}, err + } + ctx, attached, err := detachedContextFor(repo) + if err != nil || !attached { + if err == nil { + err = fmt.Errorf("config-rebind requires a verified detached attachment") + } + return configRebindPreview{}, err + } + binding, err := loadBinding(stateRoot, ctx.RepoID) + if err != nil { + return configRebindPreview{}, err + } + + source := strings.ToLower(strings.TrimSpace(opts.Source)) + var config ProjectConfig + var raw []byte + var sourcePath, authority string + switch source { + case ConfigRebindSourceRepository: + sourcePath = repositorySourceConfigPath(repo) + config, raw, err = LoadConfig(sourcePath) + authority = ConfigAuthorityRepository + case ConfigRebindSourceController: + sourcePath = ctx.SourceConfigPath() + config, raw, err = LoadConfig(sourcePath) + authority = ConfigAuthorityExternalSnapshot + case ConfigRebindSourceFile: + if strings.TrimSpace(opts.ConfigPath) == "" { + return configRebindPreview{}, fmt.Errorf("config-rebind --source file requires --config") + } + config, raw, err = loadDetachedAttachConfig(repo, opts.ConfigPath) + sourcePath, _ = filepath.Abs(opts.ConfigPath) + authority = ConfigAuthorityExternalSnapshot + default: + return configRebindPreview{}, fmt.Errorf("config-rebind --source must be repository, controller, or file") + } + if err != nil { + return configRebindPreview{}, err + } + if err := ValidateConfig(config); err != nil { + return configRebindPreview{}, err + } + controller, err := BuildExportBundle(ctx.SourceConfigPath(), config, raw, "boatstack") + if err != nil { + return configRebindPreview{}, err + } + var repositoryFiles map[string][]byte + if source == ConfigRebindSourceRepository && topology.RepositoryPackagePresent { + repository, buildErr := BuildExportBundle(repositorySourceConfigPath(repo), config, embeddedConfigBytes(raw), "boatstack") + if buildErr != nil { + return configRebindPreview{}, buildErr + } + repositoryFiles = repository.Files + } + + newSHA := SHA256Bytes(raw) + fingerprintInput := struct { + SchemaVersion int `json:"schema_version"` + BoatstackVersion string `json:"boatstack_version"` + SourceCommit string `json:"source_commit"` + RepoID string `json:"repo_id"` + RepoRoot string `json:"repo_root"` + WorktreeID string `json:"worktree_id"` + Branch string `json:"branch"` + Head string `json:"head"` + Aliases []string `json:"aliases"` + Shape string `json:"shape"` + Source string `json:"source"` + SourcePath string `json:"source_path"` + SourceSHA256 string `json:"source_sha256"` + BindingSHA256 string `json:"binding_sha256"` + RepositorySHA256 string `json:"repository_sha256"` + ControllerSHA256 string `json:"controller_sha256"` + }{ + SchemaVersion: detachedSchemaVersion, BoatstackVersion: Version, SourceCommit: SourceCommit, + RepoID: ctx.RepoID, RepoRoot: repo, WorktreeID: ctx.WorktreeID, + Branch: gitOutput(repo, "branch", "--show-current"), Head: gitOutput(repo, "rev-parse", "HEAD"), + Aliases: topology.AffectedWorktrees, Shape: topology.Shape, Source: source, SourcePath: sourcePath, + SourceSHA256: newSHA, BindingSHA256: binding.ConfigSHA256, + RepositorySHA256: topology.RepositoryConfigSHA256, ControllerSHA256: topology.ControllerConfigSHA256, + } + fingerprintBytes, err := MarshalJSON(fingerprintInput) + if err != nil { + return configRebindPreview{}, err + } + fingerprint := SHA256Bytes(fingerprintBytes) + touched := []string{ctx.ExportRoot(), bindingPath(stateRoot, ctx.RepoID)} + if len(repositoryFiles) > 0 { + touched = append(touched, repo) + } + nextArgs := []string{"--repo", repo, "--source", source} + if source == ConfigRebindSourceFile { + nextArgs = append(nextArgs, "--config", sourcePath) + } + nextArgs = append(nextArgs, "--apply", "--expected-fingerprint", fingerprint, "--json") + next := PrescribedCommand{Program: ctx.HelperPath(), Verb: "config-rebind", Args: nextArgs}.CommandLine() + result := ConfigRebindResult{ + SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", Source: source, + OldAuthority: normalizedConfigAuthority(binding), NewAuthority: authority, + OldConfigSHA256: binding.ConfigSHA256, NewConfigSHA256: newSHA, + RepositoryConfigSHA256: topology.RepositoryConfigSHA256, ControllerConfigSHA256: topology.ControllerConfigSHA256, + AffectedWorktrees: topology.AffectedWorktrees, TouchedRoots: touched, Fingerprint: fingerprint, + NextOperation: next, Reason: "Preview verified. No files changed.", + } + return configRebindPreview{result: result, repo: repo, stateRoot: stateRoot, ctx: ctx, binding: binding, rawConfig: raw, config: config, repoBundle: repositoryFiles, ctrlBundle: controller.Files, authority: authority, sourcePath: sourcePath}, nil +} + +type savedFile struct { + path string + value []byte + mode os.FileMode + existed bool +} + +func snapshotFiles(paths []string) ([]savedFile, error) { + seen := map[string]bool{} + out := []savedFile{} + for _, path := range paths { + if seen[path] { + continue + } + seen[path] = true + info, err := os.Lstat(path) + if os.IsNotExist(err) { + out = append(out, savedFile{path: path}) + continue + } + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + if err == nil { + err = fmt.Errorf("refusing non-regular transaction path: %s", path) + } + return nil, err + } + value, err := os.ReadFile(path) + if err != nil { + return nil, err + } + out = append(out, savedFile{path: path, value: value, mode: info.Mode().Perm(), existed: true}) + } + return out, nil +} + +func restoreFiles(saved []savedFile) error { + for i := len(saved) - 1; i >= 0; i-- { + item := saved[i] + if !item.existed { + if err := os.Remove(item.path); err != nil && !os.IsNotExist(err) { + return err + } + continue + } + if err := atomicWriteMode(item.path, item.value, item.mode); err != nil { + return err + } + } + return nil +} + +func ConfigRebind(opts ConfigRebindOptions) (result ConfigRebindResult, returnErr error) { + preview, err := previewConfigRebind(opts) + if err != nil { + return ConfigRebindResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil + } + if !opts.Apply { + return preview.result, nil + } + if strings.TrimSpace(opts.ExpectedFingerprint) == "" || opts.ExpectedFingerprint != preview.result.Fingerprint { + preview.result.VerificationStatus = "BLOCKED" + preview.result.Reason = "The configuration topology changed or the expected fingerprint is missing. Preview again." + return preview.result, nil + } + + paths := []string{preview.ctx.SourceConfigPath(), bindingPath(preview.stateRoot, preview.ctx.RepoID)} + for relative := range preview.ctrlBundle { + paths = append(paths, filepath.Join(preview.ctx.ExportRoot(), filepath.FromSlash(relative))) + } + for relative := range preview.repoBundle { + paths = append(paths, filepath.Join(preview.repo, filepath.FromSlash(relative))) + } + receiptPath := filepath.Join(preview.ctx.controlRoot, "operations", "config-rebind", preview.result.Fingerprint+".json") + paths = append(paths, receiptPath) + saved, err := snapshotFiles(paths) + if err != nil { + return ConfigRebindResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil + } + defer func() { + if returnErr != nil { + if rollbackErr := restoreFiles(saved); rollbackErr != nil { + returnErr = fmt.Errorf("%v; config-rebind rollback failed: %w", returnErr, rollbackErr) + } + } + }() + + receipt, err := MarshalJSON(map[string]any{ + "schema_version": 1, "operation": "config-rebind", "state": "PREPARED", + "fingerprint": preview.result.Fingerprint, "repo_id": preview.ctx.RepoID, + "old_config_sha256": preview.binding.ConfigSHA256, "new_config_sha256": preview.result.NewConfigSHA256, + }) + if err != nil { + return ConfigRebindResult{}, err + } + if err := atomicWrite(receiptPath, receipt); err != nil { + return ConfigRebindResult{}, err + } + if len(preview.repoBundle) > 0 { + for _, relative := range sortedKeys(preview.repoBundle) { + if err := atomicWriteMode(filepath.Join(preview.repo, filepath.FromSlash(relative)), preview.repoBundle[relative], generatedFileMode(relative)); err != nil { + return ConfigRebindResult{}, err + } + } + if err := CheckExport(preview.repo, preview.repoBundle); err != nil { + return ConfigRebindResult{}, err + } + } + for _, relative := range sortedKeys(preview.ctrlBundle) { + if err := atomicWriteMode(filepath.Join(preview.ctx.ExportRoot(), filepath.FromSlash(relative)), preview.ctrlBundle[relative], generatedFileMode(relative)); err != nil { + return ConfigRebindResult{}, err + } + } + if err := atomicWriteMode(preview.ctx.SourceConfigPath(), preview.rawConfig, 0o644); err != nil { + return ConfigRebindResult{}, err + } + if err := CheckExport(preview.ctx.ExportRoot(), preview.ctrlBundle); err != nil { + return ConfigRebindResult{}, err + } + if err := configRebindCheckpoint("projections-written"); err != nil { + return ConfigRebindResult{}, fmt.Errorf("config-rebind checkpoint projections-written: %w", err) + } + + preview.binding.SchemaVersion = detachedSchemaVersion + preview.binding.ConfigSHA256 = preview.result.NewConfigSHA256 + preview.binding.ConfigAuthority = preview.authority + preview.binding.CreatedByVersion = Version + bindingRaw, err := MarshalJSON(preview.binding) + if err != nil { + return ConfigRebindResult{}, err + } + // The binding is the acceptance record and is promoted last. Any interruption + // before this write leaves the mixed projection unaccepted and fail-closed. + if err := atomicWrite(bindingPath(preview.stateRoot, preview.ctx.RepoID), bindingRaw); err != nil { + return ConfigRebindResult{}, err + } + invalidateWorkspaceCache() + preview.result.Applied = true + preview.result.ControllerConfigSHA256 = preview.result.NewConfigSHA256 + preview.result.NextOperation = "" + preview.result.Reason = "Configuration authority rebound and both selected projections verify." + return preview.result, nil +} diff --git a/boatstack/config_topology.go b/boatstack/config_topology.go new file mode 100644 index 0000000..2732e7b --- /dev/null +++ b/boatstack/config_topology.go @@ -0,0 +1,195 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + ConfigShapeEmbeddedOnly = "EMBEDDED_ONLY" + ConfigShapeDetachedOnly = "DETACHED_ONLY" + ConfigShapeHybrid = "HYBRID" +) + +// ConfigurationTopology is the read-only authority map for configuration. +// The repository package and detached controller are separate projections; +// neither source is selected merely because its path happened to be resolved +// first. +type ConfigurationTopology struct { + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + Shape string `json:"shape"` + Authority string `json:"authority"` + Relation string `json:"relation"` + RepoRoot string `json:"repo_root"` + RepoID string `json:"repo_id,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + BindingPath string `json:"binding_path,omitempty"` + RepositorySourcePath string `json:"repository_source_path,omitempty"` + RepositoryBundleRoot string `json:"repository_bundle_root,omitempty"` + ControllerSourcePath string `json:"controller_source_path,omitempty"` + ControllerBundleRoot string `json:"controller_bundle_root,omitempty"` + RepositoryConfigSHA256 string `json:"repository_config_sha256,omitempty"` + ControllerConfigSHA256 string `json:"controller_config_sha256,omitempty"` + BindingConfigSHA256 string `json:"binding_config_sha256,omitempty"` + RepositoryPackagePresent bool `json:"repository_package_present"` + AffectedWorktrees []string `json:"affected_worktrees,omitempty"` + NextOperation string `json:"next_operation,omitempty"` +} + +func repositorySourceConfigPath(repo string) string { + return filepath.Join(repo, sourceConfigName) +} + +func repositoryPackagePresent(repo string) bool { + return fileExists(filepath.Join(repo, productLoopDirName, "generated.lock.json")) +} + +func fileSHAIfRegular(path string) (string, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("configuration source must be a regular non-symlink file: %s", path) + } + return SHA256File(path) +} + +func detachedAliases(stateRoot, repoID string) ([]string, error) { + registry, err := loadRegistry(stateRoot) + if err != nil { + return nil, err + } + aliases := []string{} + for path, registeredID := range registry.Repositories { + if registeredID == repoID { + aliases = append(aliases, path) + } + } + sort.Strings(aliases) + return aliases, nil +} + +func ResolveConfigurationTopology(repoPath string) (ConfigurationTopology, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return ConfigurationTopology{}, err + } + repositorySource := repositorySourceConfigPath(repo) + repositorySHA, err := fileSHAIfRegular(repositorySource) + if err != nil { + return ConfigurationTopology{}, err + } + packagePresent := repositoryPackagePresent(repo) + ctx, attached, detachedErr := detachedContextFor(repo) + if detachedErr != nil { + return ConfigurationTopology{}, detachedErr + } + if !attached { + relation := ConfigRelationRepositoryAbsent + if repositorySHA != "" { + relation = ConfigRelationMatch + } + return ConfigurationTopology{ + SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded), Shape: ConfigShapeEmbeddedOnly, + Authority: ConfigAuthorityRepository, Relation: relation, RepoRoot: repo, + RepositorySourcePath: repositorySource, RepositoryBundleRoot: repo, + RepositoryConfigSHA256: repositorySHA, RepositoryPackagePresent: packagePresent, + }, nil + } + stateRoot, err := detachedStateRoot() + if err != nil { + return ConfigurationTopology{}, err + } + binding, err := loadBinding(stateRoot, ctx.RepoID) + if err != nil { + return ConfigurationTopology{}, err + } + authority := normalizedConfigAuthority(binding) + relation := ConfigRelationMatch + switch { + case repositorySHA == "": + relation = ConfigRelationRepositoryAbsent + case authority == ConfigAuthorityExternalSnapshot || authority == ConfigAuthoritySynthesized: + relation = ConfigRelationIndependent + case repositorySHA != binding.ConfigSHA256: + relation = ConfigRelationDiverged + } + aliases, err := detachedAliases(stateRoot, ctx.RepoID) + if err != nil { + return ConfigurationTopology{}, err + } + shape := ConfigShapeDetachedOnly + if packagePresent { + shape = ConfigShapeHybrid + } + next := "" + if relation == ConfigRelationDiverged && (authority == ConfigAuthorityRepository || authority == ConfigAuthorityLegacyUnknown) { + next = PrescribedCommand{Program: ctx.HelperPath(), Verb: "config-rebind", Args: []string{"--repo", repo, "--source", "repository", "--json"}}.CommandLine() + } + return ConfigurationTopology{ + SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionDetached), Shape: shape, + Authority: authority, Relation: relation, RepoRoot: repo, RepoID: ctx.RepoID, + WorktreeID: ctx.WorktreeID, BindingPath: bindingPath(stateRoot, ctx.RepoID), + RepositorySourcePath: repositorySource, RepositoryBundleRoot: repo, + ControllerSourcePath: ctx.SourceConfigPath(), ControllerBundleRoot: ctx.ExportRoot(), + RepositoryConfigSHA256: repositorySHA, ControllerConfigSHA256: binding.ConfigSHA256, + BindingConfigSHA256: binding.ConfigSHA256, RepositoryPackagePresent: packagePresent, + AffectedWorktrees: aliases, NextOperation: next, + }, nil +} + +// RequireManagedConfiguration is called only after explicit Boatstack +// invocation. Ambient safety uses the verified controller directly and never +// turns repository/source divergence into ordinary-work interference. +func RequireManagedConfiguration(repo string) (ConfigurationTopology, error) { + topology, err := ResolveConfigurationTopology(repo) + if err != nil { + return ConfigurationTopology{}, err + } + if topology.Relation == ConfigRelationDiverged && + (topology.Authority == ConfigAuthorityRepository || topology.Authority == ConfigAuthorityLegacyUnknown) { + return topology, fmt.Errorf("CONFIG_REBIND_REQUIRED: repository configuration %s differs from detached controller %s; preview the explicit repair with %s", topology.RepositoryConfigSHA256, topology.ControllerConfigSHA256, strings.TrimSpace(topology.NextOperation)) + } + return topology, nil +} + +// ValidateConfigurationExport prevents the generic exporter from becoming an +// untracked cross-authority writer. Read-only dry runs remain available. +func ValidateConfigurationExport(repoPath, configPath string, write bool) error { + if !write { + return nil + } + // Distribution export into a fresh staging directory has no repository + // authority to cross. The guard applies only once the destination resolves to + // a live Git repository. + if _, err := ResolveRepository(repoPath); err != nil { + return nil + } + topology, err := RequireManagedConfiguration(repoPath) + if err != nil { + return err + } + absoluteConfig, err := filepath.Abs(configPath) + if err != nil { + return err + } + absoluteConfig = filepath.Clean(absoluteConfig) + if topology.Mode == string(SupervisionEmbedded) { + if absoluteConfig != filepath.Clean(topology.RepositorySourcePath) { + return fmt.Errorf("export --write requires the repository configuration source %s", topology.RepositorySourcePath) + } + return nil + } + if topology.Authority != ConfigAuthorityRepository || absoluteConfig != filepath.Clean(topology.RepositorySourcePath) { + return fmt.Errorf("CONFIG_REBIND_REQUIRED: export --write cannot cross detached configuration authority; use config-rebind or write the declared controller projection") + } + return nil +} diff --git a/boatstack/detached.go b/boatstack/detached.go index 16392bc..cf6f143 100644 --- a/boatstack/detached.go +++ b/boatstack/detached.go @@ -19,8 +19,11 @@ const ( // directory through it so they never read or write a real home directory. stateRootEnv = "BOATSTACK_STATE_ROOT" // detachedSchemaVersion versions the public detached status and binding - // records. Version 2 binds the exact detached project configuration bytes. - detachedSchemaVersion = 2 + // records. Version 2 binds exact configuration bytes. Version 3 also records + // which source owns future configuration changes; version 2 remains readable + // as LEGACY_UNKNOWN so an update never forces a migration. + detachedSchemaVersion = 3 + detachedSchemaVersionWithConfigDigest = 2 // The registry remains a path-to-repository index. Configuration provenance // belongs to the authoritative per-repository binding, not this index. detachedRegistrySchemaVersion = 1 @@ -155,6 +158,7 @@ type DetachedBinding struct { InitialCommit string `json:"initial_commit"` NormalizedOrigin string `json:"normalized_origin"` ConfigSHA256 string `json:"config_sha256"` + ConfigAuthority string `json:"config_authority,omitempty"` CreatedByVersion string `json:"created_by_version"` CreatedAt string `json:"created_at"` } @@ -325,7 +329,7 @@ type detachedGeneratedLock struct { // the exact bytes accepted at attachment. // control-law: detached-config-digest-gates-resume func verifyDetachedConfiguration(ctx WorkspaceContext, binding DetachedBinding) error { - if binding.SchemaVersion != detachedSchemaVersion { + if binding.SchemaVersion < detachedSchemaVersionWithConfigDigest || binding.SchemaVersion > detachedSchemaVersion { return fmt.Errorf("detached binding schema_version %d is unsupported; reattach with `boatstack-helper attach --repo %s --mode detached --force --config `", binding.SchemaVersion, ctx.RepoRoot) } if strings.TrimSpace(binding.ConfigSHA256) == "" { @@ -364,6 +368,30 @@ func verifyDetachedConfiguration(ctx WorkspaceContext, binding DetachedBinding) return nil } +const ( + ConfigAuthorityRepository = "REPOSITORY" + ConfigAuthorityExternalSnapshot = "EXTERNAL_SNAPSHOT" + ConfigAuthoritySynthesized = "SYNTHESIZED" + ConfigAuthorityLegacyUnknown = "LEGACY_UNKNOWN" + + ConfigRelationMatch = "MATCH" + ConfigRelationDiverged = "DIVERGED" + ConfigRelationIndependent = "INDEPENDENT" + ConfigRelationRepositoryAbsent = "REPOSITORY_ABSENT" +) + +func normalizedConfigAuthority(binding DetachedBinding) string { + if binding.SchemaVersion < detachedSchemaVersion || strings.TrimSpace(binding.ConfigAuthority) == "" { + return ConfigAuthorityLegacyUnknown + } + switch binding.ConfigAuthority { + case ConfigAuthorityRepository, ConfigAuthorityExternalSnapshot, ConfigAuthoritySynthesized: + return binding.ConfigAuthority + default: + return ConfigAuthorityLegacyUnknown + } +} + // detachedContextFor returns the detached WorkspaceContext for repo when the // repository is attached and its binding verifies. ok is false for an unattached // repository (the caller should use the embedded layout). err is non-nil only for diff --git a/boatstack/init.go b/boatstack/init.go index 1e3ba31..cb9f76c 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -328,6 +328,50 @@ func checkUpdateDiffScope(repo string, currentFiles map[string][]byte, previous return changed, nil } +func runDetachedOnlyUpdate(options InitOptions, topology ConfigurationTopology) error { + if !options.Update { + return fmt.Errorf("detached-only installation is created with attach, not init") + } + config, rawConfig, err := LoadConfig(topology.ControllerSourcePath) + if err != nil { + return err + } + bundle, err := BuildExportBundle(topology.ControllerSourcePath, config, rawConfig, "boatstack") + if err != nil { + return err + } + helperSource := options.BinaryPath + if helperSource == "" { + helperSource, err = os.Executable() + if err != nil { + return err + } + } + before := updateChangedPaths(topology.RepoRoot) + if err := writeExport(topology.ControllerBundleRoot, bundle.Files, nil); err != nil { + return fmt.Errorf("refresh detached controller bundle: %w", err) + } + states, err := readInstalledIntegrations(topology.ControllerBundleRoot, config) + if err != nil { + states = config.Integrations + } + if _, err := installDetachedRuntime(topology.RepoRoot, helperSource); err != nil { + return fmt.Errorf("refresh detached shared runtime: %w", err) + } + if _, _, err := installControllerLocalRuntime(topology.ControllerBundleRoot, helperSource, states); err != nil { + return fmt.Errorf("refresh detached controller helper: %w", err) + } + if err := CheckExport(topology.ControllerBundleRoot, bundle.Files); err != nil { + return err + } + after := updateChangedPaths(topology.RepoRoot) + if strings.Join(before, "\n") != strings.Join(after, "\n") { + return fmt.Errorf("detached-only update changed repository paths") + } + fmt.Fprintf(options.Output, "PASS: detached-only Boatstack controller updated to %s; repository files were unchanged.\n", Version) + return nil +} + func RunInit(options InitOptions) (returnErr error) { if options.Input == nil { options.Input = os.Stdin @@ -339,8 +383,15 @@ func RunInit(options InitOptions) (returnErr error) { if err != nil { return err } + topology, err := RequireManagedConfiguration(repo) + if err != nil { + return err + } + if topology.Shape == ConfigShapeDetachedOnly { + return runDetachedOnlyUpdate(options, topology) + } reader := bufio.NewReader(options.Input) - configPath := WorkspaceFor(repo).SourceConfigPath() + configPath := topology.RepositorySourcePath configExists := fileExists(configPath) installed := fileExists(filepath.Join(repo, ".product-loop", "generated.lock.json")) || fileExists(filepath.Join(repo, ".product-loop", "bin", helperName())) if installed && !options.Update { @@ -475,6 +526,23 @@ func RunInit(options InitOptions) (returnErr error) { if err != nil { return err } + var controllerBundle map[string][]byte + var controllerRawConfig []byte + if topology.Shape == ConfigShapeHybrid { + controllerConfig := config + controllerRawConfig = rawConfig + if topology.Authority != ConfigAuthorityRepository { + controllerConfig, controllerRawConfig, err = LoadConfig(topology.ControllerSourcePath) + if err != nil { + return fmt.Errorf("load independent detached configuration: %w", err) + } + } + controller, buildErr := BuildExportBundle(topology.ControllerSourcePath, controllerConfig, controllerRawConfig, "boatstack") + if buildErr != nil { + return buildErr + } + controllerBundle = controller.Files + } if err := ValidateJSON("validate project configuration before initialization", configPath, rawConfig); err != nil { return err } @@ -610,16 +678,36 @@ func RunInit(options InitOptions) (returnErr error) { if writeErr != nil { return writeErr } - // An attached repository still receives the reviewed embedded update package, - // but its active controller reads the detached projection. Refresh that same - // bundle under the resolved export root before any smoke check; feature state - // is outside the bundle key set and remains untouched. - if ctx := WorkspaceFor(repo); ctx.Mode == SupervisionDetached { - if err := writeExport(ctx.ExportRoot(), bundle.Files, nil); err != nil { + // Hybrid installations have two projections. Generate each from its declared + // source; never copy the repository bundle across the authority boundary. + if topology.Shape == ConfigShapeHybrid { + if err := writeExport(topology.ControllerBundleRoot, controllerBundle, nil); err != nil { return fmt.Errorf("refresh detached controller bundle: %w", err) } - if err := atomicWriteMode(ctx.SourceConfigPath(), rawConfig, 0o644); err != nil { - return fmt.Errorf("refresh detached source configuration: %w", err) + if topology.Authority == ConfigAuthorityRepository { + if err := atomicWriteMode(topology.ControllerSourcePath, controllerRawConfig, 0o644); err != nil { + return fmt.Errorf("refresh repository-authoritative detached source: %w", err) + } + stateRoot, stateErr := detachedStateRoot() + if stateErr != nil { + return stateErr + } + binding, bindingErr := loadBinding(stateRoot, topology.RepoID) + if bindingErr != nil { + return bindingErr + } + binding.SchemaVersion = detachedSchemaVersion + binding.ConfigAuthority = ConfigAuthorityRepository + binding.ConfigSHA256 = SHA256Bytes(controllerRawConfig) + binding.CreatedByVersion = Version + bindingRaw, marshalErr := MarshalJSON(binding) + if marshalErr != nil { + return marshalErr + } + if err := atomicWrite(bindingPath(stateRoot, topology.RepoID), bindingRaw); err != nil { + return fmt.Errorf("commit detached configuration binding: %w", err) + } + invalidateWorkspaceCache() } } if err := initCheckpoint("export-written"); err != nil { @@ -650,8 +738,8 @@ func RunInit(options InitOptions) (returnErr error) { if err := writeInstallLock(repo, binaryPath, binaryHash, states); err != nil { return err } - if ctx := WorkspaceFor(repo); ctx.Mode == SupervisionDetached { - if _, _, err := installControllerLocalRuntime(ctx.ExportRoot(), helperSource, states); err != nil { + if topology.Shape == ConfigShapeHybrid { + if _, _, err := installControllerLocalRuntime(topology.ControllerBundleRoot, helperSource, states); err != nil { return fmt.Errorf("refresh detached controller helper: %w", err) } } @@ -755,6 +843,7 @@ Boatstack workflow control begins only when the user explicitly invokes Boatstac 1. On explicit Boatstack invocation, save the proposed plan as a durable file inside the repository and pass its path to auto-plan with ` + "`--plan `" + ` (Boatstack does not scan directories for plans; an out-of-repo path is rejected so the plan stays hash-current through build). 2. Carry the selected feature slug through every status and planning call. After workspace-cut, continue only from its returned destination repository. 3. Async task completion, conversation state, or an execution-mode transition never creates implementation authority. Only a current plan lock bound to this worktree and branch does. +4. After explicit Boatstack invocation in a detached repository, use the workspace-resolved helper to query ` + "`detached-status --repo .`" + `. Configuration divergence never controls ordinary tools. If status returns ` + "`CONFIG_REBIND_REQUIRED`" + `, run its exact workspace-bound ` + "`config-rebind`" + ` preview and apply only the returned fingerprinted command. ` const interceptorHeader = "\n" @@ -806,6 +895,10 @@ func RunUpdate(options InitOptions) error { if options.Output == nil { options.Output = os.Stdout } + topology, err := RequireManagedConfiguration(repo) + if err != nil { + return err + } // Cross-version provenance guard. Each helper embeds its own generated bundle // and version constants, so a running helper cannot correctly install a // different version in-process — stamping the running version onto foreign @@ -821,7 +914,11 @@ func RunUpdate(options InitOptions) error { return reexecUpdate(options.BinaryPath, options) } } - configPath := WorkspaceFor(repo).SourceConfigPath() + if topology.Shape == ConfigShapeDetachedOnly { + options.Update = true + return RunInit(options) + } + configPath := topology.RepositorySourcePath config, rawConfig, configErr := LoadConfig(configPath) if configErr != nil { return configErr diff --git a/boatstack/planning.go b/boatstack/planning.go index 962390e..fbcb147 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -411,6 +411,23 @@ func CheckInstallationHealth(repoPath string) error { if err != nil { return err } + topology, err := RequireManagedConfiguration(repo) + if err != nil { + return err + } + if topology.Shape == ConfigShapeHybrid { + repositoryConfig, repositoryRaw, loadErr := LoadConfig(topology.RepositorySourcePath) + if loadErr != nil { + return fmt.Errorf("invalid repository Boatstack configuration: %w", loadErr) + } + repositoryBundle, buildErr := BuildExportBundle(topology.RepositorySourcePath, repositoryConfig, embeddedConfigBytes(repositoryRaw), "boatstack") + if buildErr != nil { + return buildErr + } + if checkErr := CheckExport(topology.RepositoryBundleRoot, repositoryBundle.Files); checkErr != nil { + return fmt.Errorf("repository Boatstack package is stale: %w", checkErr) + } + } ctx, err := ResolveWorkspaceContext(repo) if err != nil { return err diff --git a/boatstack/provision.go b/boatstack/provision.go index b0697a9..29c7269 100644 --- a/boatstack/provision.go +++ b/boatstack/provision.go @@ -234,7 +234,19 @@ func RegisterCapabilityCommand(repo, name, surface, command string) (RegisteredC alias = capability.Name + ":" + surface } - sourcePath := WorkspaceFor(resolved).SourceConfigPath() + 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 { @@ -248,20 +260,61 @@ func RegisterCapabilityCommand(repo, name, surface, command string) (RegisteredC if err != nil { return RegisteredCapability{}, err } - bundle, err := BuildExportBundle(sourcePath, config, rawConfig, "boatstack") + bundleRaw := rawConfig + if targetRoot == topology.RepositoryBundleRoot { + bundleRaw = embeddedConfigBytes(rawConfig) + } + bundle, err := BuildExportBundle(sourcePath, config, bundleRaw, "boatstack") if err != nil { return RegisteredCapability{}, err } - if err := WriteExport(resolved, bundle.Files); 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 + } + } + 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() + } return RegisteredCapability{Capability: capability.Name, Alias: alias, Command: command, Source: "source-and-export"}, nil } - configPath := WorkspaceFor(resolved).ProjectConfigPath() + configPath := filepath.Join(targetRoot, productLoopDirName, "project.json") config, _, err := LoadConfig(configPath) if err != nil { return RegisteredCapability{}, err diff --git a/release-notes/2026-08-10-detached-configuration-authority.md b/release-notes/2026-08-10-detached-configuration-authority.md new file mode 100644 index 0000000..2c4c59e --- /dev/null +++ b/release-notes/2026-08-10-detached-configuration-authority.md @@ -0,0 +1,2 @@ +### Keep detached configuration projections independent +Detached and hybrid installations now preserve the declared repository or controller configuration authority during updates and explicit configuration changes. Repository configuration divergence no longer blocks ordinary tools; Boatstack reports a fingerprinted `config-rebind` repair only when an invoked workflow needs reconciliation.