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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Screenshot delivery is now privacy-gated and externally hosted

Boatstack now treats registered visual-surface changes as screenshot candidates, binds capture reuse to the product diff, scenario, and command, accepts optional scenario receipts, and publishes only verified hosted links after human privacy review. Failed uploads or comments preserve the existing pull request for an idempotent retry.
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Before starting `/auto-plan` for a new feature, check `next-status --repo . --js
3. Separate facts, decisions, unknowns, and safely deferrable gaps.
4. Before proposing implementation tasks, inspect the repository and verify any assumptions about API routes, data access, UI components, authentication, server actions, streams, jobs, and external services. Do not guess application architecture.
5. If `workflow.boundary_analysis` is `true` in `project.json`: Evaluate if the requested change is a symptom of a missing systemic boundary (e.g., deficient data normalization, leaky validation, missing authorization edge). If it is, perform a rapid codebase scan for other vulnerabilities sharing this failure mode. Present this as a material product decision, showing concrete codebase evidence of the blast radius. Offer tiered implementation paths: [1a] Symptom Patch (fix only the requested route), or [1b] Programmatic Enforcement (refactor the edge and install a programmatic boundary to mathematically prevent this). If the user chooses programmatic enforcement, explicitly structure the plan into two delivery slices: Slice 1 establishes the programmatic boundary (hook, trigger, or strict test), and Slice 2 implements the feature using that boundary.
- When `workflow.pr_visual_evidence` is `suggest` or `require`, also record a structural `pr_visual_evidence` decision. Use `relevant` with one to three scenarios naming entry, state, viewport, and expected visible outcomes, or `not_relevant` with a reason. Discover repository-owned visual tooling but do not add or require framework-specific tooling.
- When `workflow.pr_visual_evidence` is `suggest` or `require`, also record one structural `pr_visual_evidence` decision reused through test, review, and ship. Changes below `project.visual_surfaces[].paths` are relevant. Use one to three scenarios naming user context, user goal, journey step, reviewer context, entry, state, viewport, surface, and expected visible outcomes, or `not_relevant` with a non-empty reason for review. Discover repository-owned visual tooling but do not add or require framework-specific tooling.
6. Express verified architectural information as typed `architecture_facts`. Each architecture fact must reference evidence IDs produced by Boatstack repository inspection. Do not create or invent evidence IDs. Reading one arbitrary repository file does not ground an unrelated architectural claim.
7. When an architectural question cannot be verified, record it in `architecture_unknowns`. Do not create an implementation task that depends on an unresolved architecture unknown. Create a bounded discovery task instead.
8. Every architecture-sensitive task must reference the facts it depends on through `requires_facts`.
Expand Down Expand Up @@ -190,7 +190,7 @@ A published delivery is immutable. Record the append-only observation without ch
- Derive tests from acceptance criteria and affected contracts, not only from the implementation.
- Run existing relevant tests plus targeted new tests, linters, type checks, builds, and runtime checks.
- When `journey_evidence` is relevant, run every compiled oracle and import typed results with `record-journey-results --feature <feature> --results <json>`. Test and review gates reject missing, failed, manifest-mismatched, head-mismatched, or diff-stale results.
- For relevant PR visual scenarios, use the repository runner first, then a host browser against the existing development server, one supplied launch instruction, or an explicitly approved machine-local runtime. Do not modify repository dependencies or configuration for capture. Review each exact PNG for secrets and private data, then import the temporary manifest with `record-pr-visual-evidence`; keep the images outside the repository.
- For relevant PR visual scenarios, use the repository runner first, then a host browser against the existing development server, one supplied launch instruction, or an explicitly approved machine-local runtime. A harness may write `BOATSTACK_CAPTURE_RECEIPT` with scenario id, reached state or URL, named check results, and overall result; without it the PNG is only `CAPTURED`, never scenario-verified. Do not modify repository dependencies or configuration for capture. A human must review each exact PNG for secrets and private data and record `human-reviewed` before any external upload; keep the images outside the repository.
- Treat model-authored tests and same-model self-review as evidence, not ground truth.
- Validate that tests load and exercise the intended interface. For high-risk code, add an independent oracle such as contract fixtures, mutation testing, differential checks, staging verification, or human acceptance.
- A failing check blocks the gate. A skipped check must include a reason and risk owner. `PASS_WITH_GAPS` is accepted only when `workflow.allow_pass_with_gaps` is true.
Expand All @@ -211,7 +211,7 @@ A published delivery is immutable. Record the append-only observation without ch
- Treat the actual committed diff as what changed, approved artifacts as why it changed, and evidence as the only support for completion claims.
- In the visible Evidence table, link each managed claim to the current repository-relative evidence ledger using a readable link label; do not expose hashes or absolute paths.
- Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. Add UI evidence, security/privacy, migration, or operations sections only when relevant.
- When PR visual evidence is relevant or unresolved, show the exact fingerprinted PNGs and public-repository warning, include the structural Visual evidence table, and treat `o` or `u` as approval of the PR body plus one evidence comment. Prefer a signed-in host browser; after observing the upload, record its PR and comment URLs with `record-pr-visual-publication`. Otherwise surface the exact machine-local paths for manual attachment. `suggest` retains a visible gap, while `require` blocks completed publication. Preserve an opened PR and fix forward from `visual_pending` rather than opening a duplicate.
- When PR visual evidence is relevant or unresolved, show the exact fingerprinted PNGs and external-host privacy warning, include the structural Visual evidence table, and treat `o` or `u` as approval of the PR body plus one evidence comment. Boatstack defaults to Litterbox with a 72-hour expiry, verifies every hosted URL, and puts only hosted Markdown image links in the comment. Never attach image files to the PR or commit an evidence branch. `suggest` retains a visible gap, while `require` blocks completed publication. Preserve an opened PR and retry the same fingerprint and comment from `visual_pending` rather than opening a duplicate.
- Internally generate the normalized context and preview skeleton with `pr-context --repo . --feature <feature>`, write `pr.md`, and validate it with `check-pr --repo . --preview <pr.md>`. Keep these helper names and their fingerprints out of the primary response.
- Inspect the projected changed files, diff stat, high-risk matches, and actual diff before composing the brief. Commit messages are navigation aids, not proof of what changed.
- Show the exact title and rendered body before any GitHub mutation. If no PR exists, render the one next action as: Reply `o` to open PR. If one exists, render: Reply `u` to update PR.
Expand Down
101 changes: 76 additions & 25 deletions labs/12-product-engineering-loop/product-engineering-loop/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ const captureMaxAttempts = 3
// writing exactly one PNG to OutputPath. The contract is surfaced to the harness
// as environment variables (see execCaptureRunner).
type CaptureRequest struct {
Repo string
Capability string
Command string
Scenario PRVisualScenario
OutputPath string
Repo string
Capability string
Command string
Scenario PRVisualScenario
OutputPath string
ReceiptPath string
}

// CaptureRunner runs one scenario's repository capture command. It must produce
Expand All @@ -49,6 +50,7 @@ func (execCaptureRunner) Run(request CaptureRequest) error {
"BOATSTACK_CAPTURE_VIEWPORT="+request.Scenario.Viewport,
"BOATSTACK_CAPTURE_SURFACE="+request.Scenario.Surface,
"BOATSTACK_CAPTURE_OUTPUT="+request.OutputPath,
"BOATSTACK_CAPTURE_RECEIPT="+request.ReceiptPath,
)
// The harness's authoritative output is the PNG on disk, not stdout; only
// stderr is retained, as bounded diagnostics for a failed capture.
Expand Down Expand Up @@ -147,33 +149,53 @@ func CaptureEvidence(options CaptureEvidenceOptions) (PRVisualEvidenceManifest,
items := make([]PRVisualEvidenceItem, 0, len(scenarios))
for _, scenario := range scenarios {
outputPath := filepath.Join(stagingDir, scenario.ID+".png")
if err := captureScenario(repo, capability, commands[scenario.ID], scenario, outputPath, feature, head, headCommit, diffHash, runner); err != nil {
receiptPath := filepath.Join(stagingDir, scenario.ID+".receipt.json")
if err := captureScenario(repo, capability, commands[scenario.ID], scenario, outputPath, receiptPath, feature, head, headCommit, diffHash, runner); err != nil {
return PRVisualEvidenceManifest{}, err
}
verificationStatus := "CAPTURED"
var receipt *PRVisualScenarioReceipt
if parsed, receiptErr := loadVisualScenarioReceipt(receiptPath, scenario.ID); receiptErr == nil {
receipt, verificationStatus = parsed, "SCENARIO_VERIFIED"
} else if !os.IsNotExist(receiptErr) {
return PRVisualEvidenceManifest{}, receiptErr
}
items = append(items, PRVisualEvidenceItem{
ScenarioID: scenario.ID,
Path: outputPath,
MIMEType: "image/png",
Viewport: scenario.Viewport,
CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
Status: "captured",
PrivacyStatus: "clean",
ScenarioID: scenario.ID,
Path: outputPath,
MIMEType: "image/png",
Viewport: scenario.Viewport,
CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
Status: "CAPTURED",
PrivacyStatus: "clean",
VerificationStatus: verificationStatus,
Receipt: receipt,
})
}
scenarioRaw, err := MarshalJSON(scenarios)
if err != nil {
return PRVisualEvidenceManifest{}, err
}
commandRaw, err := MarshalJSON(commands)
if err != nil {
return PRVisualEvidenceManifest{}, err
}

manifest := PRVisualEvidenceManifest{
Key: key,
Key: key,
// The manifest records the configured policy verbatim (informational);
// the effective policy — including plan-escalated require semantics —
// is re-derived by resolvePRVisualEvidence at every decode.
Policy: config.Workflow.PRVisualEvidence,
Relevance: relevance,
RelevanceSource: source,
Status: "PASS",
SourceCommit: headCommit,
ProductDiffSHA256: diffHash,
Scenarios: scenarios,
Items: items,
Policy: config.Workflow.PRVisualEvidence,
Relevance: relevance,
RelevanceSource: source,
Status: "PASS",
SourceCommit: headCommit,
ProductDiffSHA256: diffHash,
ScenarioDefinitionSHA256: SHA256Bytes(scenarioRaw),
CaptureCommandSHA256: SHA256Bytes(commandRaw),
Scenarios: scenarios,
Items: items,
}
saved, err := SavePRVisualEvidence(repo, manifest)
if err != nil {
Expand Down Expand Up @@ -248,9 +270,13 @@ func captureStagingDirectory(repo, key string) (string, error) {
// retry budget. The fingerprint is stable for a given command, scenario, and
// product diff, so a successful capture on the same commit is reused rather than
// re-run.
func captureScenario(repo string, capability Capability, command string, scenario PRVisualScenario, outputPath, feature, head, headCommit, diffHash string, runner CaptureRunner) error {
func captureScenario(repo string, capability Capability, command string, scenario PRVisualScenario, outputPath, receiptPath, feature, head, headCommit, diffHash string, runner CaptureRunner) error {
scenarioRaw, err := MarshalJSON(scenario)
if err != nil {
return err
}
fingerprint := SHA256Bytes([]byte(strings.Join([]string{
command, scenario.ID, scenario.Viewport, scenario.Entry, scenario.State, headCommit, diffHash,
command, string(scenarioRaw), diffHash,
}, "\x00")))
kind := "capture:" + capability.Name
postcondition := fmt.Sprintf("valid PNG captured for scenario %s (%s)", scenario.ID, scenario.Viewport)
Expand Down Expand Up @@ -288,8 +314,13 @@ func captureScenario(repo string, capability Capability, command string, scenari
}
return fmt.Errorf("capture of %s reports success but its artifact is missing", scenario.ID)
}
// A new package fingerprint must not inherit an optional receipt left by
// an older harness run. PNG-only remains CAPTURED.
if err := os.Remove(receiptPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("clear stale receipt for %s: %w", scenario.ID, err)
}
runErr := runner.Run(CaptureRequest{
Repo: repo, Capability: capability.Name, Command: command, Scenario: scenario, OutputPath: outputPath,
Repo: repo, Capability: capability.Name, Command: command, Scenario: scenario, OutputPath: outputPath, ReceiptPath: receiptPath,
})
if runErr == nil {
runErr = verifyCapturedPNG(outputPath)
Expand All @@ -312,6 +343,26 @@ func captureScenario(repo string, capability Capability, command string, scenari
return fmt.Errorf("capture of scenario %s failed after %d attempts: %w", scenario.ID, captureMaxAttempts, lastErr)
}

func loadVisualScenarioReceipt(path, scenarioID string) (*PRVisualScenarioReceipt, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var receipt PRVisualScenarioReceipt
if err := DecodeJSON("visual scenario receipt", path, raw, &receipt); err != nil {
return nil, err
}
if receipt.ScenarioID != scenarioID || strings.TrimSpace(receipt.Reached) == "" || len(receipt.Checks) == 0 || !strings.EqualFold(receipt.OverallResult, "PASS") {
return nil, fmt.Errorf("visual scenario receipt for %s is invalid or failing", scenarioID)
}
for _, check := range receipt.Checks {
if strings.TrimSpace(check.Name) == "" || !strings.EqualFold(check.Result, "PASS") {
return nil, fmt.Errorf("visual scenario receipt for %s contains an unnamed or failing check", scenarioID)
}
}
return &receipt, nil
}

func verifyCapturedPNG(path string) error {
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,49 @@ func TestCaptureEvidenceProducesManifestTrustedByPRContext(t *testing.T) {
}
}

// control-law: scenario-verification-requires-a-current-receipt
func TestCaptureEvidenceDoesNotReuseStaleOptionalReceipt(t *testing.T) {
repo := captureTestRepo(t, "reviewer-ready")
withReceipt := true
runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
writeTestPNG(t, request.OutputPath)
if withReceipt {
return os.WriteFile(request.ReceiptPath, []byte(`{"scenario_id":"warning","reached_state_or_url":"/onboarding","checks":[{"name":"warning visible","result":"PASS"}],"overall_result":"PASS"}`), 0o600)
}
return nil
}}
first, err := CaptureEvidence(CaptureEvidenceOptions{Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner})
if err != nil {
t.Fatal(err)
}
if first.Items[0].VerificationStatus != "SCENARIO_VERIFIED" {
t.Fatalf("valid receipt was not verified: %#v", first.Items[0])
}

config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
if err != nil {
t.Fatal(err)
}
config.Project.Commands["visual"] = "changed-capture-command"
raw, err := MarshalJSON(config)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", ".product-loop/project.json")
runGit(t, repo, "commit", "-m", "change capture command")
withReceipt = false
second, err := CaptureEvidence(CaptureEvidenceOptions{Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner})
if err != nil {
t.Fatal(err)
}
if second.Items[0].VerificationStatus != "CAPTURED" || second.Items[0].Receipt != nil {
t.Fatalf("PNG-only rerun inherited a stale receipt: %#v", second.Items[0])
}
}

func TestCaptureEvidenceFailsClosedOnNonConformantOutput(t *testing.T) {
repo := captureTestRepo(t, "reviewer-ready")
runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
Expand Down
Loading
Loading