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
Expand Up @@ -52,7 +52,7 @@ failed, or stale results.
| Permit visible verification gaps | `workflow.allow_pass_with_gaps` | `false` rejects `PASS_WITH_GAPS` at delivery and PR gates; `true` retains the gaps as evidence. |
| Maintain reader-facing history | `workflow.maintain_changelog` | Managed delivery and Boatstack-prepared PRs require a categorized `CHANGELOG.md` entry. |
| Check for a systemic boundary | `workflow.boundary_analysis` | Planning guidance asks whether the request is a local symptom before scope expands. |
| Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. |
| Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. A plan that approves visual scenarios lifts `suggest` to require semantics for that feature; `off` and a per-feature `not_relevant` decision (with a reason) are the escapes. Boatstack captures registered scenarios automatically during ship. |
| Render screenshots inline on a private PR | `workflow.visual_evidence_publish.*` | `mode: external-host` uploads the captured PNGs to an anonymous expiring host so the comment renders inline even on a private repo; opt-in, never automatic. |
| Ignore old ambiguous deliveries | `workflow.ignored_deliveries` | Listed feature slugs are excluded from delivery-ambiguity resolution so past work stops blocking new work; new, unlisted ambiguous deliveries still pause. |
| Pursue the PR to merge, not just to open | `delivery.terminal` | `merged` keeps the read-only flow advisors naming post-publish steps (watch checks, route corrections) until the PR is observed merged; the default `published` ends the flow when the PR is open, exactly as before. |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Plan-approved visual scenarios now ship with require semantics

When the approved plan declares `pr_visual_evidence` relevance `relevant` with scenarios, the configured `suggest` policy escalates to require semantics for that feature: publication blocks until current PASS evidence exists, even when no capture capability is registered yet. A plan that promises pixels can no longer ship with a `NOT_VERIFIED` gap. The escapes stay explicit: `off` globally, or a per-feature `not_relevant` decision with a reason for genuinely nonvisual changes. Because Boatstack now captures registered scenarios automatically during ship, provisioned repositories will not notice the escalation. One-time effect after upgrading: an existing `suggest` preview for a feature with declared scenarios reports a changed context fingerprint — regenerate the preview with `pr-context` before publishing.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### The flow now prescribes the owed visual-evidence attachment

