procurement-ingest/.claude/workflows/phase-0-deploy-guards.js
Adam Moussa cb5539bd68
Some checks are pending
Deploy / deploy (push) Waiting to run
feat: deploy-pipeline guards — healthcheck, smoke gate, bundle glob + AST test (refactor phase 0) (#107)
* feat: deploy-pipeline guards — healthcheck, smoke gate, bundle glob + AST test (refactor phase 0)

Deploys of po-email-processor and workorder-email-processor had no
verification step, so an init-time ImportError in the bundled zip
could ship silently and only surface on the next real S3 event. This
adds a synchronous post-deploy smoke gate wired into the deploy
workflow: both Lambdas are invoked with {"healthcheck": true} and the
FunctionError field is checked, since an Unhandled init error still
returns HTTP 200 on RequestResponse invokes and would false-pass a
plain exit-code check.

The healthcheck branch is the first statement in each handler, before
any boto3/S3 use or ses_auth, and only fires on a top-level direct
invoke ("healthcheck" is not a key AWS ever sets on a real S3
ObjectCreated event, so mail content can't reach this path). It emits
no EMF metrics and no log text that could match the
sender-auth-rejected metric filter, so two deploys in one window
won't trip the alarm.

Separately, the PO stack's asset bundling copied a hand-maintained
four-file allowlist into the zip, so every new sibling module
handler.py imports had to be added by hand or the deploy shipped a
Lambda that ImportErrors at cold start (bit us for template_parser in
PR #105 and nearly for derived_fields in PR #2). Replaced it with a
non-recursive ./*.py glob so top-level source files ship
automatically while tests/ and the stale package/ dir still cannot,
and added an AST-based bundle-consistency test that parses each
handler's first-party imports and fails CI if the bundling command
would omit any of them (a revert to an incomplete allowlist, or code
moved into a subdirectory the glob doesn't cover).

Includes the refactor-evaluation report that scoped this phase.

* fix: review nits — unambiguous bundling-command extraction, smoke payload-parse message, dead asserts

- tests/test_bundle_consistency.py: _extract_bundling_command now collects
  all command=[...] matches and demands exactly one per stack file, instead
  of silently returning whichever ast.walk visits first if a second bundled
  function is ever added.
- scripts/post-deploy-smoke.sh: distinguish an unparseable response payload
  from a payload mismatch so the failure message says what actually happened
  (the previous "could not parse" branch was unreachable — the inline python
  always exited 0).
- test_po_healthcheck.py: drop the substring assertions on stdout that were
  dead behind the stricter `captured.out == ""` assertion; keep the stderr
  filter-pattern check.

Review follow-up on PR #107; no behavior change to any shipped code path.
2026-07-17 13:18:45 -04:00

428 lines
22 KiB
JavaScript

export const meta = {
name: 'phase-0-deploy-guards',
description: 'Phase 0 of the procurement-ingest refactor (docs/refactor-evaluation.md): healthcheck early-return in both email processors, synchronous post-deploy smoke script wired into cd-cdk, PO cp-allowlist replaced with a non-recursive glob, and an AST bundle-consistency test. Built and adversarially verified on a branch off main, committed locally, never pushed (push is gated on /sh-security-review in the main loop).',
phases: [
{ title: 'Setup', detail: 'clean-tree check, branch feature/phase-0-deploy-guards off up-to-date main', model: 'haiku' },
{ title: 'Recon', detail: '4 parallel read-only mappers over handlers, CDK/alarms/CI, and test infra', model: 'haiku' },
{ title: 'Implement', detail: 'handlers on opus; smoke script, CDK glob + AST test on sonnet — disjoint file ownership, one branch', model: 'opus' },
{ title: 'Verify', detail: 'mechanical gates (pytest/ruff/synth/scope) on sonnet + 3 adversarial fable lenses' },
{ title: 'Fix', detail: 'opus fixer applies confirmed findings, full re-verify, max 3 rounds', model: 'opus' },
{ title: 'Package', detail: 'README update, GPT-4.1 cross-family review of the diff, single commit via -F (no push)', model: 'sonnet' },
],
}
// ---------------------------------------------------------------- constants
const REPO = '/Users/adammoussa/Documents/repositories/seahaven/procurement-ingest'
const BRANCH = 'feature/phase-0-deploy-guards'
// Pinned contract (memory lesson: define the shared contract BEFORE the
// parallel fan-out — parallel leaves can't see each other's choices).
const CONTRACT = `
HEALTHCHECK CONTRACT — pinned, do not deviate or "improve":
- Trigger: top-level direct-invoke payload only. In lambda handler ("handler"
in handler.py), the VERY FIRST statements: if the event is a dict and
event.get("healthcheck") is True -> return {"healthcheck": "ok"} immediately.
- Placement: BEFORE any boto3/S3 use, BEFORE ses_auth, BEFORE iterating
event["Records"]. It must not create any accept path for mail: real mail
events are S3 ObjectCreated events whose top-level keys AWS controls
("Records"); email content can never set a top-level event key.
- Telemetry: the healthcheck branch emits NO EMF metrics and NO log line
whose text could match the sender-auth-rejected metric-filter pattern
(read the filter pattern in cdk/*_stack.py before choosing any log text;
safest is a single log line exactly "healthcheck ok" or no logging).
That alarm pages at >=1 match, so two deploys in ~30 min must not page.
- SMOKE SCRIPT CONTRACT: scripts/post-deploy-smoke.sh invokes BOTH functions
(po-email-processor, workorder-email-processor) with payload
'{"healthcheck": true}' using: aws lambda invoke --invocation-type
RequestResponse. It must check the FunctionError field of the response
(an init ImportError returns HTTP 200 + FunctionError=Unhandled — exit-code
checks false-pass) AND that the payload equals {"healthcheck": "ok"}.
Non-zero exit on any failure; set -euo pipefail; region us-east-1.
`
const PREAMBLE = `
You are one of several agents building refactor Phase 0 in the git repo at
${REPO} on branch ${BRANCH} (already checked out — do NOT switch branches,
do NOT create branches, do NOT commit, NEVER push, do NOT run cdk deploy or
touch AWS resources; read-only aws CLI calls are also unnecessary).
Authoritative spec: docs/refactor-evaluation.md, section "Phase 0".
Work ONLY in the files you are told you own. Other agents are concurrently
editing other files in this same working tree — do not read-depend on or
modify their files.
${CONTRACT}
Your final message is consumed by an orchestrator script, not a human —
return only the structured data requested.
`
// ------------------------------------------------------------------ schemas
const RECON = {
type: 'object',
required: ['summary', 'facts'],
properties: {
summary: { type: 'string' },
facts: { type: 'array', items: { type: 'string' } },
blockers: { type: 'array', items: { type: 'string' } },
},
}
const IMPL = {
type: 'object',
required: ['filesChanged', 'testsAdded', 'summary', 'checksRun'],
properties: {
filesChanged: { type: 'array', items: { type: 'string' } },
testsAdded: { type: 'array', items: { type: 'string' } },
summary: { type: 'string' },
checksRun: { type: 'string', description: 'exact commands run + pass/fail' },
blockers: { type: 'array', items: { type: 'string' } },
},
}
const CHECKS = {
type: 'object',
required: ['passed', 'details'],
properties: {
passed: { type: 'boolean' },
details: { type: 'string', description: 'per-gate results; verbatim failure output' },
scopeViolations: { type: 'array', items: { type: 'string' } },
},
}
const FINDINGS = {
type: 'object',
required: ['findings'],
properties: {
findings: {
type: 'array',
items: {
type: 'object',
required: ['title', 'severity', 'confirmed', 'evidence', 'fix'],
properties: {
title: { type: 'string' },
severity: { enum: ['critical', 'high', 'medium', 'low'] },
confirmed: { type: 'boolean', description: 'true only with concrete file:line evidence' },
evidence: { type: 'string' },
fix: { type: 'string' },
},
},
},
},
}
const TEXT = {
type: 'object',
required: ['summary'],
properties: { summary: { type: 'string' }, verdict: { type: 'string' } },
}
// ------------------------------------------------------------------- setup
phase('Setup')
const setup = await agent(`
In ${REPO}: verify the working tree is clean apart from untracked .coverage
and docs/refactor-evaluation.md (if anything ELSE is dirty, STOP and report a
blocker — do not stash or discard anything). Then:
git fetch origin && git checkout main && git pull --ff-only
git checkout -b ${BRANCH}
Also run: gh pr list --state open --json number,title,headRefName
(memory lesson: a branch cut fresh from main misses fixes sitting in unmerged
PRs — list them so the orchestrator can flag overlaps).
Return facts: current HEAD sha, branch created y/n, open PR list, blockers.
`, { label: 'setup:branch', model: 'haiku', schema: RECON })
if (setup && setup.blockers && setup.blockers.length) {
return { aborted: 'setup blockers', blockers: setup.blockers, openPRs: setup.facts }
}
log(`Branch ${BRANCH} ready. ${setup ? setup.summary : ''}`)
// ------------------------------------------------------------------- recon
phase('Recon')
const recon = await parallel([
() => agent(`${PREAMBLE}
Read-only recon of lambdas/po/email_processor/handler.py and its tests dir.
Report: exact def line of the lambda handler; the first statements it executes
(S3 fetch? ses_auth call? Records iteration?) with line numbers; how existing
handler tests load the module and fake AWS (loader idiom, moto import-order
invariant in _po_parser_support.py); where a healthcheck test would naturally
live; any existing early-return branches. 10-20 precise facts.`,
{ label: 'recon:po-handler', model: 'haiku', phase: 'Recon', schema: RECON }),
() => agent(`${PREAMBLE}
Read-only recon of lambdas/wo/email_processor/handler.py and its tests dir.
Same report shape: handler def line, first statements executed with line
numbers, test loader idiom (_wo_parser_support.py), where a healthcheck test
lives, existing early returns. 10-20 precise facts.`,
{ label: 'recon:wo-handler', model: 'haiku', phase: 'Recon', schema: RECON }),
() => agent(`${PREAMBLE}
Read-only recon of cdk/po_stack.py, cdk/wo_stack.py and .github/workflows/.
Report: (1) the exact PO bundling command incl. the cp allowlist at
po_stack.py:~245-256 and the full file list of lambdas/po/email_processor/*.py
so the glob replacement can be proven identical-output; (2) the exact
sender-auth-rejected metric FILTER PATTERNS in both stacks (quote them
verbatim) and their alarm eval windows; (3) deploy.yaml's inputs to the
cd-cdk reusable workflow — fetch the pinned workflow file with
'gh api repos/Sea-Haven-Industries/.github/contents/.github/workflows/cd-cdk.yaml?ref=fd60e4c9041784f666ac0fdefb9bec3c7fbf5143'
(base64 -d the content) and confirm whether a post-deploy-script input exists
and its semantics (cwd, when it runs, failure handling). If it does NOT
exist, report that as a blocker with the closest available mechanism.
(4) WO bundling command for comparison. 15-25 precise facts.`,
{ label: 'recon:cdk-ci', model: 'sonnet', phase: 'Recon', schema: RECON }),
() => agent(`${PREAMBLE}
Read-only recon of test infrastructure: pytest.ini, tests/ (repo root),
tests/conftest.py, how CI (.github/workflows/ci.yaml) invokes pytest/ruff.
Report: how a NEW repo-root test file (tests/test_bundle_consistency.py)
would be collected; what it can import; whether tests/ has helpers for
locating lambda dirs; the ruff invocation used in CI. 8-15 precise facts.`,
{ label: 'recon:tests-ci', model: 'haiku', phase: 'Recon', schema: RECON }),
])
const reconOk = recon.filter(Boolean)
const reconBlockers = reconOk.flatMap(r => r.blockers || [])
const pack = reconOk.map(r => `## ${r.summary}\n${r.facts.join('\n')}`).join('\n\n')
log(`Recon complete: ${reconOk.length}/4 mappers, ${reconBlockers.length} blockers`)
// --------------------------------------------------------------- implement
phase('Implement')
const implTasks = [
{ label: 'impl:po-healthcheck', model: 'opus', prompt: `${PREAMBLE}
YOU OWN: lambdas/po/email_processor/handler.py and NEW test file(s) under
lambdas/po/email_processor/tests/ ONLY.
Task: add the healthcheck early-return branch to the PO handler exactly per
the pinned contract. Add tests: (1) {"healthcheck": true} returns
{"healthcheck": "ok"} with ZERO S3 calls, ZERO ses_auth calls, ZERO DynamoDB
writes, ZERO metric emission (assert via monkeypatch/fakes per the existing
loader idiom — preserve the moto-before-handler import ordering); (2) a normal
S3 mail event is completely unaffected (an existing golden-path test still
passing is necessary but ALSO assert a mail event containing the string
"healthcheck" in its email body does NOT take the branch).
Run before returning: ruff check lambdas/po, ruff format lambdas/po --check,
pytest lambdas/po/email_processor/tests -q --no-cov.
Recon context:\n${pack}` },
{ label: 'impl:wo-healthcheck', model: 'opus', prompt: `${PREAMBLE}
YOU OWN: lambdas/wo/email_processor/handler.py and NEW test file(s) under
lambdas/wo/email_processor/tests/ ONLY.
Task: identical healthcheck branch + tests for the WO handler, per contract,
mirroring the PO task one-for-one but using the WO test loader idiom.
Run before returning: ruff check lambdas/wo, ruff format lambdas/wo --check,
pytest lambdas/wo/email_processor/tests -q --no-cov.
Recon context:\n${pack}` },
{ label: 'impl:smoke-script', model: 'sonnet', prompt: `${PREAMBLE}
YOU OWN: scripts/post-deploy-smoke.sh (new) and .github/workflows/deploy.yaml ONLY.
Task: write the synchronous smoke script per the pinned SMOKE SCRIPT CONTRACT
(both functions, RequestResponse, FunctionError field check + payload check,
set -euo pipefail, executable bit, shellcheck-clean). Wire it into deploy.yaml
via the cd-cdk reusable workflow's post-deploy-script input per the recon
facts below — if recon reported that input missing, implement the closest
mechanism recon identified and record a blocker note instead of inventing
workflow inputs. Do NOT restructure deploy.yaml otherwise.
Run before returning: bash -n scripts/post-deploy-smoke.sh, and
python3 -c "import yaml,sys;yaml.safe_load(open('.github/workflows/deploy.yaml'))".
Recon context:\n${pack}` },
{ label: 'impl:bundle-glob-ast', model: 'sonnet', prompt: `${PREAMBLE}
YOU OWN: cdk/po_stack.py (ONLY the email-processor bundling command block,
~lines 241-258) and tests/test_bundle_consistency.py (new) ONLY.
Task A: replace the four-file cp allowlist with a non-recursive glob:
"cp ./*.py /asset-output/". Keep the pip install line untouched. Update the
warning comment to explain the glob + that the AST test now enforces
consistency. PROVE identical output: list lambdas/po/email_processor/*.py
(non-recursive) and confirm the set equals {handler, ses_auth,
template_parser, derived_fields}.py plus any other top-level .py that SHOULD
ship; if extras exist (e.g. prompts or __init__), state explicitly whether
shipping them is a no-op and why. tests/ and package/ are directories, so a
non-recursive ./*.py glob never matches them.
Task B: write tests/test_bundle_consistency.py: for EACH pipeline (po, wo),
ast-parse email_processor/handler.py, collect top-level "import X" /
"from X import ..." names, filter to first-party siblings (X.py exists in the
same dir), and assert the bundling guarantee ships them — for PO: assert the
po_stack.py bundling command contains the glob "cp ./*.py"; for WO: assert
its bundling command copies them (read wo_stack.py to see its current cp -r
form and assert accordingly). The test must FAIL if someone reverts the glob
to an allowlist missing a sibling — include a unit-level check that simulates
an allowlist command string missing derived_fields.py and asserts the
detection logic catches it. No AWS/boto3/synth in the test — pure
ast + file reads, fast.
Run before returning: ruff check cdk tests, pytest tests/test_bundle_consistency.py -q --no-cov.
Recon context:\n${pack}` },
]
const impl = await parallel(implTasks.map(t => () =>
agent(t.prompt, { label: t.label, model: t.model, phase: 'Implement', schema: IMPL })))
const implOk = impl.filter(Boolean)
const implBlockers = implOk.flatMap(r => r.blockers || [])
log(`Implement complete: ${implOk.length}/4 agents, blockers: ${implBlockers.length}`)
// ---------------------------------------------------- verify + fix loop
const EXPECTED_SCOPE = [
'lambdas/po/email_processor/handler.py',
'lambdas/po/email_processor/tests/',
'lambdas/wo/email_processor/handler.py',
'lambdas/wo/email_processor/tests/',
'scripts/post-deploy-smoke.sh',
'.github/workflows/deploy.yaml',
'cdk/po_stack.py',
'tests/test_bundle_consistency.py',
]
const mechanicalPrompt = `${PREAMBLE}
Independent re-verification (trust-but-verify — do not rely on implementers'
self-reports). Run ALL of, reporting each verbatim on failure:
1. pytest -q --no-cov (repo root — all 3 roots, expect ~568+new all green)
2. ruff check .
3. ruff format --check .
4. npx cdk synth po-ingest -q && npx cdk synth workorder-ingest -q
(run inside cdk/; selectors are ARTIFACT IDs, not stack_name)
5. bash -n scripts/post-deploy-smoke.sh; test -x scripts/post-deploy-smoke.sh
6. git status --porcelain — every modified/added path must fall under:
${EXPECTED_SCOPE.join(', ')} (plus untracked .coverage,
docs/refactor-evaluation.md, .claude/workflows/). List violations.
7. Grep the healthcheck branch in both handlers: confirm it precedes any S3
get_object and any ses_auth call by line number.
YOU MAY NOT edit any file. passed=true only if every gate is green and scope
is clean.`
const lenses = [
{ key: 'security', prompt: `${PREAMBLE}
ADVERSARIAL REVIEW — security lens. Try to REFUTE the safety of this diff
(git diff main). Attack: (1) can any S3/SES-delivered email reach the
healthcheck branch or any other new early-return (top-level key forgery via
event shape, weird typing like event={"healthcheck":"true"} vs True)?
(2) does placement before ses_auth weaken fail-closed behavior in ANY path?
(3) does the smoke script or deploy.yaml change introduce injection (unquoted
vars, payload echoed into shell)? (4) does the AST test import or execute
handler code (it must not)? confirmed=true ONLY with a concrete exploit
sketch + file:line.` },
{ key: 'telemetry', prompt: `${PREAMBLE}
ADVERSARIAL REVIEW — telemetry/alarm lens. Read the sender-auth-rejected
metric FILTER PATTERNS in cdk/po_stack.py and cdk/wo_stack.py verbatim, then
try to prove a healthcheck invocation (incl. Lambda platform START/REPORT
lines and any new log text) produces a filter match — that alarm pages at
>=1. Also verify: zero EMF emission on the healthcheck path; ParseMethod
metric contract untouched (PO emits ai_fallback BEFORE the Bedrock call —
must not have moved); no alarm/math changes snuck into po_stack.py beyond
the bundling block. confirmed=true only with file:line evidence.` },
{ key: 'bundling', prompt: `${PREAMBLE}
ADVERSARIAL REVIEW — bundling/CI lens. (1) Enumerate
lambdas/po/email_processor/*.py and prove the new glob ships EXACTLY the
right set vs the old allowlist {handler,ses_auth,template_parser,
derived_fields}.py — flag any extra top-level .py whose shipping is NOT a
proven no-op, and confirm tests/ + package/ cannot match a non-recursive
./*.py. (2) Mutation-test the AST consistency test: temporarily copy the
detection logic and feed it an allowlist string missing derived_fields.py —
does it fail? (do this in /tmp scratch, not by editing repo files).
(3) Smoke script: does it actually catch FunctionError on HTTP 200 (trace the
aws CLI output handling — --query vs jq parsing), and does a missing function
name or region default break it? (4) deploy.yaml: is the post-deploy wiring
consistent with the cd-cdk reusable workflow's actual input names (re-fetch
the pinned file via gh api if needed)? confirmed=true only with evidence.` },
]
let round = 0
let checks = null
let confirmed = []
while (round < 3) {
phase('Verify')
const results = await parallel([
() => agent(mechanicalPrompt, { label: `verify:mechanical-r${round}`, model: 'sonnet', phase: 'Verify', schema: CHECKS }),
...lenses.map(l => () =>
agent(l.prompt, { label: `verify:${l.key}-r${round}`, phase: 'Verify', schema: FINDINGS })),
])
checks = results[0]
confirmed = results.slice(1).filter(Boolean)
.flatMap(r => r.findings || [])
.filter(f => f.confirmed && (f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'))
const mechanicalGreen = checks && checks.passed
log(`Verify round ${round}: mechanical ${mechanicalGreen ? 'GREEN' : 'RED'}, confirmed findings: ${confirmed.length}`)
if (mechanicalGreen && confirmed.length === 0) break
round += 1
if (round >= 3) break
phase('Fix')
await agent(`${PREAMBLE}
You are the fix agent — you may edit any Phase-0-owned file listed here:
${EXPECTED_SCOPE.join(', ')}.
Fix EVERY item below with the minimal change; do not expand scope; keep the
pinned contract intact. Re-run the specific failing check/test for each fix.
MECHANICAL FAILURES:\n${checks ? checks.details : '(mechanical agent died — rerun everything)'}
CONFIRMED FINDINGS:\n${JSON.stringify(confirmed, null, 2)}`,
{ label: `fix:round-${round}`, model: 'opus', phase: 'Fix', schema: IMPL })
}
const verifyClean = checks && checks.passed && confirmed.length === 0
if (!verifyClean) {
return {
status: 'NEEDS ATTENTION — verify not clean after 3 rounds; branch left uncommitted',
branch: BRANCH,
mechanical: checks,
unresolvedFindings: confirmed,
implBlockers,
reconBlockers,
openPRs: setup ? setup.facts : [],
}
}
// ----------------------------------------------------------------- package
phase('Package')
const readme = await agent(`${PREAMBLE}
YOU OWN: README.md only. Document (in the style of the existing README):
the {"healthcheck": true} direct-invoke contract on both processors, the
post-deploy smoke gate (what it checks, that FunctionError is the signal),
and the PO bundling glob + AST consistency test (replacing the allowlist
note if one exists). Same-commit README updates are a handbook requirement.
Run: ruff format --check . still clean (README is md, but confirm no stray
edits). Return filesChanged.`,
{ label: 'package:readme', model: 'sonnet', phase: 'Package', schema: IMPL })
const crossReview = await agent(`${PREAMBLE}
The healthcheck branch adds a new event-contract field to both Lambda
handlers — the handbook mandates a cross-family review for handler-signature
/ event-shape changes. Run exactly:
cd ${REPO} && git diff main > /tmp/phase0.diff
python3 ~/Documents/repositories/seahaven/security-review/cross_review.py \
"Review this diff for breaking changes. Context: procurement-ingest refactor Phase 0 per docs/refactor-evaluation.md — healthcheck early-return added to both email-processor handlers (new top-level event field, direct-invoke only), post-deploy smoke script, PO bundling cp-allowlist replaced with ./*.py glob, AST bundle-consistency test. Diff follows: $(cat /tmp/phase0.diff)"
(cross_review.py is stateless/one-shot — the diff must be inline.) Return its
verdict VERBATIM in summary, and set verdict to one of: PASS / FIX / BLOCK
based on its highest finding. Do not fix anything yourself.`,
{ label: 'package:cross-review', model: 'sonnet', phase: 'Package', schema: TEXT })
const commit = await agent(`${PREAMBLE.replace('do NOT commit, ', '')}
YOU are the commit agent. Steps:
1. Read ~/Documents/repositories/seahaven/engineering-handbook/commit-messages.md
and follow it exactly.
2. git add: the Phase-0 files (${EXPECTED_SCOPE.join(', ')}), README.md, AND
docs/refactor-evaluation.md (untracked evaluation report — must land with
this first refactor PR or it is lost) AND .claude/workflows/phase-0-deploy-guards.js.
Do NOT add .coverage. Verify with git status that nothing unexpected is staged.
3. ONE commit. Write the message to /tmp/phase0-commit-msg.txt and use
git commit -F /tmp/phase0-commit-msg.txt (backticks in -m get eaten by zsh).
Suggested subject: "feat: deploy-pipeline guards — healthcheck, smoke gate, bundle glob + AST test (refactor phase 0)".
NO AI attribution / Co-Authored-By lines.
4. Do NOT push. Return the commit sha + shortstat in summary.`,
{ label: 'package:commit', model: 'sonnet', phase: 'Package', schema: IMPL })
return {
status: 'BUILT — committed locally, NOT pushed',
branch: BRANCH,
commit: commit ? commit.summary : 'commit agent died — commit manually',
crossFamilyReview: crossReview ? { verdict: crossReview.verdict, detail: crossReview.summary } : 'NOT RUN — outstanding',
implementation: implOk.map(r => r.summary),
filesChanged: implOk.flatMap(r => r.filesChanged).concat(readme ? readme.filesChanged : []),
verifyRounds: round + 1,
blockers: implBlockers.concat(reconBlockers),
openPRsAtBranchTime: setup ? setup.facts : [],
outstandingGates: [
'/sh-security-review (MANDATORY before push — handler = untrusted-input surface); run in the main loop on the committed diff',
'if cross-family verdict is FIX/BLOCK: resolve, re-run cross_review.py on the amended diff',
'push + PR + gh pr checks green',
'deploy-then-merge: deploy from branch, smoke green, one real PO + WO email each showing ParseMethod=template, all alarms green, THEN merge',
],
}