Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/2126fde051cc11a8c3de9fff4fc17b5397240383/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/8129a03686d1f9a997ae31326ede835899a74382/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.

Expand Down
16 changes: 9 additions & 7 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"files": {
".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957",
"CONTRIBUTING.md": "7338b0102e28024ac659eebe01be35bbcdf31aecb92e8c8dea15e5aa3abcf317",
"CONTRIBUTING.md": "7470a53aa869b8e3e5db7da216873955c32084176403337fc3f62256b0fbb44a",
"README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad",
"assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63",
"assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5",
Expand Down Expand Up @@ -134,6 +134,7 @@
"boatstack/plan_validation.go": "99e40806fd579ff72de53f391cd6124acc9ff18707ecf9676c5e507b738d87d0",
"boatstack/plan_validation_test.go": "6cbde4ac719baef6b73aa569515d6a9daadcbf14b33f76fa78159826954e20fa",
"boatstack/planning.go": "7e5def2fa4edff78f9164a9f117cff65dd8280b3173cdf01d4429b21a3407bb0",
"boatstack/planning_first_write_conformance_test.go": "873097aa9384b75bf01e74a475f3ec2ac7cca4a28f733e82f2c82959032c6a30",
"boatstack/planning_test.go": "06ec7022222d926040c3ae28b84ab50c3d2f804ae6473e61b303804dd992d884",
"boatstack/pr.go": "b6df3e000dd6d34ecb6575385e44b2f6ee84a8f35ad5696e299177a7cddd7629",
"boatstack/pr_test.go": "2e7709e2f163489a29ea3e7eb4bc932cfe6829b30d168f1aea9e942acbc3b4e7",
Expand Down Expand Up @@ -163,8 +164,8 @@
"boatstack/runtime_cache.go": "e026ffc1906f7e1e98b768bae63e6658164d2826c07169c9121ce0f23c73faf8",
"boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85",
"boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc",
"boatstack/safety.go": "e21cc46048e51ed973096a4c4f1c32908aff79cfba8f2f75a301c5f6521656c8",
"boatstack/safety_corpus_test.go": "6f39e7b286fef2cd948fdacaeffa07ba906a56b9f97cdffb6666963a76729a18",
"boatstack/safety.go": "ae69a5ab0609767d69c7ca27e6093c77c6576a0e4860bfe5090f50daec1485f8",
"boatstack/safety_corpus_test.go": "bf8d8a4993c9598cecf371e53c245f88cad0ed29841a6b312262cf2db4caf43b",
"boatstack/safety_test.go": "500ad53cd5e3a700553eb781d9eaf4028ae27478796759a76bfd57321fff5a7c",
"boatstack/safety_update_publisher_test.go": "ed3f8187036623694dfe7c395cdae00fdae14609bab6124d1fdfc6fe73fa2196",
"boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e",
Expand All @@ -191,10 +192,10 @@
"docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6",
"docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79",
"docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0",
"docs/evidence-engineered-coding.md": "e5c48eb66d9ac2967aa41b8ba972f0d85d7fb95cdaa2fe1019f45bd544d73eb6",
"docs/evidence-engineered-coding.md": "4a34055e046930643283281a6364c8e3a976ad56036909bb372b341daef9329d",
"docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3",
"docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8",
"docs/public-claims.json": "b723b764dcb7ca02f339a36e837a6fcf7d9b59ab881878e0ea1aab9c3a101070",
"docs/public-claims.json": "9c0c75c2ecb4828ef8ad9be21b46e114adb8ce9e251c8f6ec544af3aec71ad13",
"docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907",
"docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6",
"docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6",
Expand All @@ -208,7 +209,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": "4c31e6695c8cc726d795e8dd39d8f7ba84d208a615786d674ef445e91e60b52b",
"labs/diagram-json/plan.lock.json": "95b94e46b1dd097b11f6aac765ecf5d1ca2d525e375a662971c7a16182cd3f4d",
"labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51",
"labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d",
"labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed",
Expand Down Expand Up @@ -325,6 +326,7 @@
"release-notes/2026-07-27-constitutional-boundary-floor.md": "41514cbea53867c264e354f149638444ab0d80f64896b9cf720f3979ae78b3de",
"release-notes/2026-07-27-coreachable-recovery.md": "6ffc6b0e9a7d46c0f99a64112813c33d19571c73d02e98ac5573924f1663fd54",
"release-notes/2026-07-27-discoverable-planning-errors.md": "d8099d1a6cd1805c3fcd446d9fa95739dec93e57ff421ecf4f99562b143c9966",
"release-notes/2026-07-27-first-planning-write-owned-channel.md": "7a37e7abf7fd5f8612aca4323d55af1748c9e668bb294518619a1c39e195309f",
"release-notes/2026-07-27-guard-dual-reward-corpus.md": "6bec0385c6c553f00517259821e502796ca1b1907aeab718a287560e3e0fa0d6",
"release-notes/2026-07-27-invalid-delivery-block-actionable.md": "8fac8e3921e2285291703efa46e624b72cb5bac1b8492beca4c4b633abb5ba16",
"release-notes/2026-07-27-prescriptive-planning-closure.md": "e544408e1c3cceb0cb1979833ea120853c38020933439b39e7f009f454b9661e",
Expand All @@ -334,7 +336,7 @@
"generator": "operatorstack/intelligence-flow:boatstack-distribution",
"schema_version": 1,
"source": {
"commit": "2126fde051cc11a8c3de9fff4fc17b5397240383",
"commit": "8129a03686d1f9a997ae31326ede835899a74382",
"path": "labs/12-product-engineering-loop",
"repository": "operatorstack/intelligence-flow"
}
Expand Down
181 changes: 181 additions & 0 deletions boatstack/planning_first_write_conformance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package boatstack

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

// control-law: first-planning-write-uses-the-owned-channel
//
// No host-raw byte ever lands in .product-loop/features/ — before the first
// plan candidate exists or after. Previously the guard latched only once a
// (possibly malformed) draft registered, so the very first raw Write/cp of a
// planning artifact was allowed and the agent discovered planning-write only
// by failing into INVALID_STATE and a quarantine loop. The invariant these
// tests hold: the managed planning tree is authored exclusively through the
// owned channel at every stage; the deny is path-scoped (ordinary product
// writes stay unlatched at zero candidates); and the denial names the verb
// the guard itself admits at that stage (Coreachability).

// Positive: the owned channel clears the cause the denial reports — the
// helper verbs pass the guard at zero candidates, and planning-write actually
// creates the artifact.
func TestFirstPlanningWriteOwnedChannelStaysOpen(t *testing.T) {
repo := safetyTestRepo(t)

for _, command := range []string{
"boatstack-helper planning-write --repo . --feature checkout --artifact plan.md",
"boatstack-helper check-source-plan --repo . --plan docs/plan.md",
} {
if findings := ClassifyCommand(repo, command); len(findings) > 0 {
t.Fatalf("owned channel denied at zero candidates: %q -> %#v", command, findings)
}
}

written, err := WritePlanningArtifact(PlanningWriteOptions{
Repo: repo, Feature: "checkout", Artifact: "source-plan.md",
Content: []byte("# Source plan\n"),
})
if err != nil {
t.Fatalf("planning-write must author the first artifact: %v", err)
}
if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(written))); err != nil {
t.Fatalf("owned write did not land: %v", err)
}
}