A published PR whose Boatstack evidence comment failed to attach (`visual_pending`) or needs manual attachment (`manual_required`) no longer goes dark in `flow next`. The new `attach-evidence --repo --feature` verb retries exactly the operator-confirmed evidence package against the recorded PR — publication authority is never re-asked, and an already attached comment is a no-op. `flow next` prescribes the retry as the agent's step for a transient publisher failure, and prescribes `record-pr-visual-publication` (owing the observed comment URL) as the operator's step when no automatic publisher is available. Both fire under the `published` and `merged` terminals, because attaching evidence completes the publication itself.
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ func CaptureEvidence(options CaptureEvidenceOptions) (PRVisualEvidenceManifest,

manifest := PRVisualEvidenceManifest{
Key: key,
Policy: config.Workflow.PRVisualEvidence,
// 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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func TestCaptureEvidenceProducesManifestTrustedByPRContext(t *testing.T) {
if err != nil {
t.Fatal(err)
}
_, status, count, _, _, _, resolved, err := resolvePRVisualEvidence(repo, config, "managed", "reviewer-ready", head, diffHash)
_, status, count, _, _, _, _, resolved, err := resolvePRVisualEvidence(repo, config, "managed", "reviewer-ready", head, diffHash)
if err != nil {
t.Fatal(err)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ var nonDeliveryVerbs = map[string]bool{
"provision-capability": true,
"capability-register": true,
"record-pr-visual-publication": true,
"attach-evidence": true,
// PR construction / verification helpers reached around the ship gate.
"check-pr": true,
// Detached Supervision lifecycle (control-plane ownership, not delivery moves).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,28 @@ func recordPRVisualPublicationCommand(arguments []string) int {
return 0
}

func attachEvidenceCommand(arguments []string) int {
flags := flag.NewFlagSet("attach-evidence", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose Git-common state owns the evidence")
feature := flags.String("feature", "", "managed Boatstack feature slug")
if err := flags.Parse(arguments); err != nil {
return 2
}
if *feature == "" {
return fail(fmt.Errorf("attach-evidence requires --feature"))
}
manifest, err := boatstack.RetryVisualAttachment(*repo, *feature, boatstack.SelectVisualPublisher(*repo))
if err != nil {
return fail(err)
}
value, err := boatstack.MarshalJSON(manifest)
if err != nil {
return fail(err)
}
fmt.Print(string(value))
return 0
}

func deliveryStatusCommand(arguments []string) int {
flags := flag.NewFlagSet("delivery-status", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository containing the managed delivery")
Expand Down Expand Up @@ -1487,7 +1509,7 @@ func workspaceSyncCommand(arguments []string) int {

func run() int {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <attach|detach|detached-status|context|activate|deactivate|init|update|check-update|repair-status|operation-status|prepare-update-pr|publish-update-pr|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|next-status|recovery-status|repair-state|mutation-status|undo|run-preflight|record-change|record-journey-results|ignore-delivery|record-delivery-gate|record-pr-visual-evidence|capture-evidence|provision-capability|capability-register|record-pr-visual-publication|check-safety|migrate-config|safety-hook|ambient-safety-hook|diagnose-hook|render-denial|pr-context|check-pr|publish-pr|workspace-cut|workspace-cleanup|workspace-reap|workspace-status|workspace-sync|flow|retro|doctor|version>")
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <attach|detach|detached-status|context|activate|deactivate|init|update|check-update|repair-status|operation-status|prepare-update-pr|publish-update-pr|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|next-status|recovery-status|repair-state|mutation-status|undo|run-preflight|record-change|record-journey-results|ignore-delivery|record-delivery-gate|record-pr-visual-evidence|capture-evidence|provision-capability|capability-register|record-pr-visual-publication|attach-evidence|check-safety|migrate-config|safety-hook|ambient-safety-hook|diagnose-hook|render-denial|pr-context|check-pr|publish-pr|workspace-cut|workspace-cleanup|workspace-reap|workspace-status|workspace-sync|flow|retro|doctor|version>")
return 2
}
switch os.Args[1] {
Expand Down Expand Up @@ -1567,6 +1589,8 @@ func run() int {
return capabilityRegisterCommand(os.Args[2:])
case "record-pr-visual-publication":
return recordPRVisualPublicationCommand(os.Args[2:])
case "attach-evidence":
return attachEvidenceCommand(os.Args[2:])
case "pr-context":
return prContextCommand(os.Args[2:])
case "check-pr":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,9 @@ func denialFor(host string, finding SafetyFinding) Denial {
}
d.Qualifier = "visual evidence is owed"
d.Detail = "PR publication is blocked until required visual evidence is current for " + target + "."
if finding.PolicySource == "plan-escalated" {
d.Detail += " The approved plan declares visual scenarios, so the configured suggest policy ships with require semantics for this feature."
}
if reason := strings.TrimSpace(finding.Reason); reason != "" {
d.Detail += " Automatic capture reported: " + reason + "."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func TestTamperDenialNamesDeclaredOwnerVerbs(t *testing.T) {
".git/boatstack/mutations/v1/abc.json": {"activate-plan", "undo"},
".git/boatstack/quarantine/demo/receipt.json": {"repair-state"},
"state-root/boatstack/registry.json": {"attach", "detach"},
".git/boatstack/visual-evidence/x/manifest.json": {"record-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication"},
".git/boatstack/visual-evidence/x/manifest.json": {"record-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication", "attach-evidence"},
"boatstack/repositories/sample/binding.json": {"attach", "detach", "activate"},
}
for attempted, want := range cases {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ const (
// control-law: merged-terminal-prescribes-merge-never-executes-it
MarkerPublishedWatch = deliverycontrol.TransitionID("published.watch_checks")
MarkerPublishedMerge = deliverycontrol.TransitionID("published.merge")
// MarkerPublishedAttach names the owed-attachment retry of a published
// PR's visual-evidence comment. Unlike the merged-terminal markers above
// it fires under BOTH terminals: attaching evidence completes the
// publication itself, it is not merge pursuit.
MarkerPublishedAttach = deliverycontrol.TransitionID("published.attach_evidence")
)

// NextActor names who performs the prescribed next step. The operator owns a
Expand Down Expand Up @@ -157,6 +162,15 @@ func classifyNextActor(status NextStatus, next FlowNext) NextActor {
status.ObservedStage == "PUBLISHED" && status.Lifecycle == "PUBLISHED_MERGED":
return NextActorNone
case status.ObservedStage == "PUBLISHED":
// An owed visual attachment splits by what it owes: a transient
// publisher failure (visual_pending) is work-derivable — the agent
// retries attach-evidence; manual_required owes operator authority (a
// signed-in browser or an external-host opt-in) and stays theirs. A
// fired goal escape still demotes unconditionally.
// control-law: turn-ends-only-at-the-operator-frontier
if status.Lifecycle == "PUBLISHED_OPEN" && status.GoalEscape == "" && status.VisualPublication == "visual_pending" {
return NextActorAgent
}
// Under the default published terminal, reviewing the open pull
// request is the operator's act — unchanged. Under the merged
// terminal, the frontier extends: the phases whose next step is
Expand Down Expand Up @@ -438,6 +452,45 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri
}
}

// prescribeVisualAttach closes the owed-attachment gap of a published-open
// PR so the flow never goes dark on visual_pending or manual_required. It
// fires under BOTH terminals — the attachment completes publication, it is
// not merge pursuit. visual_pending prescribes the attach-evidence retry
// (work-derivable); manual_required prescribes recording the manually
// attached comment, owing the operator-observed URL. A fired goal escape
// prescribes nothing, exactly like the post-publish layer.
// control-law: prescriptive-closure-every-stage-names-a-runnable-command
func prescribeVisualAttach(repo string, status NextStatus) (*PrescribedCommand, string) {
if status.ObservedStage != "PUBLISHED" || status.Lifecycle != "PUBLISHED_OPEN" || status.Feature == "" || status.GoalEscape != "" {
return nil, ""
}
var repoArgs []string
if repo != "" && repo != "." {
repoArgs = []string{"--repo", repo}
}
switch status.VisualPublication {
case "visual_pending":
cmd := &PrescribedCommand{
Verb: "attach-evidence", Args: append(repoArgs, "--feature", status.Feature),
AutoDerivable: true, Transition: MarkerPublishedAttach,
}
return cmd, "The PR is open; only its Boatstack visual-evidence comment is owed. If the publisher keeps failing, attach the fingerprinted PNGs manually and record the URL with record-pr-visual-publication."
case "manual_required":
cmd := &PrescribedCommand{
Verb: "record-pr-visual-publication", Args: append(repoArgs, "--key", status.Feature),
RequiresHumanInput: []string{"--comment-url"},
Transition: MarkerPublishedAttach,
}
if strings.TrimSpace(status.PRURL) != "" {
cmd.Args = append(cmd.Args, "--pr-url", status.PRURL)
} else {
cmd.RequiresHumanInput = append(cmd.RequiresHumanInput, "--pr-url")
}
return cmd, "No automatic publisher is available here: attach the fingerprinted PNGs to one PR comment yourself, then record the observed comment URL."
}
return nil, ""
}

// prescribePostPublish closes the prescriptive loop past publish, but ONLY
// under the merged terminal: with the published default this function returns
// nothing and post-publish behavior is exactly what it always was. The
Expand Down Expand Up @@ -606,10 +659,17 @@ func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) {
}
}
}
// Past publish the oracle sits at its sink and prescribes nothing; under
// the merged terminal the observation-derived post-publish layer takes
// over. It fills only an empty prescription — it can never override an
// oracle move.
// Past publish the oracle sits at its sink and prescribes nothing. An
// owed visual attachment is consulted first and under BOTH terminals —
// it completes the publication itself — then, under the merged terminal
// only, the observation-derived post-publish layer. Each fills only an
// empty prescription — neither can override an oracle move.
if out.Prescribed == nil {
if cmd, followUp := prescribeVisualAttach(repo, status); cmd != nil {
out.Prescribed = cmd
out.FollowUp = followUp
}
}
if out.Prescribed == nil {
if cmd, followUp := prescribePostPublish(repo, status, out.Terminal); cmd != nil {
out.Prescribed = cmd
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func ResolveFrontier(repoPath string) (FlowFrontier, error) {
continue
}
branch, _, prURL := deliveryBranchAndSlice(state)
status := publishedNextStatus(state, observePRTarget(repo, prURL, branch), resolveDeliveryTerminal(repo, state.Feature))
status := publishedNextStatus(state, observePRTarget(repo, prURL, branch), resolveDeliveryTerminal(repo, state.Feature), observeVisualPublication(repo, state.Feature))
frontier.Rows = append(frontier.Rows, frontierRowFromStatus(repo, status))
}
for _, row := range frontier.Rows {
Expand Down
37 changes: 35 additions & 2 deletions labs/12-product-engineering-loop/product-engineering-loop/next.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type NextStatus struct {
PRURL string `json:"pr_url,omitempty"`
HeadBranch string `json:"head_branch,omitempty"`
ParentDelivery string `json:"parent_delivery,omitempty"`
// VisualPublication surfaces an owed evidence attachment of a published
// PR ("visual_pending" or "manual_required"); empty otherwise.
VisualPublication string `json:"visual_publication,omitempty"`
}

func blockedNextStatus(stage, operation, reason string, ambiguity ...string) NextStatus {
Expand Down Expand Up @@ -146,7 +149,7 @@ func nextForPublished(repo string, state DeliveryState) NextStatus {
pr := observePublishedPR(repo, state)
persistObservedTerminalPRState(repo, state, pr)
terminal := resolveDeliveryTerminal(repo, state.Feature)
status := publishedNextStatus(state, pr, terminal)
status := publishedNextStatus(state, pr, terminal, observeVisualPublication(repo, state.Feature))
// A fired escape is cached best-effort so the demotion holds offline in a
// fresh session — the same bounded bypass as the terminal PRState cache.
// control-law: goal-escape-demotes-to-operator-and-stops
Expand All @@ -160,7 +163,28 @@ func nextForPublished(repo string, state DeliveryState) NextStatus {
// published NextStatus. Split from nextForPublished so the frontier report can
// present the same projection without nextForPublished's best-effort terminal
// cache write. control-law: frontier-reports-never-mutates
func publishedNextStatus(state DeliveryState, pr publishedPRObservation, terminal DeliveryTerminal) NextStatus {
// observeVisualPublication reads the owed-attachment state of a feature's
// visual evidence, best-effort and read-only: any load failure is today's
// empty answer, never a block, and only the two owed states surface —
// "pending" belongs to first publication (publish-pr) and "published" owes
// nothing. control-law: frontier-reports-never-mutates
func observeVisualPublication(repo, feature string) string {
key, err := visualEvidenceKey("managed", feature, "")
if err != nil {
return ""
}
manifest, err := LoadPRVisualEvidence(repo, key)
if err != nil {
return ""
}
switch manifest.Publication.State {
case "visual_pending", "manual_required":
return manifest.Publication.State
}
return ""
}

func publishedNextStatus(state DeliveryState, pr publishedPRObservation, terminal DeliveryTerminal, visualPublication string) NextStatus {
_, sliceID, _ := deliveryBranchAndSlice(state)
status := NextStatus{
SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED",
Expand Down Expand Up @@ -205,6 +229,15 @@ func publishedNextStatus(state DeliveryState, pr publishedPRObservation, termina
default:
status.Reason = fmt.Sprintf("Feature %q is published, but its PR state could not be verified.", state.Feature)
}
if pr.Lifecycle == "PUBLISHED_OPEN" {
status.VisualPublication = visualPublication
switch visualPublication {
case "visual_pending":
status.Reason += " Its Boatstack visual-evidence comment is still owed; Boatstack can retry the attachment (attach-evidence)."
case "manual_required":
status.Reason += " Its visual-evidence comment needs manual attachment; record the observed URL with record-pr-visual-publication."
}
}
if status.GoalEscape != "" {
status.Reason = fmt.Sprintf("Feature %q is published; the merged-goal pursuit is paused because %s. Record the correction to start a new cycle, or handle the pull request yourself.", state.Feature, goalEscapeReason(status.GoalEscape))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ func TestNextActorFrontierBoundaries(t *testing.T) {
{"owed_evidence_stays_agents", NextStatus{ObservedStage: "BUILD"}, FlowNext{
Prescribed: &PrescribedCommand{Verb: "record-delivery-gate", RequiresHumanInput: []string{"--status", "--evidence"}, Transition: deliverycontrol.TransitionID("delivery.record_gate_test")},
}, NextActorAgent},
{"owed_visual_attach_retry_is_agents", NextStatus{ObservedStage: "PUBLISHED", Lifecycle: "PUBLISHED_OPEN", VisualPublication: "visual_pending"}, FlowNext{}, NextActorAgent},
{"manual_visual_attachment_is_operators", NextStatus{ObservedStage: "PUBLISHED", Lifecycle: "PUBLISHED_OPEN", VisualPublication: "manual_required"}, FlowNext{}, NextActorOperator},
{"escaped_pursuit_demotes_despite_owed_attachment", NextStatus{ObservedStage: "PUBLISHED", Lifecycle: "PUBLISHED_OPEN", VisualPublication: "visual_pending", GoalEscape: "pr_closed"}, FlowNext{Terminal: TerminalMerged}, NextActorOperator},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
Loading
Loading