diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e141e3b..743c2c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ # Contributing -Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/e3fa496abb8b23f4c2d575929de16755a9cdc2ab/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/2b61577db37944e1b1c3aad534eeeb3b32f5eb4d/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index 6180b34..fbee5d6 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "8dd08eaf68727e283f509aed9e33896da2aa9c473de95db9816bf62e642911b1", + "CONTRIBUTING.md": "e1a3cad13687fd1f9469b7cd9cd1e869143c417c3a97a4d895b9971720873dd0", "README.md": "534091974042589c31978080b0268761164f2279850f02c30e07f48945ef2321", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -35,7 +35,7 @@ "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", - "boatstack/attach.go": "8d23596da7c0dc77704d603112e3c5ce7de57f61279bfc27fee810d50aecb8df", + "boatstack/attach.go": "640616e4f08a72d045aa6f420fb47bbc99f0cf217f18479a88b39c771598613e", "boatstack/authority.go": "2ed62d4e9a93cbca946f41a5dd9292d00bb4de2ab030a13fa9f554701fdfa2f7", "boatstack/authority_test.go": "5e0d96b9f095170cf3288bd58a9b5394acb4f757a527639b91f96383c7eab3b9", "boatstack/autonomy.go": "45091e2451b9f8862c5620e13f2cec1e990161aec54cf9ce5073084f3de85c9a", @@ -49,8 +49,8 @@ "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "188a6d60b819e1594e45468262f8e868e56c14ebec20d81de1c002b5db7e67e7", "boatstack/cmd/boatstack-helper/flow.go": "0d41c7a86b49004e897f59551780220841d5941396202c81de97a4d52f593520", "boatstack/cmd/boatstack-helper/insight.go": "a6bb2afbf631eecd6005662e71b9956950fe2e56b1522dfd6ca93092d9a8d729", - "boatstack/cmd/boatstack-helper/main.go": "898842aecc744c4ee12bb6c19e9f895975eb1e62665400220c00049f763e5522", - "boatstack/cmd/boatstack-helper/main_test.go": "b36c52d6d5c9dd2428730de10ff18194b7e32a98722e41341c301c6f7a04cad5", + "boatstack/cmd/boatstack-helper/main.go": "e720856950d3456a33f5527ffeb17c433be83a48cee6b797fc46ccd3ece2fee9", + "boatstack/cmd/boatstack-helper/main_test.go": "4370dedc65cfc1570857c46a5280fcc05c6c4d0812cf19b66a485222829d8bbb", "boatstack/cmd/boatstack-helper/retro.go": "68b83e33ade5b5fec126c70ec798fdcbed22fda755dc1cad2758143fead8e187", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", @@ -60,7 +60,7 @@ "boatstack/context.go": "fcab6ad475603b30bc6a8a59d82e257e74c587636adfe20a2016f48e24019d17", "boatstack/decision.go": "944fceed965396c66c8089edbf73dbe8d4c716da17289e407e45832c91211885", "boatstack/decision_test.go": "ac36687c5012f6d142d89472490e1c5f60c4427470f275afbe3afd4247db2281", - "boatstack/delivery.go": "a362ed25024a91b1e98260fb49d1f9d9182fa713452e2e079d525243f3d85a3e", + "boatstack/delivery.go": "b21c8893c5fa202d732970d1d9624a430cb3bfd5eee6fe85c4b229afed8412e6", "boatstack/delivery_boundary_conformance_test.go": "53dde765046420b9119e82034d137742e600019938ed908c608f725d8a0c84c6", "boatstack/delivery_migrate.go": "4f31a1f2665200e86616e5b9b1cc1d1ba2e46edf0c5b0e9df2e98bab90c3763c", "boatstack/delivery_migrate_conformance_test.go": "b8ba53681e1d0361ac62b06586c62b7763d55a65b5427976b5289e1fb1503bdc", @@ -75,7 +75,8 @@ "boatstack/denial_solutions.go": "7d1cae6b8a5fd373795f8a83f62861c79a931e630972ea04490378e0400ab0fc", "boatstack/denial_solutions_conformance_test.go": "0b329dabffbc2666dffd2ff9e2b7d9d27816472d6fbee91a47a79c67527dce8d", "boatstack/denial_test.go": "9dc9f0f79328c4947073efaa785479b34e70eb214da57cd72348f39fd672e4fd", - "boatstack/detached.go": "b0ea2a1f31bf2a2a83f6089a3065a9b47221194b64de48af9120046e6d70dee8", + "boatstack/detached.go": "3e3f2b81e2d79107ede2adc55fa296d0f487aa90d8d847d3cac2aa82120c3b12", + "boatstack/detached_external_config_conformance_test.go": "6554739e80d32672f472fa55b1599e9594f4ba8790eb9939c3aef9e883316f1c", "boatstack/detached_migration.go": "01a45e392307d14033fbf40aca379d91ce745ef0f386cc57a6273275386fc2f7", "boatstack/detached_ownership_conformance_test.go": "9a6044d33d3a49c916241846e51a4411cba6e64ddef8f142cc5627af3caad6dd", "boatstack/detached_test.go": "2cd744335a80b9fbc2db8fa7955658dd31691c43150d67bf15029d06ce84277a", @@ -116,7 +117,7 @@ "boatstack/init_test.go": "5fdf687205e7a5984a98a87336b7127e4ae9b651d57e21ec2dc8ca7e653ee602", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", "boatstack/insight.go": "7ec492b65043f7b10dae9dd55104d22bc56775759b8c3fb288545dede01b9f89", - "boatstack/insight_conformance_test.go": "ef36d80a7a91bf20630b1e4683ee4fdc93c2e3f8ec8a9d0e8e4336aba859c25e", + "boatstack/insight_conformance_test.go": "9604b3f2c8501d7d8c3f01edb4ee335b65908a66fa20b70d2995fb13b889d3b1", "boatstack/installation_repair.go": "f6889e3d21102d2aedfe1af900945d0a3ea2a772b3fddad787cef99803ef91aa", "boatstack/installation_repair_test.go": "fe831f15458c10057654a296ee4472177ec150ca937462d169787566525a3976", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", @@ -157,16 +158,16 @@ "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", - "boatstack/next.go": "4468b7c489358a62d90e3b3bf3af6ebf6dcd13486fab87ca23a28daa52041485", + "boatstack/next.go": "a11998eaa2779b74fa0572df45fa84e848a6e3782c76674b81e705268189dcce", "boatstack/next_actor_conformance_test.go": "92838b63129369d166b760055cac2727a02e2cb490b6d33dbfe4781c77f65719", "boatstack/next_banner_test.go": "c431a6987ed1e479442fc9f5db4371632880b92aa790fa9dd0f5285293352c41", "boatstack/next_response.go": "11decf2e3b236cbaa183980946ec17ffbbbb1af9c08bd11a466a8487bf229d5f", "boatstack/next_response_conformance_test.go": "be4f3bc7507abfb0ae9f86310eb29e34b166dcc40b6fa103e05babb81f2bd928", "boatstack/next_test.go": "6b5ec46ecf1a197d7644846cecbb6d99873a06b7c4e5562772b5016fa0a4cb11", - "boatstack/operation.go": "1eac601c216282983dceb6f2f7c0be58b06312fb9659447bba02df896d23929a", + "boatstack/operation.go": "63a58c3e1247624b4dcd71d0c8d6f19818b1d17ff128e019564841949e3df659", "boatstack/operation_test.go": "9580b71ed4fa70cf73f02975737c824484341fa6751e670e7d183e898e1ffdde", - "boatstack/paths.go": "9341d9fcda8f02f769cd81ef814789de5e167d79ae6d53300b76d5e6971bd952", - "boatstack/plan.go": "986eaaafa95c88d0b912a2fb7ac811a5b38c7a38fcc141138bfa4e6a660e2f45", + "boatstack/paths.go": "17f50de1eeefc4e023383eadae3f41bc2813a666baa5eb967650505237236bba", + "boatstack/plan.go": "04ad740d2aab9802baefc963534965c3ea1a1bd19238569c3a3e54bea435cde6", "boatstack/plan_test.go": "1b01e7d9d7794eb11c998e19a2f3532d3b8509d984eca934cf5f1662a0a7e573", "boatstack/plan_validation.go": "06ff8fa8c22525bd371848a7776682a2b041ea5e1aacc913170856d0dda83edd", "boatstack/plan_validation_test.go": "ce17eb7449c1d9ec2e829943082bb9680414e02b8f0d5049418bace5488bbedb", @@ -174,7 +175,7 @@ "boatstack/planning_first_write_conformance_test.go": "873097aa9384b75bf01e74a475f3ec2ac7cca4a28f733e82f2c82959032c6a30", "boatstack/planning_test.go": "06ec7022222d926040c3ae28b84ab50c3d2f804ae6473e61b303804dd992d884", "boatstack/post_publish_prescribe_conformance_test.go": "3c20d359ff84648db7dedb227b4d64e6574d9f41d3cdca0adefec1c60bfbf4ae", - "boatstack/pr.go": "6a6c383ca04f5277c2a8a74322fd4d1baac93ba45b6803e82c44e7a91fab1536", + "boatstack/pr.go": "843f83e4a5d9ad0e997c6d8bd5795b6e8324f34f0f4264d2500933f1bcf36917", "boatstack/pr_phase.go": "59f8cbb75b6b538a5345474acd6a725450979579bf8ecf9591956cbbe1cc4737", "boatstack/pr_phase_conformance_test.go": "bc9c834e9c4ed43b35d81abafd7b1bf2a264ea2a8c4a4ec9758ee18d1d438968", "boatstack/pr_test.go": "ab99ba83bd33fe10e1fca61c0f164f0a59711ba2fa42ab3e7f870a0ed3dcc890", @@ -185,7 +186,7 @@ "boatstack/published_slice_routing_test.go": "ea7e7351018bc13dcd31c4b96f50f8bc230e8a1dbf7806fba32a12ae58923e7e", "boatstack/readiness.go": "121c566ac7a0945137f05b0c55ff0dad3f8fdf450ec1a3891f6caa906c907266", "boatstack/readiness_conformance_test.go": "e3df7730e18f671873400e8061f178310c45eab6d7cdebedf8234590d3e7eab8", - "boatstack/recovery.go": "63fe04b789113a2362037fe5bfc552db422537b6ae562e1029586cb5177efe85", + "boatstack/recovery.go": "7eb2089253b120f25dc6e05dd17bb1d630955982293352a21097d3e877620b0c", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/reexec.go": "fed55416479d7bd3e0c3637057ffe8eb58a032f93fc358f76df906ab7acc677b", "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", @@ -203,7 +204,7 @@ "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", "boatstack/retro.go": "8a6f13b948574c90d0f06c3b9f5570d08931e66a4c78dbcc208da8c696c2a42b", "boatstack/retro_conformance_test.go": "827250d2fc49717fb5e58a4cf79e1d5c489c37574d8a2cf9348fa1cd8c328713", - "boatstack/run.go": "88c07690b5406fa4acdf76873fcb505df64451aa9d603dc0232e50064e226e38", + "boatstack/run.go": "9def8566965735e7b028235ecfe01e525b06fe42c4948fca7f5572430b0b1e47", "boatstack/run_test.go": "5b291510fa90cefdc26eb89e18a3443385456a6ebc73408325ac1945b7c084d6", "boatstack/runtime.go": "6cb321617d81618672391dd1988bd7f6cd4e4bdbdc8c68d6b458001e2f33bc3e", "boatstack/runtime_cache.go": "6f6b023170cce982bf155e7c2fc7752cca2f7acff771967b4a523c1c13ea876f", @@ -242,10 +243,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "2ceb050bb67737c725b66d7e191949c2f3b652ecbafc5e44002e99bc785114ab", - "docs/evidence-engineered-coding.md": "06ffa316440f481ed929f7a55003b52297e7cc220d53ac8d346b274a350aae46", + "docs/evidence-engineered-coding.md": "3e12871b4561507e4dbebca80649b22f9dc23acf8bb527d55d61f03a0b53691e", "docs/generated-files.md": "8679b960bacbdf2ca7b898aa44eb3a486ebb325eca8ce9cc4e316191e7ef5087", - "docs/getting-started.md": "41f3fd80dc71a60c10964fd22fe0c079954d239c8782e62f325918190f59979b", - "docs/public-claims.json": "e1ad0ae673ee8e64904897958d626c58711adfab845ca1ab9999243a852348cc", + "docs/getting-started.md": "834e6d1c33d5198743a3f896c4e205801713762dcd2fa339e47c99b532df2cf5", + "docs/public-claims.json": "d12663da575041a87cd174a1e9db780078ca9633634315922425db03d2854954", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "1a2b84e0a4b9aa6322d35d6677ff52662c306089031295692b569233cdd94d8f", @@ -259,7 +260,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "efc6de5cd900cd9204078d5c97c6e83ef2eb770e2400718fb7c4f91df741cc01", + "labs/diagram-json/plan.lock.json": "83e623384d3ba7a497dc22efa6cc770e281624d0c784059fc388677c19c184e9", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -413,12 +414,13 @@ "release-notes/2026-08-02-detached-ownership-boundary.md": "a85bdb80e28ccb9369bda06a03f73f3377889e0c2cc9f0ce4d85e8056946600b", "release-notes/2026-08-02-goal-driven-autonomous-run.md": "a5b66250c98ca6c23c3761bc09c81cebd02a1243de12fc6110bd1119fd8faf4a", "release-notes/2026-08-02-strengthen-screenshot-delivery.md": "2e56eeb08702f4dd58dce75e26a52fbcb7e48af0cd5dd062d4e2a204704705df", - "release-notes/2026-08-04-external-authority-boundary.md": "0bc788db940f7fbc3624a01fff554d7002137e9915b10c47fd287dc1b24cdd66" + "release-notes/2026-08-04-external-authority-boundary.md": "0bc788db940f7fbc3624a01fff554d7002137e9915b10c47fd287dc1b24cdd66", + "release-notes/2026-08-05-detached-external-config.md": "b8b5ab914eae695f67c4e25deba3957894439e054b42ab93751bccb614235fda" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "e3fa496abb8b23f4c2d575929de16755a9cdc2ab", + "commit": "2b61577db37944e1b1c3aad534eeeb3b32f5eb4d", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/attach.go b/boatstack/attach.go index fbd43c8..b076d7e 100644 --- a/boatstack/attach.go +++ b/boatstack/attach.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // Attach, detach, and status operations for Detached Supervision. Attaching a @@ -15,8 +16,9 @@ import ( // AttachOptions requests a detached attachment. StateRoot, when set, overrides the // external control-state root for this process (the CLI wires --state-root to it). type AttachOptions struct { - Repo string - Force bool + Repo string + ConfigPath string + Force bool } // AttachResult is the deterministic outcome of an attach request. @@ -28,10 +30,50 @@ type AttachResult struct { 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"` FeatureMigrations []DetachedFeatureMigration `json:"feature_migrations,omitempty"` } +func loadDetachedAttachConfig(root, explicitPath string) (ProjectConfig, []byte, error) { + if strings.TrimSpace(explicitPath) == "" { + configPath := filepath.Join(root, sourceConfigName) + config, raw, err := LoadConfig(configPath) + if os.IsNotExist(err) { + config = defaultConfig(root, detectTestCommand(root)) + raw, err = MarshalJSON(config) + } + return config, raw, err + } + absolute, err := filepath.Abs(explicitPath) + if err != nil { + return ProjectConfig{}, nil, err + } + inputInfo, err := os.Lstat(absolute) + if err != nil { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration is missing or unreadable: %w", err) + } + if inputInfo.Mode()&os.ModeSymlink != 0 { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration must be a regular non-symlink file") + } + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration is missing or unreadable: %w", err) + } + info, err := os.Lstat(resolved) + if err != nil || !info.Mode().IsRegular() { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration must be a readable regular file") + } + common, err := gitCommonDir(root) + if err != nil { + return ProjectConfig{}, nil, err + } + if pathWithin(root, resolved) || pathWithin(common, resolved) { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration must be outside the repository and its Git directory") + } + return LoadConfig(resolved) +} + func blockedAttach(reason string) AttachResult { return AttachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason} } @@ -63,17 +105,11 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { ctx := detachedContextFromIdentity(stateRoot, identity) - // Prefer the repository's declared source configuration during explicit - // reattachment. Falling back to discovery is valid only when no source exists. - configPath := filepath.Join(root, sourceConfigName) - config, rawConfig, err := LoadConfig(configPath) - if os.IsNotExist(err) { - config = defaultConfig(root, detectTestCommand(root)) - rawConfig, err = MarshalJSON(config) - } + config, rawConfig, err := loadDetachedAttachConfig(root, opts.ConfigPath) if err != nil { - return blockedAttach("Boatstack could not load the repository source configuration: " + err.Error()), nil + return blockedAttach("Boatstack could not load the detached project configuration: " + err.Error()), nil } + configSHA256 := SHA256Bytes(rawConfig) imports, migrationResults, migrationErr := planDetachedFeatureImports(root, ctx) if migrationErr != nil { result := blockedAttach("Boatstack refused detached feature migration: " + migrationErr.Error()) @@ -94,7 +130,11 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { if err := writeExport(ctx.controlRoot, bundle.Files, nil); err != nil { return blockedAttach("Boatstack could not write the controller bundle: " + err.Error()), nil } - if err := os.WriteFile(ctx.SourceConfigPath(), rawConfig, 0o644); err != nil { + sourcePath, err := newControllerPath(ctx.controlRoot, ctx.SourceConfigPath()) + if err != nil { + return blockedAttach(err.Error()), nil + } + if err := atomicWrite(sourcePath.path, rawConfig); err != nil { return blockedAttach(err.Error()), nil } migrationResults, err = applyDetachedFeatureImports(imports, migrationResults) @@ -111,6 +151,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { GitCommonIdentity: identity.GitCommonIdentity, InitialCommit: identity.InitialCommit, NormalizedOrigin: identity.NormalizedOrigin, + ConfigSHA256: configSHA256, CreatedByVersion: Version, CreatedAt: nowRFC3339(), } @@ -121,7 +162,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { if err := os.MkdirAll(filepath.Dir(bindingPath(stateRoot, identity.RepoID)), 0o755); err != nil { return blockedAttach(err.Error()), nil } - if err := os.WriteFile(bindingPath(stateRoot, identity.RepoID), bindingRaw, 0o644); err != nil { + if err := atomicWrite(bindingPath(stateRoot, identity.RepoID), bindingRaw); err != nil { return blockedAttach(err.Error()), nil } registry.Repositories[root] = identity.RepoID @@ -147,6 +188,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: identity.WorktreeID, + ConfigSHA256: configSHA256, FeatureMigrations: migrationResults, Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.", }, nil @@ -223,6 +265,7 @@ type DetachedStatusResult struct { 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"` } @@ -241,14 +284,38 @@ func DetachedStatus(repoPath string) (DetachedStatusResult, error) { }, nil } if verifyErr != nil { + configSHA256 := "" + stateRoot, rootErr := detachedStateRoot() + if rootErr == nil { + registry, registryErr := loadRegistry(stateRoot) + if registryErr == nil { + if binding, bindingErr := loadBinding(stateRoot, registry.Repositories[root]); bindingErr == nil { + configSHA256 = binding.ConfigSHA256 + } + } + } return DetachedStatusResult{ SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached), - RepoRoot: root, Reason: verifyErr.Error(), + RepoID: ctx.RepoID, RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID, + ConfigSHA256: configSHA256, Reason: verifyErr.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, - Reason: "This repository is attached in detached mode and its binding verifies.", + ConfigSHA256: bindingConfigSHA256(ctx), + Reason: "This repository is attached in detached mode and its binding verifies.", }, nil } + +func bindingConfigSHA256(ctx WorkspaceContext) string { + stateRoot, err := detachedStateRoot() + if err != nil { + return "" + } + binding, err := loadBinding(stateRoot, ctx.RepoID) + if err != nil { + return "" + } + return binding.ConfigSHA256 +} diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index dd0d116..0f73885 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -99,6 +99,7 @@ func attachCommand(arguments []string) int { repo := flags.String("repo", ".", "repository to attach") mode := flags.String("mode", "detached", "supervision mode; only \"detached\" is supported by attach") stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)") + config := flags.String("config", "", "external project configuration to validate and copy into detached control state") force := flags.Bool("force", false, "re-attach even if the repository is already attached") if err := flags.Parse(arguments); err != nil { return 2 @@ -107,7 +108,7 @@ func attachCommand(arguments []string) int { return fail(fmt.Errorf("attach supports only --mode detached")) } applyStateRoot(*stateRoot) - result, err := boatstack.AttachDetached(boatstack.AttachOptions{Repo: *repo, Force: *force}) + result, err := boatstack.AttachDetached(boatstack.AttachOptions{Repo: *repo, ConfigPath: *config, Force: *force}) if err != nil { return fail(err) } diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index 0d548cc..a16f0dc 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -49,6 +49,42 @@ func TestBootstrapFailureUsesBlockingExitCode(t *testing.T) { } } +// control-law: detached-config-input-stays-outside-plant +func TestAttachCommandAcceptsExternalConfigFlag(t *testing.T) { + repo := t.TempDir() + commands := [][]string{ + {"git", "-C", repo, "init", "-b", "main"}, + {"git", "-C", repo, "config", "user.name", "Boatstack Test"}, + {"git", "-C", repo, "config", "user.email", "boatstack@example.invalid"}, + } + for _, arguments := range commands { + if output, err := exec.Command(arguments[0], arguments[1:]...).CombinedOutput(); err != nil { + t.Fatalf("%v: %v: %s", arguments, err, output) + } + } + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# app\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, arguments := range [][]string{{"git", "-C", repo, "add", "README.md"}, {"git", "-C", repo, "commit", "-m", "initial"}} { + if output, err := exec.Command(arguments[0], arguments[1:]...).CombinedOutput(); err != nil { + t.Fatalf("%v: %v: %s", arguments, err, output) + } + } + configPath := filepath.Join(t.TempDir(), "project.json") + config := []byte(`{"schema_version":1,"project":{"name":"works-yield","commands":{"test":"pnpm test"}}}` + "\n") + if err := os.WriteFile(configPath, config, 0o644); err != nil { + t.Fatal(err) + } + stateRoot := t.TempDir() + var code int + output := captureStdout(t, func() { + code = attachCommand([]string{"--repo", repo, "--mode", "detached", "--config", configPath, "--state-root", stateRoot}) + }) + if code != 0 || !strings.Contains(output, `"verification_status": "VERIFIED"`) || !strings.Contains(output, `"config_sha256":`) { + t.Fatalf("attach --config failed: code=%d output=%s", code, output) + } +} + // captureStdout runs fn with os.Stdout redirected and returns what it printed, // so read-only CLI verbs can be asserted without polluting test output. func captureStdout(t *testing.T, fn func()) string { diff --git a/boatstack/delivery.go b/boatstack/delivery.go index ae2f1d3..2c9dc2f 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -300,7 +300,11 @@ func deliveryDefinitions(plan map[string]any) ([]DeliverySlice, error) { } func deliveryStateDirectory(repo string) (string, error) { - return WorkspaceFor(repo).DeliveryDir() + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return "", err + } + return ctx.DeliveryDir() } func deliveryStatePath(repo, feature string) (string, error) { @@ -1386,7 +1390,11 @@ func IgnoreDelivery(repo, feature string) (bool, error) { if err != nil { return false, err } - configPath := WorkspaceFor(resolved).ProjectConfigPath() + ctx, err := ResolveWorkspaceContext(resolved) + if err != nil { + return false, err + } + configPath := ctx.ProjectConfigPath() config, _, err := LoadConfig(configPath) if err != nil { return false, err diff --git a/boatstack/detached.go b/boatstack/detached.go index 3a724bf..55624ea 100644 --- a/boatstack/detached.go +++ b/boatstack/detached.go @@ -18,8 +18,12 @@ const ( // stateRootEnv overrides the external control-state root. Tests inject a temp // directory through it so they never read or write a real home directory. stateRootEnv = "BOATSTACK_STATE_ROOT" - // detachedSchemaVersion versions the registry and binding records. - detachedSchemaVersion = 1 + // detachedSchemaVersion versions the public detached status and binding + // records. Version 2 binds the exact detached project configuration bytes. + detachedSchemaVersion = 2 + // The registry remains a path-to-repository index. Configuration provenance + // belongs to the authoritative per-repository binding, not this index. + detachedRegistrySchemaVersion = 1 // repoIDLength is the hex width of a repository identity key. repoIDLength = 16 // worktreeIDLength is the hex width of a per-worktree identity key. @@ -150,6 +154,7 @@ type DetachedBinding struct { GitCommonIdentity string `json:"git_common_identity"` InitialCommit string `json:"initial_commit"` NormalizedOrigin string `json:"normalized_origin"` + ConfigSHA256 string `json:"config_sha256"` CreatedByVersion string `json:"created_by_version"` CreatedAt string `json:"created_at"` } @@ -173,7 +178,7 @@ func bindingPath(stateRoot, repoID string) string { } func loadRegistry(stateRoot string) (detachedRegistry, error) { - registry := detachedRegistry{SchemaVersion: detachedSchemaVersion, Repositories: map[string]string{}} + registry := detachedRegistry{SchemaVersion: detachedRegistrySchemaVersion, Repositories: map[string]string{}} raw, err := os.ReadFile(registryPath(stateRoot)) if err != nil { if os.IsNotExist(err) { @@ -191,7 +196,7 @@ func loadRegistry(stateRoot string) (detachedRegistry, error) { } func saveRegistry(stateRoot string, registry detachedRegistry) error { - registry.SchemaVersion = detachedSchemaVersion + registry.SchemaVersion = detachedRegistrySchemaVersion raw, err := MarshalJSON(registry) if err != nil { return err @@ -231,6 +236,55 @@ func bindingMatchesIdentity(binding DetachedBinding, identity RepoIdentity) bool return true } +type detachedGeneratedLock struct { + ConfigSHA256 string `json:"config_sha256"` + Files map[string]string `json:"files"` +} + +// verifyDetachedConfiguration proves that the authoritative source copy, its +// generated snapshot, and the generated runtime configuration still describe +// the exact bytes accepted at attachment. +// control-law: detached-config-digest-gates-resume +func verifyDetachedConfiguration(ctx WorkspaceContext, binding DetachedBinding) error { + if 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) == "" { + return fmt.Errorf("detached binding is missing config_sha256; reattach with `boatstack-helper attach --repo %s --mode detached --force --config `", ctx.RepoRoot) + } + sourceSHA, err := SHA256File(ctx.SourceConfigPath()) + if err != nil { + return fmt.Errorf("detached project configuration is missing or unreadable: %w", err) + } + if sourceSHA != binding.ConfigSHA256 { + return fmt.Errorf("detached project configuration drifted from bound SHA-256 %s; restore the exact attached bytes or reattach with `boatstack-helper attach --repo %s --mode detached --force --config `", binding.ConfigSHA256, ctx.RepoRoot) + } + lockPath := filepath.Join(ctx.GeneratedRoot(), "generated.lock.json") + lockRaw, err := os.ReadFile(lockPath) + if err != nil { + return fmt.Errorf("detached generated configuration snapshot is missing or unreadable: %w", err) + } + var lock detachedGeneratedLock + if err := DecodeJSON("verify detached generated configuration snapshot", lockPath, lockRaw, &lock); err != nil { + return err + } + if lock.ConfigSHA256 != binding.ConfigSHA256 { + return fmt.Errorf("detached generated configuration snapshot does not match bound SHA-256 %s", binding.ConfigSHA256) + } + expectedProjectSHA := lock.Files[productLoopDirName+"/project.json"] + if expectedProjectSHA == "" { + return fmt.Errorf("detached generated configuration snapshot does not bind %s/project.json", productLoopDirName) + } + projectSHA, err := SHA256File(ctx.ProjectConfigPath()) + if err != nil { + return fmt.Errorf("detached generated project configuration is missing or unreadable: %w", err) + } + if projectSHA != expectedProjectSHA { + return fmt.Errorf("detached generated project configuration drifted from its snapshot") + } + return nil +} + // 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 @@ -265,13 +319,17 @@ func detachedContextFor(repo string) (ctx WorkspaceContext, ok bool, err error) return WorkspaceContext{}, true, fmt.Errorf("detached binding cannot be verified: %w", idErr) } binding, bindErr := loadBinding(stateRoot, repoID) + ctx = detachedContextFromIdentity(stateRoot, identity) if bindErr != nil { - return WorkspaceContext{}, true, fmt.Errorf("detached binding for %s is missing or unreadable: %w", repo, bindErr) + return ctx, true, fmt.Errorf("detached binding for %s is missing or unreadable: %w", repo, bindErr) } if !bindingMatchesIdentity(binding, identity) { - return WorkspaceContext{}, true, fmt.Errorf("detached binding does not match this repository's identity; reattach with `boatstack-helper attach` or migrate the binding") + return ctx, true, fmt.Errorf("detached binding does not match this repository's identity; reattach with `boatstack-helper attach` or migrate the binding") + } + if configErr := verifyDetachedConfiguration(ctx, binding); configErr != nil { + return ctx, true, configErr } - return detachedContextFromIdentity(stateRoot, identity), true, nil + return ctx, true, nil } // detachedContextFromIdentity builds the detached WorkspaceContext for a resolved diff --git a/boatstack/detached_external_config_conformance_test.go b/boatstack/detached_external_config_conformance_test.go new file mode 100644 index 0000000..0bf64cd --- /dev/null +++ b/boatstack/detached_external_config_conformance_test.go @@ -0,0 +1,328 @@ +package boatstack + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func externalConfigFixture(t *testing.T, name, command string) (string, []byte) { + t.Helper() + directory := t.TempDir() + path := filepath.Join(directory, "project.json") + raw := []byte(`{"schema_version":1,"project":{"name":"` + name + `","commands":{"test":"` + command + `"}}}` + "\n") + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + return path, raw +} + +func filesystemSnapshot(t *testing.T, root string) string { + t.Helper() + entries := []string{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + line := filepath.ToSlash(relative) + " " + info.Mode().String() + if info.Mode().IsRegular() { + digest, err := SHA256File(path) + if err != nil { + return err + } + line += " " + digest + } else if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err != nil { + return err + } + line += " " + target + } + entries = append(entries, line) + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(entries) + return strings.Join(entries, "\n") +} + +// control-law: detached-config-digest-gates-resume +func TestDetachedAttachAcceptsExternalConfigWithoutPlantWrites(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/external-config.git") + configPath, raw := externalConfigFixture(t, "works-yield", "pnpm --filter @works/yield-web test") + before := filesystemSnapshot(t, repo) + + result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + if after := filesystemSnapshot(t, repo); after != before { + t.Fatal("external configuration attachment changed repository or Git bytes") + } + wantSHA := SHA256Bytes(raw) + if result.SchemaVersion != detachedSchemaVersion || result.ConfigSHA256 != wantSHA { + t.Fatalf("attach digest = %q schema=%d, want %q schema=%d", result.ConfigSHA256, result.SchemaVersion, wantSHA, detachedSchemaVersion) + } + ctx := WorkspaceFor(repo) + copied, err := os.ReadFile(ctx.SourceConfigPath()) + if err != nil || string(copied) != string(raw) { + t.Fatalf("external source copy = %q, %v", copied, err) + } + generated, _, err := LoadConfig(ctx.ProjectConfigPath()) + if err != nil || generated.Project.Name != "works-yield" || generated.Project.Commands["test"] != "pnpm --filter @works/yield-web test" { + t.Fatalf("generated config did not preserve supplied values: %+v %v", generated, err) + } + stateRoot, _ := detachedStateRoot() + binding, err := loadBinding(stateRoot, result.RepoID) + if err != nil || binding.ConfigSHA256 != wantSHA || binding.SchemaVersion != detachedSchemaVersion { + t.Fatalf("binding did not capture config digest: %+v %v", binding, err) + } + lockRaw, err := os.ReadFile(filepath.Join(ctx.GeneratedRoot(), "generated.lock.json")) + if err != nil { + t.Fatal(err) + } + var lock detachedGeneratedLock + if err := json.Unmarshal(lockRaw, &lock); err != nil || lock.ConfigSHA256 != wantSHA { + t.Fatalf("generated lock did not capture config digest: %+v %v", lock, err) + } + status, _ := DetachedStatus(repo) + if !status.Verified || status.ConfigSHA256 != wantSHA || status.SchemaVersion != detachedSchemaVersion { + t.Fatalf("detached status did not bind config digest: %+v", status) + } + + // Attachment is copy-based. Later changes to the input path are not live + // configuration changes and cannot alter the bound detached snapshot. + if err := os.WriteFile(configPath, []byte(`{"schema_version":2}`), 0o644); err != nil { + t.Fatal(err) + } + status, _ = DetachedStatus(repo) + if !status.Verified || status.ConfigSHA256 != wantSHA { + t.Fatalf("changing original input changed detached attachment: %+v", status) + } +} + +// control-law: detached-config-input-stays-outside-plant +func TestDetachedAttachRejectsInvalidOrNonExternalConfigBeforeWrites(t *testing.T) { + tests := []struct { + name string + build func(*testing.T, string) string + want string + }{ + {name: "missing", build: func(t *testing.T, _ string) string { return filepath.Join(t.TempDir(), "missing.json") }, want: "missing or unreadable"}, + {name: "malformed", build: func(t *testing.T, _ string) string { + p := filepath.Join(t.TempDir(), "project.json") + _ = os.WriteFile(p, []byte("{\n"), 0o644) + return p + }, want: "parse JSON"}, + {name: "newer schema", build: func(t *testing.T, _ string) string { + p := filepath.Join(t.TempDir(), "project.json") + _ = os.WriteFile(p, []byte(`{"schema_version":2,"project":{"name":"x","commands":{"test":"true"}}}`), 0o644) + return p + }, want: "newer Boatstack"}, + {name: "repository local", build: func(t *testing.T, repo string) string { + p := filepath.Join(repo, "project.json") + _ = os.WriteFile(p, []byte(`{"schema_version":1,"project":{"name":"x","commands":{"test":"true"}}}`), 0o644) + return p + }, want: "outside the repository"}, + {name: "git local", build: func(t *testing.T, repo string) string { + gitDir, _ := gitCommonDir(repo) + p := filepath.Join(gitDir, "project.json") + _ = os.WriteFile(p, []byte(`{"schema_version":1,"project":{"name":"x","commands":{"test":"true"}}}`), 0o644) + return p + }, want: "outside the repository"}, + {name: "symlink", build: func(t *testing.T, _ string) string { + real, _ := externalConfigFixture(t, "x", "true") + link := filepath.Join(t.TempDir(), "project.json") + _ = os.Symlink(real, link) + return link + }, want: "non-symlink"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/reject-"+strings.ReplaceAll(test.name, " ", "-")+".git") + path := test.build(t, repo) + result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: path}) + if err != nil || result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, test.want) { + t.Fatalf("result = %+v, err=%v, want %q", result, err, test.want) + } + stateRoot, _ := detachedStateRoot() + if _, statErr := os.Stat(stateRoot); !os.IsNotExist(statErr) { + t.Fatalf("rejected config wrote detached state: %v", statErr) + } + }) + } +} + +func assertDetachedDriftBlocked(t *testing.T, repo, want string) { + t.Helper() + status, err := DetachedStatus(repo) + if err != nil || !status.Attached || status.Verified || status.ConfigSHA256 == "" || !strings.Contains(status.Reason, want) { + t.Fatalf("status did not report %q drift: %+v %v", want, status, err) + } + next, err := ResolveNext(repo, "") + if err != nil || next.VerificationStatus != "BLOCKED" || next.NextOperation != "attach" { + t.Fatalf("next-status did not fail closed: %+v %v", next, err) + } + recovery, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "fix", SourceStage: "ci"}) + if err != nil || recovery.VerificationStatus != "BLOCKED" { + t.Fatalf("recovery-status did not fail closed: %+v %v", recovery, err) + } + preflight := CheckRunPreflight(repo, "") + if preflight.VerificationStatus != "BLOCKED" || preflight.Relation != "DETACHED_CONFIG_DRIFT" { + t.Fatalf("run-preflight did not fail closed: %+v", preflight) + } + if _, err := LoadDeliveryState(repo, "missing-feature"); err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("delivery state bypassed detached verification: %v", err) + } +} + +// control-law: detached-config-digest-gates-resume +func TestDetachedConfigDriftBlocksResumeAndRestoresExactly(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/drift.git") + configPath, _ := externalConfigFixture(t, "drift", "true") + result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + ctx := WorkspaceFor(repo) + + sourcePath := ctx.SourceConfigPath() + source, _ := os.ReadFile(sourcePath) + if err := os.WriteFile(sourcePath, append(source, ' '), 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + assertDetachedDriftBlocked(t, repo, "drifted from bound SHA-256") + if WorkspaceFor(repo).ProjectConfigPath() != ctx.ProjectConfigPath() { + t.Fatal("unverified attachment redirected controller paths into repository") + } + if err := os.WriteFile(sourcePath, source, 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + if status, _ := DetachedStatus(repo); !status.Verified { + t.Fatalf("exact source restoration did not recover verification: %+v", status) + } + + projectPath := ctx.ProjectConfigPath() + project, _ := os.ReadFile(projectPath) + if err := os.WriteFile(projectPath, append(project, ' '), 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + assertDetachedDriftBlocked(t, repo, "generated project configuration drifted") + if err := os.WriteFile(projectPath, project, 0o644); err != nil { + t.Fatal(err) + } + + lockPath := filepath.Join(ctx.GeneratedRoot(), "generated.lock.json") + lock, _ := os.ReadFile(lockPath) + var changed map[string]any + if err := json.Unmarshal(lock, &changed); err != nil { + t.Fatal(err) + } + changed["config_sha256"] = strings.Repeat("0", 64) + changedRaw, _ := MarshalJSON(changed) + if err := os.WriteFile(lockPath, changedRaw, 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + assertDetachedDriftBlocked(t, repo, "snapshot does not match") + if err := os.WriteFile(lockPath, lock, 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + if status, _ := DetachedStatus(repo); !status.Verified { + t.Fatalf("exact generated-state restoration did not recover verification: %+v", status) + } +} + +// control-law: detached-config-digest-gates-resume +func TestDetachedConfigDriftBlocksMutationAndPublicationBypasses(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/drift-bypass.git") + embeddedFeatureForDetach(t, repo, "feature-one", "") + configPath, _ := externalConfigFixture(t, "drift-bypass", "true") + result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + ctx := WorkspaceFor(repo) + source, _ := os.ReadFile(ctx.SourceConfigPath()) + if err := os.WriteFile(ctx.SourceConfigPath(), append(source, ' '), 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + before := filesystemSnapshot(t, ctx.controlRoot) + planPath := filepath.Join(ctx.FeatureDir("feature-one"), "plan.md") + + checks := []struct { + name string + run func() error + }{ + {name: "activation", run: func() error { + return ActivatePlan(ActivationOptions{ + PlanPath: planPath, OutDir: filepath.Join(ctx.FeatureDir("feature-one"), "compiled"), + OutputPath: filepath.Join(ctx.FeatureDir("feature-one"), "plan.lock.json"), SourceCommit: "test", + }) + }}, + {name: "repair", run: func() error { + _, _, err := RecordChangeObservation(ChangeObservationOptions{Repo: repo, Feature: "feature-one", Classification: "implementation_repair", Message: "fix", SourceStage: "ci"}) + return err + }}, + {name: "gate", run: func() error { + _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: "feature-one", SliceID: "delivery", Gate: "test", Status: "PASS"}) + return err + }}, + {name: "pr-context", run: func() error { + _, err := PreparePRContext(PRContextOptions{Repo: repo}) + return err + }}, + {name: "publish", run: func() error { + _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: "missing.md", ExpectedFingerprint: "missing", Action: "open"}) + return err + }}, + {name: "operation", run: func() error { + _, err := PrepareOperation(OperationPrepareOptions{Repo: repo, Kind: "test", Target: "target", PackageFingerprint: "package", ExpectedPostcondition: "done", RetryClass: "ATOMIC_LOCAL"}) + return err + }}, + } + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); err == nil || !strings.Contains(err.Error(), "drifted from bound SHA-256") { + t.Fatalf("entry point did not reach detached config boundary: %v", err) + } + }) + } + if after := filesystemSnapshot(t, ctx.controlRoot); after != before { + t.Fatal("blocked resume path mutated detached controller state") + } +} + +// control-law: detached-config-rebinding-requires-explicit-force +func TestDetachedForceReattachRebindsExternalConfig(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/rebind.git") + firstPath, firstRaw := externalConfigFixture(t, "first", "true") + first, _ := AttachDetached(AttachOptions{Repo: repo, ConfigPath: firstPath}) + secondPath, secondRaw := externalConfigFixture(t, "second", "pnpm test") + blocked, _ := AttachDetached(AttachOptions{Repo: repo, ConfigPath: secondPath}) + if blocked.VerificationStatus != "BLOCKED" { + t.Fatalf("reattach without force succeeded: %+v", blocked) + } + second, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: secondPath, Force: true}) + if err != nil || second.VerificationStatus != "VERIFIED" || second.ConfigSHA256 != SHA256Bytes(secondRaw) || second.ConfigSHA256 == SHA256Bytes(firstRaw) || second.ConfigSHA256 == first.ConfigSHA256 { + t.Fatalf("forced reattach did not rebind config: first=%+v second=%+v err=%v", first, second, err) + } +} diff --git a/boatstack/insight_conformance_test.go b/boatstack/insight_conformance_test.go index c051c17..87410f0 100644 --- a/boatstack/insight_conformance_test.go +++ b/boatstack/insight_conformance_test.go @@ -14,14 +14,7 @@ func configureInsights(t *testing.T, repo string, terminal DeliveryTerminal) Wor t.Helper() stateRoot := t.TempDir() t.Setenv("BOATSTACK_STATE_ROOT", stateRoot) - if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { - t.Fatal(err) - } - ctx := WorkspaceFor(repo) - config, _, err := LoadConfig(ctx.ProjectConfigPath()) - if err != nil { - t.Fatal(err) - } + config := testConfig() config.Insights = &InsightPolicy{ Enabled: true, CaptureMode: "manual", ValueMap: "required", SuggestFeatures: true, EvaluateOnPR: true, PendingFrontier: true, CompletionMode: "human_confirmed", @@ -33,10 +26,14 @@ func configureInsights(t *testing.T, repo string, terminal DeliveryTerminal) Wor if err != nil { t.Fatal(err) } - if err := os.WriteFile(ctx.ProjectConfigPath(), value, 0o600); err != nil { + configPath := filepath.Join(t.TempDir(), "project.json") + if err := os.WriteFile(configPath, value, 0o600); err != nil { t.Fatal(err) } - return ctx + if _, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath}); err != nil { + t.Fatal(err) + } + return WorkspaceFor(repo) } func configureEmbeddedInsights(t *testing.T, repo string, terminal DeliveryTerminal) WorkspaceContext { diff --git a/boatstack/next.go b/boatstack/next.go index ede85a4..2d461dc 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -314,6 +314,11 @@ func ResolveNext(repoPath, explicitFeature string) (result NextStatus, resultErr if err != nil { return NextStatus{}, err } + if _, workspaceErr := ResolveWorkspaceContext(repo); workspaceErr != nil { + status := blockedNextStatus("INVALID_STATE", "attach", workspaceErr.Error()) + status.SupervisionMode = string(SupervisionDetached) + return status, nil + } defer func() { ctx, ok, verifyErr := detachedContextFor(repo) if verifyErr != nil { diff --git a/boatstack/operation.go b/boatstack/operation.go index 7f8f4df..91a3704 100644 --- a/boatstack/operation.go +++ b/boatstack/operation.go @@ -117,7 +117,11 @@ func operationTimestamp() string { // so bumping the version cleanly orphans the legacy clone-shared "v1" ledger for // every worktree — including main — instead of silently inheriting its receipts. func operationDirectory(repo string) (string, error) { - return WorkspaceFor(repo).OperationDir() + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return "", err + } + return ctx.OperationDir() } // pruneLegacyOperationLedger removes the pre-isolation clone-shared "v1" ledger @@ -285,6 +289,9 @@ func PrepareOperation(options OperationPrepareOptions) (OperationReceipt, error) if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } // Retention is best-effort and never prevents a new supervised operation. _ = compactOperations(repo) kind := strings.TrimSpace(options.Kind) @@ -353,6 +360,9 @@ func AuthorizeOperation(repoPath, id, packageFingerprint, authorizationFingerpri if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } var result OperationReceipt err = withOperationLock(repo, id, func() error { receipt, loadErr := loadOperation(repo, id) @@ -390,6 +400,9 @@ func BeginOperation(repoPath, id, attemptKey, tool string) (OperationBeginResult if err != nil { return OperationBeginResult{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationBeginResult{}, err + } attemptKey = strings.TrimSpace(attemptKey) if attemptKey == "" { return OperationBeginResult{}, fmt.Errorf("operation attempt key is required") @@ -465,6 +478,9 @@ func reconcileSucceededInstallUpdate(repoPath, id, detail, evidence string) (Ope if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } var result OperationReceipt err = withOperationLock(repo, id, func() error { receipt, loadErr := loadOperation(repo, id) @@ -496,6 +512,9 @@ func completeOperation(repoPath, id, leaseToken, attemptKey, outcome, detail, ev if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } var result OperationReceipt err = withOperationLock(repo, id, func() error { receipt, loadErr := loadOperation(repo, id) @@ -570,6 +589,9 @@ func RecordOperationReconciliation(repoPath, id, result, detail, evidence string if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } var output OperationReceipt err = withOperationLock(repo, id, func() error { receipt, loadErr := loadOperation(repo, id) @@ -658,6 +680,9 @@ func ResolveOperationStatus(repoPath, id string) (OperationStatusResult, error) if err != nil { return OperationStatusResult{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationStatusResult{}, err + } if strings.TrimSpace(id) != "" { receipt, loadErr := refreshExpiredOperation(repo, strings.TrimSpace(id)) if loadErr != nil { diff --git a/boatstack/paths.go b/boatstack/paths.go index 1551334..9d4cb77 100644 --- a/boatstack/paths.go +++ b/boatstack/paths.go @@ -117,13 +117,11 @@ func (w WorkspaceContext) sharedOwnedPath(target string) (controllerPath, error) } // WorkspaceFor returns the resolver for a repository. It consults the external -// attachment registry and returns a detached context when the repository is -// attached and its binding verifies; otherwise it returns the embedded layout. -// An attached-but-unverifiable repository resolves to embedded here (best effort, -// so deep path callers stay total) — the fail-closed denial with a bounded -// recovery action is raised at the safety and CLI entry points via -// ResolveWorkspaceContext. Results are cached per input path; attach/detach -// invalidate the cache. +// attachment registry and returns a detached context whenever the repository is +// attached. Verification failures do not redirect paths into the repository: +// strict operational entry points deny through ResolveWorkspaceContext, while +// best-effort path projection remains external. Results are cached per input +// path; attach/detach invalidate the cache. func WorkspaceFor(repo string) WorkspaceContext { workspaceCacheMu.Lock() if cached, ok := workspaceCache[repo]; ok { @@ -133,7 +131,7 @@ func WorkspaceFor(repo string) WorkspaceContext { workspaceCacheMu.Unlock() resolved := embeddedWorkspace(repo) - if ctx, ok, err := detachedContextFor(repo); ok && err == nil { + if ctx, ok, _ := detachedContextFor(repo); ok { resolved = ctx } @@ -183,8 +181,8 @@ func ResolveControllerRepository(path string) (string, error) { return "", err } for repo := range registry.Repositories { - ctx, ok, verifyErr := detachedContextFor(repo) - if !ok || verifyErr != nil { + ctx, ok, _ := detachedContextFor(repo) + if !ok { continue } if pathWithin(ctx.ExportRoot(), path) { diff --git a/boatstack/plan.go b/boatstack/plan.go index d2887cd..e99e41d 100644 --- a/boatstack/plan.go +++ b/boatstack/plan.go @@ -1105,15 +1105,19 @@ type ActivationOptions struct { } func ActivatePlan(options ActivationOptions) error { - check, err := CheckPlan(options.PlanPath) + repo, err := ResolveControllerRepository(filepath.Dir(options.PlanPath)) if err != nil { return err } - repo, err := ResolveControllerRepository(filepath.Dir(options.PlanPath)) + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return err + } + check, err := CheckPlan(options.PlanPath) if err != nil { return err } - config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + config, _, err := LoadConfig(ctx.ProjectConfigPath()) if err != nil { return fmt.Errorf("plan activation requires a valid Boatstack project configuration: %w", err) } diff --git a/boatstack/pr.go b/boatstack/pr.go index cce3533..e8278fb 100644 --- a/boatstack/pr.go +++ b/boatstack/pr.go @@ -710,6 +710,10 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { if err != nil { return PRContext{}, err } + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return PRContext{}, err + } if strings.TrimSpace(options.Feature) == "" { active, activeErr := ActiveManagedDeliveries(repo) if activeErr != nil { @@ -723,7 +727,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { if err != nil || head == "" { return PRContext{}, fmt.Errorf("PR preparation requires a named branch") } - configPath := WorkspaceFor(repo).ProjectConfigPath() + configPath := ctx.ProjectConfigPath() config, _, err := LoadConfig(configPath) if err != nil { return PRContext{}, fmt.Errorf("PR preparation requires a valid Boatstack project configuration: %w", err) @@ -1123,6 +1127,9 @@ func CheckPRPreview(repoPath, previewPath string) (PRPreview, PRContext, error) if err != nil { return PRPreview{}, PRContext{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return PRPreview{}, PRContext{}, err + } if !filepath.IsAbs(previewPath) { previewPath = filepath.Join(repo, filepath.FromSlash(previewPath)) } diff --git a/boatstack/recovery.go b/boatstack/recovery.go index 95e5e19..fab3b03 100644 --- a/boatstack/recovery.go +++ b/boatstack/recovery.go @@ -389,6 +389,9 @@ func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) { if err != nil { return RecoveryStatus{}, err } + if _, workspaceErr := ResolveWorkspaceContext(repo); workspaceErr != nil { + return blockedRecovery(workspaceErr.Error()), nil + } if strings.TrimSpace(options.Message) == "" || strings.TrimSpace(options.SourceStage) == "" { return RecoveryStatus{}, fmt.Errorf("recovery status requires the exact message and source stage") } diff --git a/boatstack/run.go b/boatstack/run.go index 5185caf..5d48539 100644 --- a/boatstack/run.go +++ b/boatstack/run.go @@ -107,6 +107,9 @@ func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight { if err != nil { return blockedRunPreflight("", "", "", "INVALID_REPOSITORY", err.Error()) } + if _, workspaceErr := ResolveWorkspaceContext(repo); workspaceErr != nil { + return blockedRunPreflight("", "", "", "DETACHED_CONFIG_DRIFT", workspaceErr.Error()) + } if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return blockedRunPreflight("", "", "", "NOT_INITIALIZED", "This repository has no Boatstack project installation to run.") } diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 9e34096..c754837 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`e3fa496abb8b23f4c2d575929de16755a9cdc2ab`](https://github.com/operatorstack/intelligence-flow/tree/e3fa496abb8b23f4c2d575929de16755a9cdc2ab/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`2b61577db37944e1b1c3aad534eeeb3b32f5eb4d`](https://github.com/operatorstack/intelligence-flow/tree/2b61577db37944e1b1c3aad534eeeb3b32f5eb4d/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/getting-started.md b/docs/getting-started.md index d3f2037..52357f8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -277,12 +277,23 @@ Boatstack files. Attach the repository, then install the developer-level guard once per coding agent: ```bash -boatstack-helper attach --repo . --mode detached +boatstack-helper attach \ + --repo . \ + --mode detached \ + --config /stationkeep/task/project.json boatstack-helper activate --repo . ``` -`attach` inspects the repository and writes the controller state and a binding to the external -control root, leaving the working tree byte-for-byte unchanged. `activate` merges a +The external file uses the normal Boatstack project-config schema. This is useful when the +repository root does not describe the project you want to supervise, such as a package inside +a monorepo. `attach` validates the file, copies its exact bytes into the external control root, +and binds their SHA-256 to detached status and generated provenance. Boatstack never writes the +file into the repository or `.git`. A changed detached copy blocks resume until you restore the +exact bytes or explicitly reattach with `--force --config `. + +Without `--config`, `attach` keeps the existing repository discovery behavior. In either mode it +writes the controller state and binding only to the external control root, leaving the working +tree byte-for-byte unchanged. `activate` merges a developer-level ambient guard into each agent's global configuration; that guard enforces Boatstack only on repositories you have attached and is a no-op everywhere else, and it never removes your own hooks. Use `activate --print` to review the exact per-agent configuration diff --git a/docs/public-claims.json b/docs/public-claims.json index ca1e7de..2b3e00e 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "e3fa496abb8b23f4c2d575929de16755a9cdc2ab", + "source_commit": "2b61577db37944e1b1c3aad534eeeb3b32f5eb4d", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "human-decisions", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "validation-provenance", @@ -34,7 +34,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "irreversible-operations", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:e3fa496abb8b23f4c2d575929de16755a9cdc2ab" + "last_verified_version": "source:2b61577db37944e1b1c3aad534eeeb3b32f5eb4d" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index d85668a..991e020 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "e3fa496abb8b23f4c2d575929de16755a9cdc2ab", + "source_commit": "2b61577db37944e1b1c3aad534eeeb3b32f5eb4d", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-08-05-detached-external-config.md b/release-notes/2026-08-05-detached-external-config.md new file mode 100644 index 0000000..f04a6a1 --- /dev/null +++ b/release-notes/2026-08-05-detached-external-config.md @@ -0,0 +1,3 @@ +### Detached attachment accepts an external project configuration + +Detached Supervision can now validate and copy a normal Boatstack project configuration from an explicit `--config` path. Its exact SHA-256 is bound to detached status and generated provenance, and any later drift blocks resume without writing configuration into the repository or `.git`.