// Negative: the first raw write is denied with the exact finding — category,
// source, stage, prescribed verb, and the slug parsed from the path — at any
// depth and for any name under the planning tree.
func TestFirstRawPlanningWriteIsDenied(t *testing.T) {
repo := safetyTestRepo(t)

finding := func(path string) SafetyFinding {
findings := ClassifyTool(repo, "Write", map[string]any{
"file_path": filepath.Join(repo, filepath.FromSlash(path)),
"content": "# Draft\n",
})
if len(findings) == 0 {
t.Fatalf("raw write of %q must be denied at zero candidates", path)
}
return findings[0]
}

got := finding(".product-loop/features/checkout/plan.md")
if got.Category != "workflow-phase-bypass" || got.Source != "planning-state" {
t.Fatalf("wrong finding identity: %#v", got)
}
if got.WorkflowStage != "NOT_STARTED" || got.NextOperation != "planning-write" {
t.Fatalf("finding must carry the real stage and the owned verb: %#v", got)
}
if got.BlockingFeature != "checkout" {
t.Fatalf("slug must be parsed from the path for a copy-pasteable denial: %#v", got)
}

// Non-allowlisted names and nested depths are still inside the latch.
for _, path := range []string{
".product-loop/features/checkout/notes/scratch.md",
".product-loop/features/checkout/random.txt",
} {
if got := finding(path); got.NextOperation != "planning-write" {
t.Fatalf("latch must cover %q: %#v", path, got)
}
}
}

