procurement-ingest/.claude/workflows/phase-7-ops-recovery.js
Adam Moussa 75fe91c198
Ops/recovery tooling + dependency hygiene (refactor phase 7) (#110)
* feat: ops/recovery tooling + dependency hygiene (refactor phase 7)

Generalize scripts/reprocess.py from a PO-only full-sweep script into a
pipeline-general recovery tool. Targeted replay (--key/--prefix/--since)
is now the default, and the full inbound/ sweep is demoted behind an
explicit --all that documents its five hazards (async concurrency does
not serialize, use RequestResponse if order matters, metric double-count,
Bedrock re-bill, out-of-order field regression). --pipeline po|wo resolves
the correct function + bucket; dry-run-by-default / --execute is preserved.
A new tests/test_reprocess_contract.py pins the synthetic S3 event shape
and asserts the raw list_objects_v2 key is emitted untransformed (the
handler is the single decode point; a pre-decoded key would corrupt keys
containing spaces or '+').

Add docs/runbook-dlq-recovery.md: the async on-failure DLQ has no console
redrive-to-source, so it documents the receive -> extract key -> targeted
reprocess --key -> verify -> purge procedure, the real recovery windows
(14-day DLQ breadcrumb, 90-day raw-email S3 that overrides the table
RETAIN policy and is the true replay floor), and that sender-auth and
ai_fallback_rejected drops are fail-closed skips that never reach the DLQ.
Linked from the README alarms and scripts sections.

Drop the vendored boto3 floor pin from both email-processor requirements
(the Lambda runtime provides boto3; lambda-template.md empty-with-comment
form). With nothing left to install, the email-processor bundling becomes
cp-only -- the whole pip step is removed, which is the only acceptable way
the manylinux2014_aarch64 pin disappears (removing the pin while keeping a
pip install caused the PR #34 x86-wheel outage). Exact-pin moto==5.2.2 and
add pinned po/web_ui + po/site_extractor manifests (excluded from their
bundles, so hash-neutral) so their new Dependabot entries have something
to act on; add Dependabot entries for /tests, /lambdas/po/web_ui, and
/lambdas/po/site_extractor.

cdk diff is confined to exactly the two email processors' asset hashes on
both stacks. The wo/web_ui dead-manifest reduction was deliberately left
out: that manifest already ships inside the plain (non-bundled) WebUI
asset on main, so reducing or excluding it would redeploy workorder-web-ui
for no functional change -- deferred to keep the blast radius to the two
intended targets.

The untracked 44 MB lambdas/po/email_processor/package/ dir was removed
from the filesystem (asset-hash-neutral given Phase 2's package/ exclude);
it is untracked, so there is nothing to commit for it.

* Reject --all combined with --prefix/--since in reprocess.py

--all is a distinct mode (the demoted full-prefix sweep), but the args.all
branch unconditionally set prefix=inbound/ and since=None, so passing it
alongside a narrower selector silently discarded that selector. `--all
--since 2026-07-01` swept the entire corpus instead of the bounded window,
triggering every documented --all hazard (Bedrock re-bill, metric double-
count, merged-field regression) on objects the operator never targeted --
contradicting the tool's safety goal. Add the missing mutual-exclusion
guard alongside the existing --key one, and pin --all+--prefix,
--all+--since, and all three together as argparse rejections.
2026-07-20 12:53:34 -04:00

661 lines
40 KiB
JavaScript

export const meta = {
name: 'phase-7-ops-recovery',
description: 'Phase 7 of the procurement-ingest refactor (docs/refactor-evaluation.md): ops/recovery + dependency hygiene. Generalize scripts/reprocess.py (--pipeline po|wo, --key/--prefix/--since; TARGETED replay primary, full-prefix DEMOTED behind --all with documented caveats) + a synthetic-event-shape contract test pinning the raw-key/no-URL-decode S3 event. New docs/runbook-dlq-recovery.md for the no-redrive async-destination DLQ (receive->key->re-invoke->verify->purge; 14d DLQ / 90d S3 windows; sender-auth + ai_fallback_rejected drops never reach the DLQ), linked from README alarms. Dependency hygiene: drop vendored boto3 from both email-processor requirements (handbook empty-with-comment form; runtime copy suffices per lambda-template.md), simplify bundling to cp-only where the vendored dep is gone, exact-pin moto==, pin-then-add dependabot entries for /tests + po/web_ui + po/site_extractor, reduce wo/web_ui dead manifest to empty-with-comment. Optional local-only rm of the untracked 44 MB package/ dir. Gates on Phase 2 merged (../lambdas asset root). Ops tooling + dep hygiene, no auth/untrusted-input/IAM change. Committed locally, never pushed.',
phases: [
{ title: 'Setup', detail: 'verify Phase 2 on base (Code.from_asset("../lambdas")), branch feature/phase-7-ops-recovery', model: 'haiku' },
{ title: 'Recon', detail: '4 mappers: reprocess.py + event shape, dependency/manifest/dependabot state, post-Phase-2 bundling + package/ dir, DLQ/alarm/S3-lifecycle names (read-only AWS)' },
{ title: 'Spec', detail: 'serial fable spec: reprocess CLI + caveats, runbook contents, requirements/moto/dependabot edits, cp-only bundling form, package/ cleanup, contract test, verify rules' },
{ title: 'Implement', detail: 'opus: scripts/ + reprocess contract test; sonnet: requirements + dependabot + cdk bundling; opus: runbook + README — disjoint files', model: 'opus' },
{ title: 'Verify', detail: 'mechanical gates + 3 fable lenses (bundling-integrity, reprocess-safety, dependency)', model: 'sonnet' },
{ title: 'Fix', detail: 'opus fixer, full re-verify, max 3 rounds', model: 'opus' },
{ title: 'Package', detail: 'single commit via -F (no push); optional local rm of package/ reported, not committed', model: 'sonnet' },
],
}
// ---------------------------------------------------------------- constants
const REPO = '/Users/adammoussa/Documents/repositories/seahaven/procurement-ingest'
const BRANCH = 'feature/phase-7-ops-recovery'
let _args = args
if (typeof _args === 'string') {
try { _args = JSON.parse(_args) } catch (e) { _args = null }
}
const BASE = (_args && _args.base) || 'main'
const CONSTRAINTS = `
PINNED BEHAVIORAL CONSTRAINTS (docs/refactor-evaluation.md Phase 7 — violating any is a build failure):
1. GENERALIZE scripts/reprocess.py: add --pipeline po|wo (selects the
function name + bucket per pipeline — po-email-processor /
po-ingest-emails-<acct>, workorder-email-processor / its bucket), plus
--key (single object), --prefix, and --since (time filter on
LastModified). TARGETED replay (--key / --prefix / --since) is the
PRIMARY, default mode. FULL-PREFIX replay is DEMOTED behind an explicit
--all flag. The --all caveats MUST be documented BOTH in --help text and
in code comments: Event-type (async) invocation is CONCURRENT so sorting
does NOT serialize; switch to RequestResponse if order matters; metrics
get double-counted on replay; Bedrock is re-billed; out-of-order replay
can REGRESS already-merged fields. Keep the existing dry-run-by-default /
--execute safety (targeted replay stays dry-run unless --execute).
2. SYNTHETIC-EVENT-SHAPE CONTRACT TEST (§4.7): pin the exact S3 event shape
reprocess emits — Records[0].s3.bucket.name + Records[0].s3.object.key —
and assert the key is the RAW object key with NO URL-decoding (a real S3
notification URL-encodes the key; reprocess builds from the raw
list_objects_v2 key, and the handler is what decodes — replaying a
pre-decoded key would double-decode). Test lives under scripts' tests or
tests/ and runs in the standard pytest collection.
3. NEW docs/runbook-dlq-recovery.md: the async on-failure destination DLQ
has NO console redrive-to-source. Documented procedure: receive-message
-> extract the S3 key from the event body -> targeted re-invoke (via the
generalized reprocess.py --key) -> verify the write -> purge the message.
State the recovery WINDOWS explicitly: 14-day DLQ breadcrumb retention,
90-day raw-email S3 retention (the inbound/ lifecycle rule OVERRIDES the
table RETAIN policy — S3 is the real replay floor). Document that
sender-auth rejections AND ai_fallback_rejected drops INTENTIONALLY never
reach the DLQ (they are fail-closed SKIPS, not errors — no retry, no DLQ
message). Link the runbook from the README alarms section.
4. DEPENDENCY HYGIENE — DROP vendored boto3 from BOTH email-processor
requirements.txt: replace 'boto3>=1.43.47' with the handbook
empty-with-comment form (a comment explaining the Lambda runtime provides
boto3 per lambda-template.md — only-boto3 functions correctly use the
runtime copy; no pinned third-party dep remains). Same empty-with-comment
reduction for wo/web_ui/requirements.txt (a dead manifest today).
5. SIMPLIFY BUNDLING TO CP-ONLY where the vendored dep is gone: once the
email-processor requirements are empty-with-comment, 'pip install -r ... '
installs nothing, so the pip step is removable and the command becomes
cp-only. This is ONLY safe BECAUSE there are no third-party binary deps
left. Do NOT regress: the Phase 2 exclude lists
(['**/__pycache__/**','**/tests/**','**/package/**']) STAY; the widened
'../lambdas' asset root STAYS; if a Phase 3 'cp shared/*.py' line is
present on the base it STAYS. The '--platform manylinux2014_aarch64
--only-binary=:all:' pin is only meaningful while a pip install runs — its
accidental removal caused the PR #34 outage, so removing the WHOLE pip
step (deliberately, because nothing is installed) is the only acceptable
way it disappears; you may NOT keep a pip install while dropping the pin.
If recon finds ANY third-party dep still required by an email processor,
cp-only is a blocker — keep the pinned pip step.
6. EXACT-PIN moto: change tests/requirements.txt 'moto>=5.0.0' to
'moto==<resolved-version>' (resolve the actually-installed/current 5.x
version; floor pins make the dependabot entry a no-op).
7. DEPENDABOT: add pip entries for '/tests', '/lambdas/po/web_ui', and
'/lambdas/po/site_extractor' — but PIN THOSE MANIFESTS FIRST. po/web_ui
and po/site_extractor have NO requirements.txt today, so a dependabot
entry pointed at them is a no-op until a PINNED manifest exists; create
each manifest (exact-pinned boto3, or empty-with-comment ONLY if the
dependabot entry would then be pointless — a dependabot entry needs at
least one pinned dep to act on). Match the existing dependabot.yml entry
shape (weekly, minor-and-patch group). tests/ gets its entry once moto is
exact-pinned (constraint 6).
8. DELETE the untracked 44 MB lambdas/po/email_processor/package/ dir as an
OPTIONAL local-cleanup step: it is UNTRACKED / local-only, so this is a
filesystem 'rm -rf', NOT a 'git rm' (there is nothing tracked to commit).
With Phase 2's '**/package/**' exclude already deployed, the deletion is
asset-hash-NEUTRAL. The Package agent PERFORMS and REPORTS it; it is NOT
part of the commit.
9. UNTOUCHABLE: no handler / lambda runtime code change under lambdas/*/
email_processor/*.py (this is dep + ops + bundling only). No alarm, IAM,
table, env, runtime, memory, timeout, or logical-ID delta on either stack
— the ONLY cdk delta permitted is the bundling command string on the two
email processors (+ any resulting asset-hash / CDK metadata change). The
dropped-vendored-boto3 functions MUST still 'import boto3' at runtime from
the Lambda runtime copy.
10. AWS access is READ-ONLY (get-function, get-function-event-invoke-config,
SQS get-queue-attributes, S3 get-bucket-lifecycle-configuration,
describe-alarms). NEVER cdk deploy, never invoke, never mutate, never
purge a real queue.
`
const PREAMBLE = `
You are one of several agents building refactor Phase 7 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 beyond read-only calls).
Authoritative spec: docs/refactor-evaluation.md, section "Phase 7 — Ops/recovery + dependency hygiene".
Work ONLY in the files you are told you own; other agents are concurrently
editing other files in this same working tree.
${CONSTRAINTS}
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 SPEC = {
type: 'object',
required: ['reprocessCli', 'contractTest', 'runbook', 'dependencyEdits', 'bundlingEdits', 'packageCleanup', 'verifyRules', 'notes'],
properties: {
reprocessCli: { type: 'string', description: 'the complete generalized scripts/reprocess.py design: argparse surface (--pipeline po|wo, --key/--prefix/--since, --all, --execute), per-pipeline function-name/bucket resolution, which modes are dry-run-by-default, and the EXACT --help + code-comment caveat text for --all (concurrency/no-serialize, RequestResponse-if-order-matters, double-count, Bedrock re-bill, out-of-order regression)' },
contractTest: { type: 'string', description: 'the synthetic-event-shape contract test: file path, what it asserts about Records[0].s3.bucket.name / object.key, and the exact raw-key / no-URL-decoding assertion (encoded vs raw key example)' },
runbook: { type: 'string', description: 'the docs/runbook-dlq-recovery.md outline: the no-redrive procedure steps (receive->key->targeted re-invoke via reprocess --key->verify->purge), the 14d-DLQ / 90d-S3 windows with the lifecycle-overrides-RETAIN note, the intentional sender-auth + ai_fallback_rejected non-DLQ statement, and the exact README alarms-section link edit' },
dependencyEdits: { type: 'string', description: 'per requirements.txt: exact new contents (both email-processor empty-with-comment, wo/web_ui empty-with-comment, po/web_ui + po/site_extractor new pinned manifests, tests/ moto==<version>); the resolved moto version; the exact dependabot.yml additions matching the existing entry shape' },
bundlingEdits: { type: 'string', description: 'both email-processor bundling command strings BEFORE and AFTER, verbatim: the cp-only result, proof that the pip step is safely removable (no third-party dep remains), and confirmation the exclude lists + widened root + any Phase-3 shared cp line are preserved' },
packageCleanup: { type: 'string', description: 'the package/ dir cleanup: the exact rm command, why it is a filesystem rm not git rm (untracked), and why it is asset-hash-neutral given the Phase 2 exclude — this is a REPORTED local step, not a commit' },
verifyRules: { type: 'string', description: 'how Verify judges success: which pytest tests must pass (incl. the new contract test), ruff scope now including scripts/, both cdk synth expectations, the dependabot yaml-parse check, requirements resolve/import check, and the runtime import-boto3 proof for the cp-only assets' },
notes: { type: 'string' },
},
}
const IMPL = {
type: 'object',
required: ['filesChanged', 'summary', 'checksRun'],
properties: {
filesChanged: { type: 'array', items: { type: 'string' } },
summary: { type: 'string' },
checksRun: { type: 'string' },
blockers: { type: 'array', items: { type: 'string' } },
},
}
const CHECKS = {
type: 'object',
required: ['passed', 'details'],
properties: {
passed: { type: 'boolean' },
details: { type: 'string' },
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' },
evidence: { type: 'string' },
fix: { type: 'string' },
},
},
},
},
}
// ------------------------------------------------------------------- setup
phase('Setup')
const setup = await agent(`
In ${REPO}:
1. SEQUENCING GATE — Phase 2 must be on ${BASE}. Phase 7 "can run parallel
to 4-6" (largely independent), BUT the cp-only bundling simplification and
the package/ cleanup touch the same cdk bundling that Phases 2/3
established, so this phase must NOT race Phase 2. git fetch origin, then
pick the base ref: origin/${BASE} if that remote ref exists, otherwise the
local branch ${BASE} (a stacked local-only base is expected and fine).
Verify on the base ref that BOTH cdk/po_stack.py and cdk/wo_stack.py
contain Code.from_asset("../lambdas") for the email processors
(git show <baseref>:cdk/po_stack.py | grep -n '\\.\\./lambdas', same for
wo_stack.py). If either is missing, STOP with a blocker naming Phase 2 as
unmet and do nothing else.
NOTE (report, do not block): Phase 7 is otherwise independent of Phases
4-6. If Phase 4 (cdk/common.py dedup) lands on ${BASE} FIRST, the
stack-touching bundling edits here must be rebased onto the moved bundling
code — flag that in your facts so the implement agents know whether the
bundling lives inline or in a common helper.
2. Verify clean working tree (untracked .coverage / .claude/ / the local
44 MB lambdas/po/email_processor/package/ dir are fine; any OTHER dirt =
blocker, never stash or discard).
3. git checkout ${BASE}; then git pull --ff-only ONLY if the branch has an
upstream (a local-only base skips the pull — not a blocker); then
git checkout -b ${BRANCH}
4. gh pr list --state open --json number,title,headRefName (overlap check —
especially any in-flight Phase 2/3/4 stack PR).
Return facts: HEAD sha, Phase 2 gate evidence, whether Phase 4 has landed
(bundling inline vs helper), open PRs, blockers.
`, { label: 'setup:branch', model: 'haiku', schema: RECON })
if (!setup || (setup.blockers && setup.blockers.length)) {
return { aborted: 'setup blockers', blockers: setup ? setup.blockers : ['setup agent died'], facts: setup ? setup.facts : [] }
}
log(`Branch ${BRANCH} ready off ${BASE}. ${setup.summary}`)
// ------------------------------------------------------------------- recon
phase('Recon')
const recon = await parallel([
() => agent(`${PREAMBLE}
Read-only recon of scripts/reprocess.py and the event shape it must pin:
1. Quote scripts/reprocess.py verbatim with file:line — the argparse surface
today (--execute only), FUNCTION_NAME, get_bucket_name (po-only,
po-ingest-emails-<acct>), list_inbound_keys (paginator, Prefix='inbound/'),
build_s3_event (the exact Records/s3/bucket.name/object.key shape), the
Event (async) InvocationType.
2. Find the WO analogues so --pipeline wo can resolve: the workorder
email-processor function name and its email bucket naming (grep the cdk
stacks / function_name / bucket_name). State them exactly.
3. Determine how the email-processor handler reads the key from an S3 event:
does it URL-decode Records[].s3.object.key (urllib.parse.unquote_plus)?
Quote the handler line. This decides the contract test's no-double-decode
assertion (reprocess must emit the RAW key so the handler decode path is
exercised exactly once — a pre-decoded key double-decodes '+'/'%20').
4. Is there ANY existing test that touches scripts/reprocess.py? (grep tests/
and scripts/). Where should the new synthetic-event contract test live so
it is collected by the standard pytest run (repo-root pytest collects
tests/ and both email_processor tests/ roots)? Note whether scripts/ is on
sys.path for import.
15-25 precise facts.`,
{ label: 'recon:reprocess', model: 'sonnet', phase: 'Recon', schema: RECON }),
() => agent(`${PREAMBLE}
Read-only recon of the dependency / manifest / dependabot state:
1. Quote every requirements.txt under lambdas/ and tests/ verbatim with path:
lambdas/po/email_processor (boto3>=1.43.47), lambdas/wo/email_processor
(boto3>=1.43.47), lambdas/wo/web_ui (boto3>=1.43.47 — dead manifest),
tests/ (moto>=5.0.0), cdk/. Confirm po/web_ui and po/site_extractor have
NO requirements.txt (they must be CREATED + pinned before a dependabot
entry is not a no-op).
2. Resolve the moto version to exact-pin: what 5.x is actually installed /
currently resolves (pip show moto or the lockfile / venv)? State the exact
version string to pin.
3. Quote .github/dependabot.yml verbatim: the existing pip entries (/cdk,
/lambdas/po/email_processor, /lambdas/wo/email_processor, /lambdas/wo/web_ui)
and the github-actions entry — the exact shape (weekly interval,
minor-and-patch group) the three NEW entries must match.
4. Confirm via lambda-template.md (handbook) the exact empty-with-comment
form for only-boto3 functions (the Lambda runtime provides boto3) — quote
the canonical comment wording so the edits match the handbook.
5. Does anything under lambdas/*/email_processor actually import a THIRD-PARTY
package other than boto3 (grep imports; anthropic/requests/etc.)? If yes,
cp-only is unsafe — that is a blocker for constraint 5.
15-25 facts.`,
{ label: 'recon:dependencies', model: 'sonnet', phase: 'Recon', schema: RECON }),
() => agent(`${PREAMBLE}
Read-only recon of the post-Phase-2 CDK bundling + the package/ dir:
1. Quote BOTH email-processor Code.from_asset blocks verbatim with current
file:line (cdk/po_stack.py ~241, cdk/wo_stack.py ~240): asset root, the
FULL exclude list, the entire bundling command list — the pip install
'--platform manylinux2014_aarch64 --only-binary=:all: -r
<pipeline>/email_processor/requirements.txt -t /asset-output' step, the
'&&', the 'cp <pipeline>/email_processor/*.py /asset-output/' step, and
whether a Phase-3 'cp shared/*.py /asset-output/' line is already present
on this branch's base. Note the explanatory NOTE comment in po_stack.
2. State the exact cp-only rewrite for each (drop the pip step entirely,
keep every cp line + the widened root + the exclude list). Confirm the
pip step is the ONLY thing removed.
3. The three plain from_asset calls (po/web_ui, po/site_extractor,
wo/web_ui) — quote them; they are NOT bundling-simplified here (no pip
step), just confirm they are untouched by this phase.
4. lambdas/po/email_processor/package/ — du -sh it, confirm it is UNTRACKED
(git status / git ls-files must not list it), and confirm the Phase 2
'**/package/**' exclude already keeps it out of the asset hash (so its
removal is asset-hash-neutral).
5. If Phase 4 has landed (bundling moved into a cdk/common helper) note the
new location.
12-20 facts.`,
{ label: 'recon:cdk-bundling', model: 'haiku', phase: 'Recon', schema: RECON }),
() => agent(`${PREAMBLE}
Read-only AWS + docs recon for the runbook (region us-east-1, READ-ONLY):
1. Both email-processor DLQs: exact SQS queue names/ARNs and the
MessageRetentionPeriod (confirm the ~14-day breadcrumb window). Find them
via the stacks (dead_letter_queue / make_processor_dlq) and
aws sqs get-queue-attributes. Also the async event-invoke config /
on-failure destination if present (get-function-event-invoke-config) —
confirm it is an async on-failure destination DLQ with NO console
redrive-to-source.
2. Both raw-email S3 buckets: exact names and the inbound/ prefix lifecycle
rule (get-bucket-lifecycle-configuration) — confirm the ~90-day expiry
that overrides the table RETAIN policy (S3 is the replay floor).
3. The relevant CloudWatch alarm names the runbook references: the
sender-auth-rejected alarm(s) and the ai_fallback_rejected alarm(s) per
pipeline (describe-alarms / grep the stacks) — so the runbook states that
those two drop classes are fail-closed SKIPS that never reach the DLQ.
4. Quote the README alarms section (its heading + surrounding lines) so the
Implement agent can add the runbook link in the right place, matching
style.
Return names/ARNs/windows as facts; flag any name you could not resolve so
the runbook can be validated against real infra later.`,
{ label: 'recon:dlq-alarms', model: 'sonnet', phase: 'Recon', schema: RECON }),
])
const reconOk = recon.filter(Boolean)
const pack = reconOk.map(r => `## ${r.summary}\n${r.facts.join('\n')}`).join('\n\n')
const reconBlockers = reconOk.flatMap(r => r.blockers || [])
.filter(b => b && !/^\s*(none|n\/a)\b/i.test(b))
log(`Recon complete: ${reconOk.length}/4 mappers, ${reconBlockers.length} blockers`)
if (reconBlockers.length) {
return { aborted: 'recon blockers (likely a surviving third-party dep blocking cp-only, or an unresolvable pipeline name)', blockers: reconBlockers, reconPack: pack }
}
// -------------------------------------------------------------------- spec
phase('Spec')
const spec = await agent(`${PREAMBLE}
You are the SPEC agent — the single authority that pins every contested
decision BEFORE parallel implementation (parallel leaves cannot see each
other's choices). Using the recon pack below plus your own reads of the
actual files, produce the binding implementation spec:
- reprocessCli: the complete generalized scripts/reprocess.py — argparse
surface (--pipeline po|wo required; --key / --prefix / --since targeted
modes; --all for full-prefix; --execute preserving dry-run-by-default),
the per-pipeline function-name + bucket resolution table, and the EXACT
--help + in-code caveat text for --all (all five caveats from constraint 1
verbatim). TARGETED is the default path; --all is the ONLY way to sweep the
whole prefix.
- contractTest: the synthetic-event-shape test — file path (collected by the
standard pytest run), the assertions on Records[0].s3.bucket.name and
object.key, and the raw-key / no-URL-decoding assertion with a concrete
encoded-vs-raw example (e.g. a key with a space or '+').
- runbook: the full docs/runbook-dlq-recovery.md outline with the real
DLQ/queue/alarm/bucket names from recon, the no-redrive procedure steps
(receive -> extract S3 key from event body -> targeted re-invoke via
reprocess.py --key -> verify -> purge), the 14d-DLQ / 90d-S3 windows +
lifecycle-overrides-RETAIN note, the intentional sender-auth +
ai_fallback_rejected non-DLQ statement, and the exact README alarms-section
link edit.
- dependencyEdits: exact new contents of every manifest — both
email-processor requirements.txt (handbook empty-with-comment), wo/web_ui
(empty-with-comment), po/web_ui + po/site_extractor (NEW; decide pinned dep
vs whether the dependabot entry is worthwhile — a dependabot entry needs at
least one pinned dep to act on, so pin boto3 exactly if you want the entry
live), tests/ (moto==<resolved version> from recon) — plus the exact
dependabot.yml additions (three entries) matching the existing shape.
- bundlingEdits: both email-processor bundling command strings BEFORE and
AFTER, verbatim — the cp-only result, an explicit proof line that no
third-party dep remains (so removing the whole pip step is safe, NOT a
regression of the manylinux pin), and confirmation the exclude lists +
widened '../lambdas' root + any Phase-3 'cp shared/*.py' line survive
untouched. If Phase 4 moved bundling into a helper, target that location.
- packageCleanup: the exact rm -rf command for
lambdas/po/email_processor/package/, the note that it is a filesystem rm
(UNTRACKED — never git rm) performed and REPORTED by the Package agent, not
committed, and why it is asset-hash-neutral (Phase 2 exclude).
- verifyRules: exactly how Verify judges success — the pytest set incl. the
new contract test, ruff now INCLUDING scripts/, both cdk synth,
dependabot.yml yaml-parse, requirements resolve/import, and the runtime
'import boto3' proof for a cp-only staged asset.
Recon pack:\n${pack}`,
{ label: 'spec:pin-ops-recovery', phase: 'Spec', schema: SPEC })
if (!spec) return { aborted: 'spec agent died — rerun workflow', reconBlockers }
const specBlock = `BINDING SPEC (from the spec agent — implement EXACTLY this):\n${JSON.stringify(spec, null, 2)}`
log('Spec pinned: reprocess CLI + caveats, contract test, runbook, dependency edits, cp-only bundling, package cleanup')
// --------------------------------------------------------------- implement
phase('Implement')
const impl = await parallel([
() => agent(`${PREAMBLE}
YOU OWN: scripts/reprocess.py and the new synthetic-event contract test file
ONLY (per spec.contractTest's path — do not touch any other test). Do not
touch cdk/, requirements, dependabot, docs, or README.
Task: rewrite scripts/reprocess.py per spec.reprocessCli — add --pipeline
po|wo, --key/--prefix/--since (TARGETED, the default), --all (DEMOTED
full-prefix, with all five caveats in BOTH --help and code comments),
preserving dry-run-by-default / --execute. Keep build_s3_event emitting the
RAW key (no URL-decoding). Then add the contract test per spec.contractTest
pinning the event shape + the raw-key / no-double-decode assertion.
Run before returning: ruff check scripts && ruff format scripts --check,
python3 scripts/reprocess.py --help (must render the caveats), and
pytest -q --no-cov -k <the contract test> (tests land in parallel — if
collection of other roots is red from another agent's in-flight edit, run
your test file directly; poll up to ~10 min before reporting a blocker).
${specBlock}`,
{ label: 'impl:reprocess', model: 'opus', phase: 'Implement', schema: IMPL }),
() => agent(`${PREAMBLE}
YOU OWN: every requirements.txt (lambdas/po/email_processor,
lambdas/wo/email_processor, lambdas/wo/web_ui, tests/, and the two NEW
manifests lambdas/po/web_ui/requirements.txt +
lambdas/po/site_extractor/requirements.txt), .github/dependabot.yml, and the
bundling regions of cdk/po_stack.py + cdk/wo_stack.py ONLY (only the
email-processor from_asset bundling command — alarms, IAM, tables, env,
memory, timeout, the three plain from_asset calls are all off-limits).
Task: apply spec.dependencyEdits (drop vendored boto3 -> empty-with-comment
in both email-processor + wo/web_ui; create the two pinned po manifests;
exact-pin moto==) + the three dependabot entries + spec.bundlingEdits
(cp-only: remove ONLY the pip step from both email-processor commands,
preserving the exclude lists, widened root, and any Phase-3 shared cp line).
Do NOT delete package/ (that is the Package agent's reported step).
Run before returning: ruff check cdk, python3 -c 'import yaml,pathlib;
yaml.safe_load(pathlib.Path(".github/dependabot.yml").read_text())' (parses),
cd cdk && npx cdk synth po-ingest -q -o /tmp/phase7-synth &&
npx cdk synth workorder-ingest -q -o /tmp/phase7-synth (artifact-id
selectors). Docker bundling runs — confirm each staged email-processor asset
still contains handler.py + its siblings (and shared/*.py if Phase 3 landed)
and that 'import boto3' resolves at runtime from the Lambda runtime copy (the
staged asset need not vendor it). Note asset hashes. If the other agents'
edits have not landed the synth is unaffected (bundling is yours) — but poll
~10 min if Docker is slow before a blocker.
${specBlock}`,
{ label: 'impl:deps-bundling', model: 'sonnet', phase: 'Implement', schema: IMPL }),
() => agent(`${PREAMBLE}
YOU OWN: docs/runbook-dlq-recovery.md (NEW) and README.md ONLY.
Task A: write docs/runbook-dlq-recovery.md per spec.runbook — the no-redrive
async-destination DLQ procedure (receive-message -> extract the S3 key from
the event body -> targeted re-invoke via 'python scripts/reprocess.py
--pipeline <po|wo> --key <key> --execute' -> verify the write -> purge the
message), the real queue/bucket/alarm names from the spec, the recovery
windows (14-day DLQ breadcrumb, 90-day raw-email S3 with the
lifecycle-overrides-RETAIN note), and the explicit statement that sender-auth
rejections AND ai_fallback_rejected drops are fail-closed SKIPS that never
reach the DLQ (no retry, no DLQ message).
Task B: README — link the new runbook from the alarms section per
spec.runbook's exact edit, and note reprocess.py is now pipeline-general
(--pipeline / --key / --prefix / --since targeted; --all demoted). Match
existing README style.
Run before returning: confirm the runbook links resolve and the README edit
is in the alarms section. (No pytest owned by you; if you want a doc-lint,
ruff does not lint .md.)
${specBlock}`,
{ label: 'impl:runbook-readme', model: 'opus', phase: 'Implement', schema: IMPL }),
])
const implOk = impl.filter(Boolean)
const implBlockers = implOk.flatMap(r => r.blockers || [])
log(`Implement complete: ${implOk.length}/3 agents, blockers: ${implBlockers.length}`)
// ---------------------------------------------------- verify + fix loop
const EXPECTED_SCOPE = [
'scripts/reprocess.py',
'tests/',
'docs/runbook-dlq-recovery.md',
'lambdas/po/email_processor/requirements.txt',
'lambdas/wo/email_processor/requirements.txt',
'lambdas/wo/web_ui/requirements.txt',
'lambdas/po/web_ui/requirements.txt',
'lambdas/po/site_extractor/requirements.txt',
'.github/dependabot.yml',
'cdk/po_stack.py',
'cdk/wo_stack.py',
'README.md',
]
const mechanicalPrompt = `${PREAMBLE}
Independent re-verification — trust nothing self-reported. Run ALL gates,
quoting failures verbatim:
1. pytest -q --no-cov (repo root, all roots green) — INCLUDING the new
synthetic-event contract test (name it and quote its pass line).
2. ruff check . && ruff format --check . — scripts/ is now IN scope (it was
previously omitted); confirm scripts/reprocess.py lints and formats clean.
3. cd cdk && npx cdk synth po-ingest -q && npx cdk synth workorder-ingest -q
(Docker bundling runs; both must succeed).
4. cd cdk && npx cdk diff po-ingest ; npx cdk diff workorder-ingest —
constraint 9: the ONLY resource delta allowed is the two email
processors' Code/S3Key (asset) + CDK metadata. Any alarm/IAM/env/runtime/
memory/timeout/handler-prop/logical-ID delta = FAIL. Paste the diff
summaries.
5. DEPENDABOT: python3 -c 'import yaml,pathlib; d=yaml.safe_load(pathlib.Path(".github/dependabot.yml").read_text()); print([u["directory"] for u in d["updates"]])'
— the three new dirs (/tests, /lambdas/po/web_ui, /lambdas/po/site_extractor)
present, shape matches existing entries.
6. MANIFESTS: every email-processor + wo/web_ui requirements.txt is the
empty-with-comment form (no pinned third-party dep); tests/requirements.txt
is moto==<exact>; po/web_ui + po/site_extractor manifests EXIST and are
PINNED (a dependabot entry pointed at a floor/absent manifest is a no-op —
FAIL if unpinned). pip install --dry-run (or pip-compile check) resolves
each without error.
7. RUNTIME IMPORT: no lambda handler .py under lambdas/*/email_processor
changed (git diff ${BASE}...HEAD -- must be empty for those); 'import boto3'
still resolves — prove the Lambda runtime copy suffices (the cp-only staged
asset need not vendor boto3).
8. RUNBOOK: docs/runbook-dlq-recovery.md exists, states the 14d/90d windows,
the no-redrive procedure, and the sender-auth + ai_fallback_rejected
non-DLQ note; README alarms section links it.
9. git status --porcelain scope check: every modified/added path under
${EXPECTED_SCOPE.join(', ')} (untracked .coverage/.claude/package/
tolerated; package/ must NOT be staged).
passed=true only if all green. YOU MAY NOT edit files.`
const lenses = [
{ key: 'bundling-integrity', prompt: `${PREAMBLE}
ADVERSARIAL REVIEW — bundling-integrity lens. The bundling was simplified to
cp-only and vendored boto3 was dropped; try to prove a runtime ImportError
was introduced (the PR #34 / PR #105 failure class). (1) cd cdk && synth both
stacks; inside each staged email-processor asset dir list the files and run
python3 -c "import handler" with that dir alone on sys.path (env-var stubs as
needed) — does every runtime-imported module still ship (handler siblings +
shared/*.py if Phase 3 landed)? (2) Does 'import boto3' succeed against the
Lambda runtime copy — i.e. is boto3 genuinely NOT needed in the zip, or does
some code path need a newer boto3 than the runtime ships? (3) Are the Phase
2/3 excludes (['**/__pycache__/**','**/tests/**','**/package/**']) STILL
present and is the widened '../lambdas' root intact? (4) Was the manylinux
pin removed ONLY as part of removing the whole pip step (because nothing is
installed), NOT while keeping a pip install? A cp-only command that still
tries to pip-install without the pin, or a surviving third-party dep, is a
critical finding. (5) DETERMINISM: synth po-ingest twice into fresh -o dirs —
identical asset hashes. confirmed=true only with a concrete ImportError
sketch or file:line proof.` },
{ key: 'reprocess-safety', prompt: `${PREAMBLE}
ADVERSARIAL REVIEW — reprocess-safety lens. Attack the generalized
reprocess.py. (1) Is the DEFAULT behavior TARGETED (—key/—prefix/—since), and
is it IMPOSSIBLE to sweep the whole inbound/ prefix without the explicit
--all flag? Trace the arg parsing — can a missing/empty --prefix silently
fall through to a full-prefix replay? That would be a critical finding
(accidental mass re-invoke = mass Bedrock re-bill + merged-field regression).
(2) Are ALL FIVE --all caveats present in BOTH --help and code comments
(concurrency/no-serialize, RequestResponse-if-order-matters, double-count,
Bedrock re-bill, out-of-order regression)? (3) Does build_s3_event emit the
RAW key with NO URL-decoding, and does the contract test actually FAIL if
someone adds an unquote_plus (mutate it on a scratch copy and confirm red)?
A key with a space/'+' must round-trip raw so the handler decodes exactly
once. (4) --pipeline wo resolves the correct function name + bucket (not the
PO defaults)? confirmed=true only with file:line or reproduced-failure
evidence.` },
{ key: 'dependency', prompt: `${PREAMBLE}
ADVERSARIAL REVIEW — dependency lens. (1) moto: is tests/requirements.txt
EXACT-pinned (moto==<version>), not a floor (>=)? A floor makes the tests/
dependabot entry a no-op. (2) The three NEW dependabot dirs: do
/lambdas/po/web_ui and /lambdas/po/site_extractor now have PINNED manifests
that actually exist? A dependabot entry pointed at an absent or floor-pinned
manifest does nothing — that is a finding. Cross-check dependabot.yml
directory strings against the real file paths (a typo'd directory silently
no-ops). (3) The email-processor + wo/web_ui requirements are the handbook
empty-with-comment form — confirm no pinned third-party dep is left that
would then require the pip step back (which would contradict the cp-only
bundling). (4) Does the empty-with-comment wording match lambda-template.md
(runtime provides boto3)? confirmed=true only with file:line 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 !== 'low')
const green = checks && checks.passed
log(`Verify round ${round}: mechanical ${checks && checks.passed ? 'GREEN' : 'RED'}, confirmed findings: ${confirmed.length}`)
if (green && confirmed.length === 0) break
round += 1
if (round >= 3) break
phase('Fix')
await agent(`${PREAMBLE}
You are the fix agent — you may edit files under: ${EXPECTED_SCOPE.join(', ')}.
Fix EVERY item below minimally; the binding spec and 10 pinned constraints
still hold (a finding that conflicts with a constraint is reported, not
"fixed" — the constraint wins, esp. constraint 5's cp-only-only-if-no-dep
rule, constraint 8's do-NOT-git-rm-package, and constraint 9's no-handler /
no-alarm change). Re-run the specific failing gate/test per fix.
MECHANICAL:\n${checks ? checks.details : '(agent died — rerun all gates)'}
CONFIRMED FINDINGS:\n${JSON.stringify(confirmed, null, 2)}
${specBlock}`,
{ 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,
spec,
}
}
// ----------------------------------------------------------------- package
phase('Package')
const commit = await agent(`${PREAMBLE.replace('do NOT commit, ', '')}
YOU are the commit agent:
1. Read ~/Documents/repositories/seahaven/engineering-handbook/commit-messages.md
and follow it exactly.
2. OPTIONAL LOCAL CLEANUP (constraint 8): rm -rf
lambdas/po/email_processor/package/ — it is UNTRACKED, so this is a
filesystem rm, NOT git rm; there is NOTHING to stage from it and it is
asset-hash-neutral (Phase 2 exclude). PERFORM it and REPORT the reclaimed
~44 MB in your summary. It is NOT part of the commit.
3. git add only paths under: ${EXPECTED_SCOPE.join(', ')} and
.claude/workflows/phase-7-ops-recovery.js. NOT .coverage, NOT package/
(it is gone / untracked either way). Verify the staged set with
git status — the two NEW manifests and the new runbook MUST be staged.
4. ONE commit; write the message to /tmp/phase7-commit-msg.txt and use
git commit -F /tmp/phase7-commit-msg.txt (backticks in -m get eaten by
zsh). Suggested subject:
"feat: ops/recovery tooling + dependency hygiene — generalized reprocess, DLQ runbook, cp-only bundling (refactor phase 7)"
Body: the reprocess generalization (targeted-primary, --all demoted with
caveats) + contract test, the DLQ runbook + windows, the dropped vendored
boto3 / cp-only bundling, the moto exact-pin + three dependabot entries
with pinned manifests, and the reported (not committed) package/ cleanup.
NO AI attribution / Co-Authored-By lines.
5. Do NOT push. Return commit sha + shortstat + the package/ cleanup result
in summary.`,
{ label: 'package:commit', model: 'sonnet', phase: 'Package', schema: IMPL })
return {
status: 'BUILT — committed locally, NOT pushed',
branch: BRANCH,
base: BASE,
commit: commit ? commit.summary : 'commit agent died — commit manually',
spec: { reprocessCli: spec.reprocessCli, runbook: spec.runbook, dependencyEdits: spec.dependencyEdits, bundlingEdits: spec.bundlingEdits, packageCleanup: spec.packageCleanup },
implementation: implOk.map(r => r.summary),
filesChanged: implOk.flatMap(r => r.filesChanged),
verifyRounds: round + 1,
blockers: implBlockers.concat(reconBlockers),
outstandingGates: [
'/sh-security-review NOT required (ops tooling + dependency hygiene only — no untrusted-input parsing, no auth code, no IAM/policy change). The pre-push deterministic scanners still run as the unattended backstop.',
'cross-family cross_review.py NOT required (no IAM/policy change; no Lambda handler signature / event-shape / return-contract change — reprocess.py emits the SAME S3 event shape it always did, pinned by the new contract test).',
'push + PR + gh pr checks green',
'deploy-then-merge for the bundling/dep changes: deploy from branch, smoke green, one real email per pipeline (the dropped-vendored-boto3 email processors MUST still import boto3 at runtime from the Lambda runtime copy), expect ONE benign asset-hash redeploy from the package/ cleanup + cp-only change, THEN merge.',
'reprocess.py + docs/runbook-dlq-recovery.md are ops/docs (no deploy risk), but the runbook should be VALIDATED against the real DLQ / alarm / bucket names before it is relied on in an incident.',
'Confluence "AWS Architecture Map" — Phase 7 adds the DLQ-recovery runbook to the inventory; note the new runbook when the resource inventory is next refreshed.',
],
}