diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d46f870..eb0e3fb 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/bfaa855fddf392520adb0e2324d38aff0421a7fb/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/6f20a566ff2e5cbc5893273627cc019dcff9de16/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 ffecd9b..9defdaa 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 57829, - "estimated_tokens": 14458, + "characters": 59068, + "estimated_tokens": 14767, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "2352ef6339506b5d3b6abaa1c81e35f33410add957cb10a05df8c3aa5f71f1a1", + "CONTRIBUTING.md": "89f4914e5877a355e5f33c98d8386cda0261c6756049c2de623bb7a10ef846f4", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -50,10 +50,10 @@ "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", "boatstack/hooks.go": "b88cedcd045e5217fedfac625ced2e4f691adb42e62155fcbc392a8f6d88366e", "boatstack/hooks_test.go": "c5786bc6463cf6932a6612b26cbe65d035008ecbca5c0063bea253d857c2f622", - "boatstack/init.go": "a32ffdbc148a19aa556e36897d80e7d41ac04a976fef6b27d527b81246bcf32d", + "boatstack/init.go": "302957edf2e663f806d3d5de4486fef5eaf04797270a6f797cfe0170a732f66d", "boatstack/init_test.go": "5fdf687205e7a5984a98a87336b7127e4ae9b651d57e21ec2dc8ca7e653ee602", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", - "boatstack/installation_repair.go": "0c896e7a5033211350eca2b2225bca638b3b0ac4adff550bc514090f7b789f23", + "boatstack/installation_repair.go": "6574f7133a9644843c9260b9b9daede641a14438f7357bae42fb8ec188890446", "boatstack/installation_repair_test.go": "ae5a5ea1110836bd78cf20ade863a4d32cfd63d282559f92786f57b31869bd14", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", "boatstack/migrate.go": "eaf589e2b266238068e42c6d78e01dc040266d28e342cb24f09e33e8541749b3", @@ -74,11 +74,11 @@ "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", "boatstack/references/config-schema.md": "c07c2d532ef95ea6ae538a1fffefb92ded1f8dc6a06eb3b8d463e371d1ed8416", - "boatstack/references/failure-moves.md": "36e4f1487a790d981055ee5554fb1ee829b183dc8ca8c2b93f24e678b7d8a680", + "boatstack/references/failure-moves.md": "34a39aefb282b1b5d9ea387f8536bdc5e0145a240f102ae3d01c7ada6d4abebc", "boatstack/references/host-hook-contracts.md": "d68ae1556e7b1e29e9ac7cb4db767809d510aabf0be52e60e44665ea7abb980e", "boatstack/references/irreversible-operation-boundary.md": "631743991ace65977586e4537f8dd50f8ae88f8e16f27cf7baad93b2791a73df", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "86cc04ff440098ddf9f1a793da9e5dca7208fd24dcc3be4a7742c1d323e4866c", + "boatstack/references/workflow.md": "3fc235d6b87d413796c806236c49452be664406450b7ee400ccf287d9a94b694", "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/run.go": "fbdbf583c862c41f23d1a200f53d042842db72f19c29fe94e4288a69b0ac4a6b", @@ -93,10 +93,10 @@ "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1", - "boatstack/update.go": "edcf524ec103c62e07266aa70c3b7abba665ce1ed04dfa8eb3b26e330100172e", + "boatstack/update.go": "042c7e8141423e2cdb71c92f93cf73cc81d916116d76ab9928c56cf18bc6827a", "boatstack/update_publication.go": "c8b7bd38019b1cbf8c523652e9e20e8c971c7e48b2f3972a0ac638648326fe63", "boatstack/update_publication_test.go": "c5f32578db53be65e35452d5e8b4520884354e4e80a370dcec19860ea644d091", - "boatstack/update_test.go": "4cdd612356978edf718191f794be11590796bd5af1d524381b1a09243636cd51", + "boatstack/update_test.go": "bf5f19f8499db6dd7356917d867b113b790548ab89bfdea59e2adf4999a82a6a", "boatstack/visual_evidence.go": "90a68d554e10ff4fb7afa45000912cdedd4cdf93b3d279055b50844401924f01", "boatstack/visual_evidence_test.go": "0fe8f5154ef4398dfeba5e7f7b387b2d279ed75635ea35d93ff392269f2cc6d0", "boatstack/workspace.go": "91b343400b3506a6f516c28fabc3f1575f22024a5b19f934a020be660a20482e", @@ -105,14 +105,14 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "f530c5dcbacf32dcb6fdab590901d8f658a4f29bd93f6264cec5d4f449c2cbd1", - "docs/evidence-engineered-coding.md": "f722b9e3db8c3f8f9e8e34f8f9ee916ed22966dacc67965fc2acec17bd7fbc99", + "docs/evidence-engineered-coding.md": "e6570313e611a97b30b41c0a53fe5f13e6758e6919e9a612de778d796a7ee2e0", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "d5f0b170209e61518810a23b851bf9eb50b6755906703ca313e6e9faab20e1cd", - "docs/public-claims.json": "7e844ed4a89ebdcf1834def12db0bb8cda83fe2fab511bfa54353304b4127193", + "docs/public-claims.json": "b414e68cad00c21d5de0433a14f9d0ed791cb091fe29afd35aa68f12f204bbd6", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", - "docs/troubleshooting.md": "7e0106ab2b7642f0c7cf641a61c18287bbe8a6b61a89222ce745dabe804240c9", + "docs/troubleshooting.md": "76f8cf2558345bbb873b19977fe27b455ef9f5f3e70e0386951fc462138706fa", "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", "docs/why-these-steps.md": "cbe0d769db11ef15bb1dff888009378d6783776ad020e6f5139847a1dd62fa09", "install.ps1": "6f5857ec0feb502683c5781b9bfbbe31ed39556cd13384ddc66da622a8423cb7", @@ -122,7 +122,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": "b2cb5cdf6ed31f55d748ad5d62cba8d894d48a98857201818e553d9c0928358f", + "labs/diagram-json/plan.lock.json": "268dc0a059c57bec2094f8aa779752b77ad71a989b0cf744b20aad49e1350cce", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -180,12 +180,13 @@ "release-notes/2026-07-22-product-configuration-guide.md": "45e96862336bd53a2628ce3fe718c829ea20315555f53187eb7f04ba749f3a97", "release-notes/2026-07-22-projection-layout-validation.md": "fd0d2935f3f0c4caa41bda678e3bd9a9b496eb2652ab38d80c1c1434cbba3159", "release-notes/2026-07-22-value-translation-boundary.md": "9cf168ff7caaf3906b78533935bdfb2c86e753984ed1e5ec8204cb373d083390", - "release-notes/2026-07-23-bootstrap-safe-update-repair.md": "d8e66c46ae05e3d228dfea45879f4d1166d5e3a253ac24bababd4c3e396c214b" + "release-notes/2026-07-23-bootstrap-safe-update-repair.md": "d8e66c46ae05e3d228dfea45879f4d1166d5e3a253ac24bababd4c3e396c214b", + "release-notes/2026-07-23-canonical-update-ownership.md": "7f34f890b252493797519389b23f1b56ec7ec16db7547aac8ea196f75f3b8c2c" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "bfaa855fddf392520adb0e2324d38aff0421a7fb", + "commit": "6f20a566ff2e5cbc5893273627cc019dcff9de16", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/init.go b/boatstack/init.go index b03c97a..0435ce9 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -302,21 +302,12 @@ func updateChangedPaths(repo string) []string { return sortedKeys(seen) } -func checkUpdateDiffScope(repo string, currentFiles map[string][]byte, previous map[string]string, hookPaths []string) ([]string, error) { - allowed := map[string]bool{".boatstack-project.json": true} - for path := range currentFiles { - allowed[filepath.ToSlash(path)] = true - } - for path := range previous { - allowed[filepath.ToSlash(path)] = true - } - for _, path := range hookPaths { - allowed[filepath.ToSlash(path)] = true - } +func checkUpdateDiffScope(repo string, currentFiles map[string][]byte, previous map[string]string, config ProjectConfig) ([]string, error) { + ownership := newUpdateOwnershipProjection(config, currentFiles, previous) changed := updateChangedPaths(repo) unexpected := []string{} for _, path := range changed { - if !allowed[path] { + if err := ownership.verify(repo, path); err != nil { unexpected = append(unexpected, path) } } @@ -502,6 +493,9 @@ func RunInit(options InitOptions) (returnErr error) { for _, path := range HostHookPaths(config.Adapters) { fmt.Fprintln(options.Output, " "+path+" (merge Boatstack safety hook; preserve existing settings)") } + for _, path := range executionInterceptorPaths(config.Adapters) { + fmt.Fprintln(options.Output, " "+path+" (replace only the marker-bounded Boatstack interceptor)") + } if !configExists { fmt.Fprintln(options.Output, " .boatstack-project.json (editable repository facts)") } @@ -632,7 +626,7 @@ func RunInit(options InitOptions) (returnErr error) { return fmt.Errorf("post-install smoke check failed: %w", err) } if options.Update { - changed, scopeErr := checkUpdateDiffScope(repo, bundle.Files, previousGenerated, HostHookPaths(config.Adapters)) + changed, scopeErr := checkUpdateDiffScope(repo, bundle.Files, previousGenerated, config) if scopeErr != nil { return scopeErr } @@ -669,6 +663,7 @@ func RunInit(options InitOptions) (returnErr error) { } } stagePaths = append(stagePaths, HostHookPaths(config.Adapters)...) + stagePaths = append(stagePaths, executionInterceptorPaths(config.Adapters)...) stageSet := map[string]bool{} for _, path := range stagePaths { stageSet[path] = true @@ -750,19 +745,9 @@ func injectExecutionInterceptor(repo, file string) error { } func InstallExecutionInterceptors(repo string, adapters []string) error { - for _, adapter := range adapters { - if adapter == "gemini" { - if err := injectExecutionInterceptor(repo, "GEMINI.md"); err != nil { - return err - } - } else if adapter == "claude" { - if err := injectExecutionInterceptor(repo, "CLAUDE.md"); err != nil { - return err - } - } else if adapter == "cursor" { - if err := injectExecutionInterceptor(repo, ".cursorrules"); err != nil { - return err - } + for _, path := range executionInterceptorPaths(adapters) { + if err := injectExecutionInterceptor(repo, path); err != nil { + return err } } return nil @@ -794,6 +779,12 @@ func RunUpdate(options InitOptions) error { return err } } + // Validate the complete update workspace before creating a durable attempt. + // Invalid branch or diff state must not consume a retry or leave an identity + // that collides with the later, correctly prepared operation. + if err := ValidateUpdateWorkspaceForRepair(repo, config, preflight, options.Repair); err != nil { + return err + } branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) repairAuthority := fmt.Sprintf("repair=%t\x00allow-downgrade=%t", options.Repair, options.AllowDowngrade) packageFingerprint := SHA256Bytes([]byte(Version + "\x00" + SourceCommit + "\x00" + ChecksumsSHA256 + "\x00" + repairAuthority)) @@ -819,7 +810,7 @@ func RunUpdate(options InitOptions) error { } options.Update = true if err := RunInit(options); err != nil { - _, _ = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "RETRYABLE", "the atomic update transaction rolled back", "") + _, _ = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "RETRYABLE", "the atomic update transaction rolled back: "+err.Error(), "") return err } _, err = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "post-install doctor and generated projections passed", Version) diff --git a/boatstack/installation_repair.go b/boatstack/installation_repair.go index 06c0099..91d1138 100644 --- a/boatstack/installation_repair.go +++ b/boatstack/installation_repair.go @@ -240,7 +240,7 @@ func classifyHookState(repo, host string) []InstallationRepairItem { } func classifyExecutionInterceptor(repo, host string) []InstallationRepairItem { - relative := map[string]string{"cursor": ".cursorrules", "claude": "CLAUDE.md", "gemini": "GEMINI.md"}[host] + relative := executionInterceptorPath(host) if relative == "" { return nil } diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index a304ed3..7586093 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -15,6 +15,7 @@ Select a move only after locating the failure below its surface symptom. “Time | Review miss | Defect found after same-agent review | Independent reviewer; risk checklist; mechanical enforcement | Expensive review everywhere | | Scope drift | Diff no longer maps to approved outcomes | Re-scope; split PR; update spec with approval | Hiding product changes in implementation | | Update self-lockout | An installed helper, stale hook event, or damaged owned receipt blocks its own updater | Let the verified target helper classify state; migrate exact provenance automatically or offer fingerprinted `--repair` | Reinstalling blindly, overwriting user settings, or treating `--repair` as downgrade authority | +| Ownership projection contradiction | Update admission classifies a path as Boatstack-owned, then final validation rejects the controller's own bounded mutation | Build one semantic ownership projection before execution; reuse it for admission, mutation, final verification, staging, and preview | Path-only allowlists accepting user content or independently maintained validators disagreeing after a side effect | | Security/tenancy | Trust boundary or data scope violated | Specialist review; invariant test; deny-by-default guard | Generic prompt mistaken for enforcement | | Integration/deploy | Local pass but runtime fails | Environment parity; canary; health checks; rollback | Treating staging as identical to production | | Documentation drift | Durable behavior and docs disagree | Update source-of-truth artifact; drift check | Growing instructions with unverified rules | @@ -35,6 +36,7 @@ Select a move only after locating the failure below its surface symptom. “Time - **Tool failure must not create recovery authority.** The sanitized database incident moved from a partial schema apply failure to an invented reset path. The irreversible-operation guard is `PROPOSED`, not promoted: evaluate its deny corpus, safe corpus, latency, and workflow regressions against the unguarded baseline. - **Fail-closed controls need an available evaluator.** A linked worktree copied the safety hook but not its ignored helper, so the guard also denied its own repair command. Share only the verified runtime within the Git clone and hydrate local ignored state before judging the original event. - **A retry needs a new observation.** Identical in-flight calls wait. Unknown non-idempotent calls enter `RECONCILE_REQUIRED`; Git, GitHub, filesystem, browser, and MCP boundaries must observe their exact postcondition before another attempt consumes the persistent budget. +- **Preconditions run before leases.** Wrong branch, stale base, or invalid diff state returns a recovery operation without creating a durable attempt. A rejected precondition cannot consume retry budget or leave an identity that collides with the corrected invocation. ## Move proposal schema diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 3bbd13f..68bfe66 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -383,6 +383,8 @@ After successful publication only, the publisher may use the ignored 24-hour rel For an available version, create `chore/update-boatstack-v` and download and checksum-verify the target helper before consulting the installed runtime. The target helper classifies hook fragments, generated locks, helper provenance, and marker-bounded interceptors. Exact installed state migrates automatically. Recoverable owned drift is fingerprinted and, interactively, offered as **Repair Boatstack-owned state and continue the update? [y/N]**; noninteractive updates stop with one `--repair` retry. Repair backs up the exact paths in Git-common state and remains in the same update PR. User-owned, mixed, malformed, symlinked, or product state stays blocked. Downgrades require both `--repair` and `--allow-downgrade`. +Before a durable update attempt is created, Boatstack verifies the dedicated branch, base commit, repair classification, and current diff. Invalid workspace state consumes no retry budget. The update transaction then reuses one semantic ownership projection for admission, mutation, final verification, staging, and preview. Generated files must match their prepared bytes, host-hook files must preserve their non-Boatstack JSON, and `.cursorrules`, `CLAUDE.md`, and `GEMINI.md` must preserve everything outside their single Boatstack marker boundary. + The update transaction is a durable atomic-local operation. It preserves repository configuration, adapters, integrations, and unrelated host settings, then runs `doctor`. After installation, `prepare-update-pr` verifies that every changed path is Boatstack-owned and atomically stores the exact non-empty publication package in Git-common runtime state. Show release and repair provenance, the exact generated diff, checksums, changed paths, integration state, rollout, and rollback. Use **Boatstack update ready** and exactly one action: Reply `o` to open update PR. Only the state-scoped `o` or compatible full reply authorizes `publish-update-pr` with that preview fingerprint. The publisher stages only the approved paths, reuses or creates the exact update commit, pushes normally, and reconciles the head branch before opening at most one PR. The PR body records release provenance, changed generated files, verification, rollout, and revert instructions. If a response is lost after GitHub accepted the request, the next invocation observes and returns the existing PR. If publication is unavailable, retain the prepared branch and provide one manual action. Never merge automatically. diff --git a/boatstack/update.go b/boatstack/update.go index 8a2b76b..89fa6da 100644 --- a/boatstack/update.go +++ b/boatstack/update.go @@ -1,6 +1,7 @@ package boatstack import ( + "bytes" "context" "encoding/json" "fmt" @@ -392,106 +393,204 @@ func updateDirtyPaths(repo string) ([]string, error) { return paths, nil } -func withoutBoatstackHooks(value []byte) ([]byte, error) { +func withoutInterceptor(value []byte) ([]byte, error) { + text := string(value) + start := strings.Index(text, interceptorHeader) + end := strings.Index(text, interceptorFooter) + if start < 0 && end < 0 { + return value, nil + } + if start < 0 || end < start || strings.Count(text, interceptorHeader) != 1 || strings.Count(text, interceptorFooter) != 1 { + return nil, fmt.Errorf("ambiguous interceptor boundary") + } + return []byte(strings.TrimSpace(text[:start] + text[end+len(interceptorFooter):])), nil +} + +type updateOwnedKind string + +const ( + updateOwnedConfig updateOwnedKind = "config" + updateOwnedGenerated updateOwnedKind = "generated" + updateOwnedHook updateOwnedKind = "hook" + updateOwnedInterceptor updateOwnedKind = "interceptor" +) + +type updateOwnershipProjection struct { + kinds map[string]updateOwnedKind + currentFiles map[string][]byte + previous map[string]string + targetKnown bool +} + +func executionInterceptorPath(adapter string) string { + return map[string]string{"cursor": ".cursorrules", "claude": "CLAUDE.md", "gemini": "GEMINI.md"}[adapter] +} + +func executionInterceptorPaths(adapters []string) []string { + paths := map[string]bool{} + for _, adapter := range adapters { + if path := executionInterceptorPath(adapter); path != "" { + paths[path] = true + } + } + return sortedKeys(paths) +} + +func newUpdateOwnershipProjection(config ProjectConfig, currentFiles map[string][]byte, previous map[string]string) updateOwnershipProjection { + projection := updateOwnershipProjection{ + kinds: map[string]updateOwnedKind{ + ".boatstack-project.json": updateOwnedConfig, + ".product-loop/generated.lock.json": updateOwnedGenerated, + }, + currentFiles: currentFiles, previous: previous, targetKnown: currentFiles != nil, + } + for path := range currentFiles { + projection.kinds[filepath.ToSlash(path)] = updateOwnedGenerated + } + for path := range previous { + projection.kinds[filepath.ToSlash(path)] = updateOwnedGenerated + } + for _, path := range HostHookPaths(config.Adapters) { + projection.kinds[filepath.ToSlash(path)] = updateOwnedHook + } + for _, path := range executionInterceptorPaths(config.Adapters) { + projection.kinds[filepath.ToSlash(path)] = updateOwnedInterceptor + } + return projection +} + +func readUpdateVersion(repo, relative string, head bool) ([]byte, bool, error) { + if head { + value, err := exec.Command("git", "-C", repo, "show", "HEAD:"+relative).Output() + if err != nil { + return nil, false, nil + } + return value, true, nil + } + path := filepath.Join(repo, filepath.FromSlash(relative)) + if err := rejectSymlinkComponents(repo, path); err != nil { + return nil, false, err + } + value, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, false, nil + } + return value, err == nil, err +} + +func withoutOwnedHostHook(value []byte, exists bool) ([]byte, error) { + if !exists { + return []byte("{}"), nil + } var config map[string]any if err := json.Unmarshal(value, &config); err != nil { return nil, err } hooks, ok := config["hooks"].(map[string]any) - if !ok { - return json.Marshal(config) - } - for event, raw := range hooks { - entries, ok := raw.([]any) - if !ok { - continue - } - kept := []any{} - for _, entry := range entries { - if !containsBoatstackHook(entry) { - kept = append(kept, entry) + if ok { + for event, raw := range hooks { + entries, entriesOK := raw.([]any) + if !entriesOK { + continue + } + kept := []any{} + for _, entry := range entries { + if !containsBoatstackHook(entry) { + kept = append(kept, entry) + } + } + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept } } - if len(kept) == 0 { - delete(hooks, event) - } else { - hooks[event] = kept + if len(hooks) == 0 { + delete(config, "hooks") } } + // Cursor's root version is generated alongside an otherwise Boatstack-only + // hook document. Retain it whenever user-owned configuration also exists. + if len(config) == 1 && config["version"] != nil { + delete(config, "version") + } return json.Marshal(config) } -func withoutInterceptor(value []byte) ([]byte, error) { - text := string(value) - start := strings.Index(text, interceptorHeader) - end := strings.Index(text, interceptorFooter) - if start < 0 && end < 0 { - return value, nil - } - if start < 0 || end < start || strings.Count(text, interceptorHeader) != 1 || strings.Count(text, interceptorFooter) != 1 { - return nil, fmt.Errorf("ambiguous interceptor boundary") +func withoutOwnedInterceptor(value []byte, exists bool) ([]byte, error) { + if !exists { + return []byte{}, nil } - return []byte(strings.TrimSpace(text[:start] + text[end+len(interceptorFooter):])), nil + return withoutInterceptor(value) } -func dirtyChangeIsOwned(repo, relative string, config ProjectConfig) bool { - base, err := exec.Command("git", "-C", repo, "show", "HEAD:"+relative).Output() +func (projection updateOwnershipProjection) verify(repo, relative string) error { + relative = filepath.ToSlash(relative) + kind, owned := projection.kinds[relative] + if !owned { + return fmt.Errorf("path is outside the prepared Boatstack ownership projection") + } + base, baseExists, err := readUpdateVersion(repo, relative, true) if err != nil { - return false - } - current, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative))) - for _, hookPath := range HostHookPaths(config.Adapters) { - if relative == filepath.ToSlash(hookPath) { - baseProjection, baseErr := withoutBoatstackHooks(base) - if os.IsNotExist(err) && baseErr == nil { - var skeleton map[string]any - if json.Unmarshal(baseProjection, &skeleton) != nil { - return false - } - for key, value := range skeleton { - if key == "version" { - continue - } - if key == "hooks" { - hooks, ok := value.(map[string]any) - if ok && len(hooks) == 0 { - continue - } - } - return false - } - return true + return err + } + current, currentExists, err := readUpdateVersion(repo, relative, false) + if err != nil { + return err + } + switch kind { + case updateOwnedConfig: + if !currentExists { + return fmt.Errorf("project configuration was removed") + } + return nil + case updateOwnedGenerated: + if expected, ok := projection.currentFiles[relative]; projection.targetKnown && ok { + if !currentExists || !bytes.Equal(current, expected) { + return fmt.Errorf("generated path does not match the prepared target") + } + return nil + } + if relative == ".product-loop/generated.lock.json" && !projection.targetKnown { + var lock struct { + Generator string `json:"generator"` + Files map[string]string `json:"files"` } - if err != nil { - return false + if !baseExists || json.Unmarshal(base, &lock) != nil || lock.Generator != Generator || len(lock.Files) == 0 { + return fmt.Errorf("generated lock lacks installed Boatstack provenance") } - currentProjection, currentErr := withoutBoatstackHooks(current) - return baseErr == nil && currentErr == nil && string(baseProjection) == string(currentProjection) + return nil } - } - if relative == ".cursorrules" || relative == "CLAUDE.md" || relative == "GEMINI.md" { - if err != nil { - return false + expected, wasGenerated := projection.previous[relative] + if !wasGenerated || !baseExists || SHA256Bytes(base) != expected { + return fmt.Errorf("generated path lacks matching installed provenance") } - baseProjection, baseErr := withoutInterceptor(base) - currentProjection, currentErr := withoutInterceptor(current) - return baseErr == nil && currentErr == nil && string(baseProjection) == string(currentProjection) - } - if relative == ".product-loop/generated.lock.json" { - var lock struct { - Generator string `json:"generator"` - Files map[string]string `json:"files"` + if projection.targetKnown && currentExists { + return fmt.Errorf("retired generated path was not removed") } - return json.Unmarshal(base, &lock) == nil && lock.Generator == Generator && len(lock.Files) > 0 - } - expected, generated := previousFiles(repo)[relative] - if !generated { - return false - } - if SHA256Bytes(base) != expected { - return false + return nil + case updateOwnedHook: + baseProjection, baseErr := withoutOwnedHostHook(base, baseExists) + currentProjection, currentErr := withoutOwnedHostHook(current, currentExists) + if baseErr != nil || currentErr != nil || !bytes.Equal(baseProjection, currentProjection) { + return fmt.Errorf("non-Boatstack host-hook configuration changed") + } + return nil + case updateOwnedInterceptor: + baseProjection, baseErr := withoutOwnedInterceptor(base, baseExists) + currentProjection, currentErr := withoutOwnedInterceptor(current, currentExists) + if baseErr != nil || currentErr != nil || !bytes.Equal(baseProjection, currentProjection) { + return fmt.Errorf("content outside the Boatstack interceptor markers changed") + } + return nil + default: + return fmt.Errorf("unsupported Boatstack ownership kind") } - return os.IsNotExist(err) || err == nil +} + +func dirtyChangeIsOwned(repo, relative string, config ProjectConfig) bool { + projection := newUpdateOwnershipProjection(config, nil, previousFiles(repo)) + return projection.verify(repo, relative) == nil } func ValidateUpdateWorkspaceForRepair(repo string, config ProjectConfig, repairResult InstallationRepairResult, repair bool) error { diff --git a/boatstack/update_test.go b/boatstack/update_test.go index 38a03d6..c6311d6 100644 --- a/boatstack/update_test.go +++ b/boatstack/update_test.go @@ -311,6 +311,105 @@ func TestUpdateRequiresCleanCurrentDedicatedBranch(t *testing.T) { } } +func TestRunUpdateRejectsInvalidWorkspaceBeforePreparingOperation(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + + err := RunUpdate(InitOptions{Repo: repo, Yes: true, Output: io.Discard}) + if err == nil || !strings.Contains(err.Error(), "chore/update-boatstack-v0.5.0") { + t.Fatalf("update on main did not fail at the branch precondition: %v", err) + } + receipts, receiptErr := operationReceipts(repo) + if receiptErr != nil { + t.Fatal(receiptErr) + } + for _, receipt := range receipts { + if receipt.Kind == "install-update" && receipt.Target == "boatstack-install:v0.5.0" { + t.Fatalf("invalid workspace consumed a durable update attempt: %#v", receipt) + } + } +} + +func replaceInterceptorBody(t *testing.T, repo, relative, body string) { + t.Helper() + path := filepath.Join(repo, relative) + value, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + start := strings.Index(string(value), interceptorHeader) + end := strings.Index(string(value), interceptorFooter) + if start < 0 || end < start { + t.Fatalf("%s has no complete interceptor boundary", relative) + } + updated := string(value[:start]) + interceptorHeader + body + interceptorFooter + string(value[end+len(interceptorFooter):]) + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestUpdateDiffScopeUsesMarkerBoundariesForEveryInterceptor(t *testing.T) { + repo, _ := updateInstalledRepo(t) + config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if err != nil { + t.Fatal(err) + } + config.Adapters = append(config.Adapters, "gemini") + if err := injectExecutionInterceptor(repo, "GEMINI.md"); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "GEMINI.md") + runGit(t, repo, "commit", "-m", "add Gemini interceptor fixture") + + for _, relative := range executionInterceptorPaths(config.Adapters) { + replaceInterceptorBody(t, repo, relative, "old Boatstack boundary") + } + if _, err := checkUpdateDiffScope(repo, map[string][]byte{}, map[string]string{}, config); err != nil { + t.Fatalf("marker-bounded interceptor migration was rejected: %v", err) + } + + claudePath := filepath.Join(repo, "CLAUDE.md") + value, err := os.ReadFile(claudePath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(claudePath, append(value, []byte("user-owned change\n")...), 0o644); err != nil { + t.Fatal(err) + } + if _, err := checkUpdateDiffScope(repo, map[string][]byte{}, map[string]string{}, config); err == nil || !strings.Contains(err.Error(), "CLAUDE.md") { + t.Fatalf("change outside interceptor markers was accepted: %v", err) + } +} + +func TestRunUpdateMigratesStaleInterceptorsWithoutScopeContradiction(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + for _, relative := range []string{".cursorrules", "CLAUDE.md"} { + replaceInterceptorBody(t, repo, relative, "old Boatstack boundary") + } + runGit(t, repo, "add", ".cursorrules", "CLAUDE.md") + runGit(t, repo, "commit", "-m", "record stale interceptors") + runGit(t, repo, "push", "origin", "main") + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + + var output bytes.Buffer + if err := RunUpdate(InitOptions{Repo: repo, Repair: true, Yes: true, Output: &output}); err != nil { + t.Fatalf("stale interceptor update failed: %v\n%s", err, output.String()) + } + for _, relative := range []string{".cursorrules", "CLAUDE.md"} { + value, err := os.ReadFile(filepath.Join(repo, relative)) + if err != nil || !strings.Contains(string(value), strings.TrimSpace(ExecutionBoundaryDX)) || strings.Contains(string(value), "old Boatstack boundary") { + t.Fatalf("%s was not migrated to the target boundary: %v", relative, err) + } + } +} + func TestUpdateDirtyPathsPreserveSpacesAndRejectRenames(t *testing.T) { repo := updateCacheRepo(t) if err := os.WriteFile(filepath.Join(repo, "space name.txt"), []byte("fixture\n"), 0o644); err != nil { diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index feaa8ea..98f3839 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -96,7 +96,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **14458 estimated tokens**, while host adapters point to one operation at a time. +That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **14767 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -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 [`bfaa855fddf392520adb0e2324d38aff0421a7fb`](https://github.com/operatorstack/intelligence-flow/tree/bfaa855fddf392520adb0e2324d38aff0421a7fb/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 [`6f20a566ff2e5cbc5893273627cc019dcff9de16`](https://github.com/operatorstack/intelligence-flow/tree/6f20a566ff2e5cbc5893273627cc019dcff9de16/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index eec6ffa..7aebb89 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "bfaa855fddf392520adb0e2324d38aff0421a7fb", + "source_commit": "6f20a566ff2e5cbc5893273627cc019dcff9de16", "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" }, { "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:bfaa855fddf392520adb0e2324d38aff0421a7fb" + "last_verified_version": "source:6f20a566ff2e5cbc5893273627cc019dcff9de16" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1387212..a46a6af 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -127,6 +127,8 @@ Release discovery uses a short, unauthenticated request to GitHub and a 24-hour Boatstack classifies the named path before writing. Exact installed state migrates automatically. If the path is provably Boatstack-owned but drifted, an interactive update shows the fingerprinted repair and asks whether to continue; a noninteractive update returns one retry using `--repair`. The repair is backed up outside the worktree and included in the same update PR. +Admission and final diff validation use the same ownership projection. Marker-bounded updates to `.cursorrules`, `CLAUDE.md`, and `GEMINI.md` are accepted only when all content outside the Boatstack markers is byte-equivalent. If final validation rejects a path that preflight classified as owned, stop rather than retrying: that is a controller consistency failure and must not consume another attempt. + Do not use `--repair` for user-owned or mixed changes. Move durable project content into `.boatstack-project.json` or repository documentation first. A downgrade additionally requires `--allow-downgrade`; repair authority alone never removes newer behavior. ## The installed helper or hook prevents updating diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index a2fd939..e223a34 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": "bfaa855fddf392520adb0e2324d38aff0421a7fb", + "source_commit": "6f20a566ff2e5cbc5893273627cc019dcff9de16", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-23-canonical-update-ownership.md b/release-notes/2026-07-23-canonical-update-ownership.md new file mode 100644 index 0000000..891a4a2 --- /dev/null +++ b/release-notes/2026-07-23-canonical-update-ownership.md @@ -0,0 +1,5 @@ +### Canonical update ownership supervision + +- Reuse one semantic ownership projection across update admission, mutation verification, staging, and preview, including marker-bounded Cursor, Claude, and Gemini interceptors. +- Validate branch and workspace preconditions before creating a durable operation so rejected setup cannot consume retry budget or collide with the corrected attempt. +- Preserve the underlying rollback reason in the operation receipt and cover the reported stale-interceptor upgrade end to end.