// Relation: the same law reaches both guard entry paths — a Write tool and a
// raw shell write yield the same finding, and the rendered denial names the
// owned channel in full.
func TestFirstWriteLatchCoversToolAndCommandPaths(t *testing.T) {
repo := safetyTestRepo(t)

toolFindings := ClassifyTool(repo, "Write", map[string]any{
"file_path": filepath.Join(repo, ".product-loop", "features", "checkout", "plan.md"),
"content": "# Draft\n",
})
commandFindings := ClassifyCommand(repo, "cp draft.md .product-loop/features/checkout/plan.md")
if len(toolFindings) == 0 || len(commandFindings) == 0 {
t.Fatalf("both entry paths must deny: tool=%#v command=%#v", toolFindings, commandFindings)
}
for _, pair := range []struct {
label string
finding SafetyFinding
}{{"tool", toolFindings[0]}, {"command", commandFindings[0]}} {
if pair.finding.Category != "workflow-phase-bypass" || pair.finding.NextOperation != "planning-write" || pair.finding.WorkflowStage != "NOT_STARTED" {
t.Fatalf("%s path finding drifted: %#v", pair.label, pair.finding)
}
}

rendered := denialFor("claude", toolFindings[0]).Render(RenderPlain)
if !strings.Contains(rendered, "planning-write --repo . --feature checkout --artifact <name>") {
t.Fatalf("denial must name the owned channel: %q", rendered)
}
if !strings.Contains(rendered, "NOT_STARTED") {
t.Fatalf("denial must name the real stage: %q", rendered)
}
}

// Bypass: shell write idioms and mutation-capable MCP tools cannot slip the
// latch, while product writes outside the planning tree stay unlatched — the
// deny is path-scoped, never a blanket zero-candidate interlock.
func TestFirstWriteLatchBypassAndScope(t *testing.T) {
repo := safetyTestRepo(t)

for _, command := range []string{
`tee .product-loop/features/checkout/plan.md < draft.md`,
`printf '# Plan' > .product-loop/features/checkout/plan.md`,
`mv draft.md .product-loop/features/checkout/source-plan.md`,
`mkdir -p .product-loop/features/checkout && cp draft.md .product-loop/features/checkout/plan.md`,
} {
if findings := ClassifyCommand(repo, command); len(findings) == 0 {
t.Fatalf("shell write bypass allowed: %q", command)
}
}

if findings := ClassifyTool(repo, "mcp__files__update", map[string]any{
"path": ".product-loop/features/checkout/plan.md", "content": "# Draft\n",
}); len(findings) == 0 {
t.Fatal("mutation-capable MCP tool must not bypass the latch")
}

// Scope: ordinary product writes remain free at zero candidates.
for _, path := range []string{"src/app.ts", "product.go"} {
if findings := ClassifyTool(repo, "Write", map[string]any{
"file_path": filepath.Join(repo, path), "content": "package main\n",
}); len(findings) > 0 {
t.Fatalf("path-scoped latch leaked onto product write %q: %#v", path, findings)
}
}
}

// Failure-state: a denial is decided before any effect — the working tree is
// byte-identical afterward and the planning tree still does not exist.
func TestFirstWriteDenialLeavesTreeUntouched(t *testing.T) {
repo := safetyTestRepo(t)
before, err := exec.Command("git", "-C", repo, "status", "--short").CombinedOutput()
if err != nil {
t.Fatal(err)
}

findings := ClassifyTool(repo, "Write", map[string]any{
"file_path": filepath.Join(repo, ".product-loop", "features", "checkout", "plan.md"),
"content": "# Draft\n",
})
if len(findings) == 0 {
t.Fatal("expected denial")
}

after, err := exec.Command("git", "-C", repo, "status", "--short").CombinedOutput()
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatalf("classification mutated the tree: before=%q after=%q", before, after)
}
if _, err := os.Stat(filepath.Join(repo, ".product-loop", "features")); !os.IsNotExist(err) {
t.Fatalf("planning tree must not exist after a denial: %v", err)
}
}
60 changes: 58 additions & 2 deletions boatstack/safety.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ var mutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|
var planningMutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|create)`)
var externalReadOnlyToolPattern = regexp.MustCompile(`(?i)(?:^|[_-])(?:get|list|read|search|find|status|inspect|query|fetch|open)(?:[_-]|$)`)

// featuresCommandPathPattern extracts a .product-loop/features/… operand from a
// shell command so the first-write latch can see raw shell writes (cp, tee, >)
// the same way ClassifyTool sees a Write tool's file_path. Mirrors the
// deliveryStatePathPattern law for .git/boatstack: only owned channels may name
// managed planning paths in a mutating command.
// control-law: first-planning-write-uses-the-owned-channel
var featuresCommandPathPattern = regexp.MustCompile(`(?i)(?:^|[\s"'=(])((?:\./)?\.product-loop[/\\]features[/\\][^\s"';&|)]+)`)

func controlledPhaseTransition(command, stage string) bool {
if strings.ContainsAny(command, "\n`><;&|") || strings.Contains(command, "$(") {
return false
Expand Down Expand Up @@ -200,6 +208,12 @@ func controlledPhaseTransition(command, stage string) bool {
return fields[1] == "planning-write" || fields[1] == "record-approval"
case "APPROVED", "POLICY_READY":
return fields[1] == "activate-plan" || fields[1] == "workspace-cut"
case "NOT_STARTED":
// The first-write latch denies raw writes into .product-loop/features/
// before any candidate exists and prescribes planning-write; Coreachability
// requires the guard to admit that verb at the very stage that names it.
// record-approval is NOT admitted here — there is no plan to approve yet.
return fields[1] == "planning-write"
default:
return false
}
Expand Down Expand Up @@ -303,8 +317,29 @@ func attemptedRepositoryPath(repo string, input any) string {
return visit(input)
}

// featureScopedPath reports whether a repo-relative path lands anywhere under
// the managed planning tree. Broader than planningMarkdownPath on purpose: the
// first-write latch covers every depth and name, while planningMarkdownPath
// stays the exact allowlist for the bounded DRAFT_PLAN carve-out.
func featureScopedPath(path string) bool {
return strings.HasPrefix(filepath.ToSlash(path), ".product-loop/features/")
}

// featuresPathInCommand extracts the first .product-loop/features/… operand a
// shell command names, normalized to a slash-form repo-relative path, or ""
// when none is named. It is the ClassifyCommand analogue of a Write tool's
// extracted file_path, feeding the same first-write latch.
func featuresPathInCommand(command string) string {
match := featuresCommandPathPattern.FindStringSubmatch(command)
if match == nil {
return ""
}
path := filepath.ToSlash(match[1])
return strings.TrimPrefix(path, "./")
}

func planningMarkdownPath(path string) bool {
if !strings.HasPrefix(path, ".product-loop/features/") {
if !featureScopedPath(path) {
return false
}
parts := strings.Split(filepath.ToSlash(path), "/")
Expand Down Expand Up @@ -347,6 +382,25 @@ func preActivationFinding(repo, attemptedPath string) (SafetyFinding, bool) {
return SafetyFinding{Category: "workflow-observation-fault", Reason: "saved feature plans cannot be verified; diagnose the channel with doctor", Source: "planning-state", NextOperation: "doctor"}, true
}
if len(candidates) == 0 {
// First-write latch: even before any plan candidate exists, the managed
// planning tree is authored only through the owned channel. Without this,
// the very first raw host write of plan.md registers a malformed draft and
// the agent discovers planning-write only by failing into INVALID_STATE.
// The deny is path-scoped — ordinary product writes stay unlatched at zero
// candidates. Stage NOT_STARTED is what ResolveNext reports here, and
// controlledPhaseTransition admits planning-write at that stage, so the
// denial names a verb the guard accepts (Coreachability).
// control-law: first-planning-write-uses-the-owned-channel
if featureScopedPath(attemptedPath) {
finding := SafetyFinding{
Category: "workflow-phase-bypass", Reason: "planning Markdown is created through the owned channel; a raw first write into .product-loop/features/ is denied", Source: "planning-state",
WorkflowStage: "NOT_STARTED", AttemptedPath: attemptedPath, NextOperation: "planning-write",
}
if parts := strings.Split(filepath.ToSlash(attemptedPath), "/"); len(parts) > 2 && featureSlugPattern.MatchString(parts[2]) {
finding.BlockingFeature = parts[2]
}
return finding, true
}
return SafetyFinding{}, false
}
status, err := ResolveNext(repo, "")
Expand Down Expand Up @@ -710,7 +764,9 @@ func ClassifyCommand(repo, command string) []SafetyFinding {
return dedupeFindings(findings)
}
if !isPureReadOnlyCommand(command) {
if finding, blocked := preActivationFinding(repo, ""); blocked && !controlledPhaseTransition(command, finding.WorkflowStage) && !controlledWorkspaceSync(repo, command) {
// Feed any named .product-loop/features/ operand so the first-write latch
// sees raw shell writes (cp/tee/redirect) the same way it sees a Write tool.
if finding, blocked := preActivationFinding(repo, featuresPathInCommand(command)); blocked && !controlledPhaseTransition(command, finding.WorkflowStage) && !controlledWorkspaceSync(repo, command) {
return []SafetyFinding{finding}
}
}
Expand Down
Loading
Loading