* 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.
39 KiB
procurement-ingest — Refactor Evaluation Report
Date: 2026-07-16 · Branch audited: feature/po-derived-classifier · Findings: 71 (55 confirmed by verification pass, 16 unverified) · Suite: 568 passed / 0 failed / 1.47s
1. Executive summary
Verdict: a full rewrite is NOT warranted; a staged consolidation refactor IS — and one security-parity gap must land before (or as the first phase of) any restructuring.
The core ingest path is in good shape: template-first parsers with fail-closed gates, 93% coverage on the tested tree, 0 ruff violations, clean CI. The problems are structural, not functional:
- Security drift between twin pipelines (fix first, not a refactor). The AI-fallback hardening from #104 (XML-delimited prompt, tag neutralization,
validate_ai_fallbackfail-closed gate,ai_fallback_rejectedmetric + alarm) exists only in WO. PO'sextract_with_claudesends the raw untrusted body bare after the prompt and routes unvalidated LLM output straight tosave_cancellation/save_new_po— a DKIM-passing injected{"email_type":"cancellation"}sticky-cancels a live PO. This is the canonical drifted-copy failure and the strongest argument for consolidation. - Duplication with no shared home.
ses_auth.pyis a byte-identical 452-line copy (sync policed by a hand-parameterized test fixture); the web_ui auth gate is a byte-identical ~61-line block; the EMF emitter is copied 3×; ~379 identical lines sit between the two CDK stacks. The handbook'slambdas/shared/location is unused, blocked today by per-functionCode.from_assetroots that can't reach it. - The deploy pipeline cannot catch the refactor's main failure mode. The hand-maintained
cpallowlist in po_stack bundling has already caused production ImportErrors (PR #105); deploys run zero tests; cd-cdk's pre-flight/health-check steps are dormant (no stack-name passed); detection of a broken bundle is traffic-dependent. Guards must land before any packaging change.
Shape: ~11 PR-sized phases over the sequence guards → security parity → bundling-root move → shared-code extraction → CDK dedup → handler decomposition → site_extractor reconciliation → test/doc consolidation. Every phase is independently deployable and revertible per deploy-then-merge. Rough scope: ~2–3 weeks of focused work; phases 0–3 are the high-value 20% that eliminates the drifted-copy failure class.
Hard constraints respected throughout: fail-closed gates unchanged or strengthened (never weakened); shadow telemetry observe-only (no derived_fields behavior change until the bake concludes); ParseMethod EMF / fallback-alarm contracts preserved (with the two deliberate per-pipeline emission-ordering differences pinned by tests, not accidentally "fixed"); all CDK changes either provably logical-ID-safe or gated on a zero-change cdk diff.
2. Current-state assessment (measured)
Tests
| Root | Tests |
|---|---|
tests/ (cross-pipeline) |
117 |
lambdas/wo/email_processor/tests/ |
143 |
lambdas/po/email_processor/tests/ |
308 |
| Total | 568 passed, 0 failed, 1.47s |
pytest.iniis the only config;test_local.pydeliberately excluded (imports handler → boto3 clients at collection). CI runs barepytestat repo root so all three roots are collected; no coverage step in CI.- Three divergent module-loading mechanisms (tests/conftest importlib loader with sys.modules save/restore;
_po_parser_support.pyindependent importlib reimplementation + load-bearingimport motoordering;_wo_parser_support.pybare sys.pathimport handler) exist solely because both pipelines duplicate bare module names.
Coverage (pytest-cov 7.1.0, Python 3.12.13)
| Module | Coverage |
|---|---|
| po/derived_fields | 95% (296 stmts) |
| po/handler | 96% (186) |
| po/ses_auth · wo/ses_auth | 94% (223 each) |
| po/template_parser | 89% (494) |
| wo/handler | 96% (139) |
| wo/template_parser | 95% (274) |
| Tested-tree total | 93% (3,356 stmts) |
| po/web_ui, wo/web_ui, po/site_extractor, scripts/ | 0% — 540 stmts, zero test references |
| True all-first-party coverage | ~80% (3,896 stmts, 777 miss) |
Gotcha: naive --cov=lambdas silently omits web_ui/site_extractor (missing __init__.py) — the 93% headline overstates reality.
Duplication (difflib line-level, autojunk off)
| Pair | Similarity |
|---|---|
| ses_auth.py (po vs wo) | 100% — byte-identical, 452 lines |
| web_ui/handler.py | 59.6% (212 common lines; auth block byte-identical) |
| cdk/po_stack.py vs wo_stack.py | 59.9% (379 identical lines measured) |
| email_processor/handler.py | 30.9% |
| template_parser.py | 15.4% (structurally parallel, legitimately divergent) |
Lint
- ruff 0.15.12:
ruff check .→ 0 violations;ruff format --check→ 135 files clean. - Caveat: there is no ruff config file, so PLR/C901 are never selected — the three
noqa: PLR09xxsuppressions on_validate_new_po_valuesare inert, andextract_new_po(C901=35) passes lint for the same reason. "0 violations" reflects default rules only.
Dead weight
- Untracked 44 MB
lambdas/po/email_processor/package/— pre-Bedrock vendored anthropic SDK + stale handler. Not in any build; copied into cdk.out staging on every synth.
3. Refactor plan — phased PR sequence
Ordering principle: guards first → byte-identical consolidation → behavior-preserving extraction → structural moves last. Each phase deploys and verifies (deploy-then-merge per git-workflow.md) before the next merges. Rollback at every step: git revert + push (auto-redeploy ~5–10 min) or local redeploy of previous commit; dropped mail recoverable via DLQ (14 d) and S3 inbound/ replay (90 d).
Phase 0 — Deploy-pipeline guards (PR-0) · effort: S · risk: low
Goal: make a broken bundle fail the deploy job, not Monday's first email.
- Both handlers: healthcheck early-return branch (
{"healthcheck": true}→ ok) placed before S3 fetch and SES-auth so it neither creates an accept path nor trips thesender_auth_rejectedsubstring metric-filter alarm (which pages at ≥1 reject, 2-of-6 × 5 min — two deploys in ~30 min would false-page otherwise). scripts/post-deploy-smoke.sh: synchronous invoke (RequestResponse) of both processors, checking theFunctionErrorresponse field (init ImportError returns HTTP 200 +FunctionError=Unhandled— exit-code checks false-pass). Wire via cd-cdk'spost-deploy-scriptinput (stack-name input takes only one stack; this repo has two).- Replace PO's four-file
cpallowlist withcp ./*.py /asset-output/(identical output today; non-recursive glob excludes tests/). - CI-time ast consistency test: extract handler.py's first-party sibling imports, assert each ships in the bundle. Runs in the existing pytest step, before synth.
- Gates: deploy, smoke green, one real PO + WO email each showing
ParseMethod=template, all alarms green. Handler touch = untrusted-input surface →/sh-security-reviewmandatory; new event field in handler → run cross-family review (cross_review.py) for the handler-contract change.
Phase 1 — PO AI-fallback security parity (port of #104) · effort: M · risk: medium
Goal: close the highest-impact confirmed finding; make pipelines structurally symmetric.
lambdas/po/email_processor/: add PO-specificvalidate_ai_fallback(NOT a WO copy — PO contract is nested:CONTRACT_KEYSexact key-set with missing-key normalization,po_numbervs_PO_ID_RE(protects the DynamoDB partition key built at handler.py:523/621),email_type∈ {new_po, revision, cancellation}, Decimal/int/None money types since PO parses withparse_float=Decimal). Call immediately afterextract_with_claude, beforeenrich_parsed/dispatch; on failure emitParseMethod=ai_fallback_rejectedthencontinue(skip, never raise — avoids DLQ churn on attacker-controlled input).- Port
<email>data-block wrapping +_EMAIL_TAG_REtag neutralization +temperature=0intoextract_with_claude. - Preserve the deliberate PO metric ordering: PO emits
ai_fallbackBEFORE the Bedrock call (so Bedrock-side errors still record the outcome — handler.py:656-669). Do not move it.ai_fallback_rejectedis an additive second datapoint on rejection; document the intentional double-count. cdk/po_stack.pyin the same PR: (a) foldFILL(rej,0)into the existing fallback-rate MathExpression numerator+denominator inside the IF volume floor (in-place property update to the existing alarm logical ID — safe; respect the post-#102 no-element-wise-MAX rule at po_stack.py:416-418); exclude the rejected series from the rate numerator or account for the pre-call double-count — do not copy WO's fb+rej math verbatim, it double-counts PO rejections. (b) net-newEmailProcessorAiFallbackRejectedAlarm— retune for ~57 emails/day (WO's 5-min/30-min sparse idiom is structurally dead at PO volume; use 1h–6h periods like the existing PO fallback-rate retune).- Tests (mirroring WO one-for-one): non-dict model output, injected po_number (
123#x, fullwidth digits), injected email_type (assert no save_* AND no misroute intosave_new_povia the else branch), injected status, XML-wrap, forged-tag neutralization, linear-time regex. - README: fix the false "Data is never corrupted" PO claim (line 29) and add
ai_fallback_rejectedto the PO ParseMethod list (line 145). - Gates:
/sh-security-review(untrusted-input, mandatory), full suite,npx cdk synth po-ingest, deploy-then-merge with live-email verification of both metric series.
Phase 2 — Bundling-root move, no code move (PR-1 of migration) · effort: S · risk: medium (deploy-mechanics only)
Goal: make lambdas/shared/ reachable before anything moves into it.
- Both stacks:
Code.from_asset("../lambdas")withcommand: cp po/email_processor/*.py /asset-output/(resp. wo) andpip install -r po/email_processor/requirements.txt(the-rpath change is required — bundling cwd is the asset root). Addexclude=['**/__pycache__/**','**/tests/**','**/package/**']—from_assetdoes NOT honor .gitignore, and the stale 44 MBpackage/dir would otherwise diverge local vs CI asset hashes. - WO note: its current
cp -r .ships tests/ (real scrubbed .eml fixtures),__pycache__, and requirements.txt in the prod zip. Accept and document the file-list shrinkage as deliberate cleanup; verification criterion for WO is "runtime-imported module set unchanged + smoke", byte-identical zip diff applies to PO only. - Also add
excludeto the three plainfrom_assetcalls (po web_ui :502, site_extractor :581, wo web_ui :548) — local__pycache__currently makes their hashes nondeterministic. - Gates:
aws lambda get-functionzip file-list diff (PO identical; WO shrinkage reviewed), smoke, one real email per pipeline. Zero handler diff.
Phase 3 — lambdas/shared/ extraction (PR-2) · effort: M · risk: medium
Goal: one canonical copy per bare module name where copies are byte-identical or trivially superset-able.
- Create
lambdas/shared/(handbookcdk-project-layout.md). Move in dependency-risk order:ses_auth.py(byte-identical; the prime target — security-critical auth that currently requires every hardening fix to land twice). Addcp shared/*.py /asset-output/to both bundling commands; module lands flat sofrom ses_auth import authenticate_inbound_emailis unchanged — zero handler diff keeps fail-closed auth byte-identical.web_ui_auth.py: the byte-identical block (_get_auth_token/_header/is_authenticated+ the four cache globals). The web_ui functions have no bundling today — add the same staging mechanism. Keep the per-stack INFRA-74 comments in each handler (they've already drifted in wording; they're stack-specific).email_parsing.py:parse_raw_emailsuperset returningccunconditionally (PO ignores it — harmless).emf.py: generic emitter parameterized by namespace/dimension-sets/properties, serving all three hand-built_awsenvelopes (both ParseMethod emitters +_emit_derived_agreement_metric). Dimension-set list[["ParseMethod"],["ParseMethod","TemplateId"]]is load-bearing for the alarms; a shared function makes one-sided dimension fixes impossible.
- Do NOT move:
template_parser.py(990 vs 508 lines, genuinely divergent — stays per-pipeline in_SIBLING_MODULES), the Bedrock extraction functions (divergent contracts; share only the invocation/prompt-delimiting layer, parameterizingjson.loadskwargs,max_tokens2048/1024, prompt), per-pipelinevalidate_ai_fallbackgates. - Test plumbing in the same PR: update
_SIBLING_MODULESresolution,_po_parser_support.py:71, the fixture-hygiene test, drop theses_authfixture params (halves the 448-line test_ses_auth run). The sys.modules save/restore dance survives for template_parser — it does not shrink to nothing. - Gates: full suite, smoke, deploy-then-merge watching both sender-auth-rejected alarms through live mail. The post-merge CI redeploy being a no-op (unchanged asset hash) is itself a verification signal. Auth code moved →
/sh-security-review.
Phase 4 — cdk/common.py dedup · effort: M · risk: medium (logical-ID discipline)
Goal: collapse the 379 identical CDK lines.
- Extract as plain functions taking
(scope, id, ...)called with the SAME scope (the Stack) and SAME construct ids — 100% logical-ID-safe. Do NOT wrap in Construct subclasses: that inserts a tree node, changes every child logical ID, and would attempt replacement of the RETAIN-protectedpurchase-orders/WorkOrderstables and named buckets. - Contents:
_DDB_ALARM_OPERATIONS+add_ddb_alarms,add_sender_auth_rejected_alarm,add_standard_lambda_alarms(scope, id_prefix, fn, name_prefix, topic, *, duration_statistic, errors=True, dlq=None, descriptions=...)(variance to preserve: PO p99 vs wo-email-processor p95, po-web-ui throttles+duration only, site_extractor no-DLQ, wo web_ui has zero alarms — do not silently add any; bespoke description strings passed verbatim),make_bedrock_invoke_statement(derive the inference-profile ARN fromStack.account/Stack.regioninstead of hardcoding 328440206208),make_email_bucket,make_processor_dlq,make_fallback_rate_alarm(namespace, rejected_included, period, threshold, floor, evaluation_periods, datapoints_to_alarm)reproducing expression strings/FILL/labels byte-for-byte. WO's rejected alarm stays a WO-only call (until Phase 1's PO twin). - Do not import stack-specific services (kms/ssm/event_sources) into common.py.
- Gates:
cdk diffon BOTH stacks showing zero changes (the acceptance test); moving IAM PolicyStatement construction → mandatory cross-family review (cross_review.py) even though semantics are identical. - Same PR: add
account='328440206208'to bothcdk.Environmentcalls, pinconstructs==exact, fix the stale "2.259.0" comments, add CfnOutputs for the five function ARNs + consumed table names (net-new — ID-safe).
Phase 5 — Handler decomposition + lazy boto3 clients · effort: M · risk: medium
Goal: break the God-modules along the seams that already work (ses_auth/template_parser/derive_all prove the flat-sibling pattern).
- PO:
handler.py(event loop + auth + routing) /extraction.py(parse_raw_email shim, extract_with_claude, prompt import) /enrichment.py(enrich_parsed, pad_zip — PO-only, WO has no enrichment stage) /telemetry.py/persistence.py(_write_fields/_merge_update/save_*; collapse the byte-identicalsave_new_po/save_revisioninto one_save_merge). WO: ~5 concerns, not 7 — its split must keepvalidate_ai_fallback+ the[0-9]+work_order_id key guard in the handler loop ahead of both saves, and keep_header_date_iso/comment_id determinism with persistence. - Move
EXTRACTION_PROMPT(181/693 lines of po handler) toprompts.pywith cross-reference headers to derived_fields; keep a re-export since 4 tests dereferencehandler.EXTRACTION_PROMPT. - Lazy cached boto3 accessors in I/O modules; pure modules import no boto3. Update the
monkeypatch.setattr(handler, "dynamodb", fake)patch surface in the same change. Preserve the moto-before-handler import ordering (_po_parser_support.py:26-33) or moto-backed suites hit real AWS. - Behavior-preservation obligations stated per pipeline: PO metric before Bedrock call; WO metric after gate with mutually-exclusive
ai_fallback/ai_fallback_rejected; shadow telemetry ai_fallback-only. Bundling: the glob from Phase 0 already ships new siblings automatically; the ast test verifies. - Gates: full suite (goldens unchanged), smoke, one live email per pipeline. Handler decomposition keeps signatures — no cross-family review needed unless the event/return contract changes.
Phase 6 — site_extractor reconciliation · effort: M · risk: medium · timing: after the shadow bake concludes
Goal: end the three-way site_code contradiction (KLAL-class all-letter codes currently can NEVER self-register — permanent pending-review rows).
extract_site_codealready prefersrecord["site_code"](line 36) — the defect is the digit-requiringSITE_CODE_PATTERN.match(prefix-anchored, rejects KLAL, accepts overlong junk likeDLI6X). Fix: validate the direct field with the canonicalderived_fieldsshape + skip-list semantics (fullmatch), importderive_site_codefor the fallback path (shipderived_fields.pyinto the site_extractor asset via the Phase 2/3 mechanism), delete the bespoke regex ladder (line 62's[A-Z]{4,5}alternation is dead code). Keep shape/skip validation on the ai_fallback-sourced field rather than blind trust (LLM value is authoritative during the bake). Keep parse_address + pending-review flow as-is.- Repoint
scripts/backfill_sites.pyatderive_site_code(or delete it — one-time script). - Characterization tests FIRST (pin current behavior, including the KLAL divergence, before changing it) — see §4.
- Gates: new site_extractor test suite, deploy, watch pending-site-review write rate drop.
Phase 7 — Ops/recovery + dependency hygiene · effort: S · risk: low (can run parallel to 4–6)
- Generalize
scripts/reprocess.py:--pipeline po|wo,--key/--prefix/--since; targeted replay primary, full-prefix demoted behind--allwith documented caveats (Event-type invocation is concurrent → sorting doesn't serialize; switch to RequestResponse if order matters; metrics double-counted; Bedrock re-billed; out-of-order replay regresses merged fields). docs/runbook-dlq-recovery.md: async-destination DLQ has no redrive-to-source; procedure = receive-message → key from event body → targeted re-invoke → verify → purge. State the windows: 14 d DLQ breadcrumb, 90 d raw-email S3 (lifecycle overrides RETAIN). Document that sender-auth and ai_fallback_rejected drops intentionally never reach the DLQ. Link from README alarms section.- Dependency hygiene: drop vendored boto3 (both email-processor requirements → handbook empty-with-comment form; only-boto3 functions correctly use the runtime copy per
lambda-template.md); simplify bundling to cp-only; exact-pinmoto==and add/tests,/lambdas/po/web_ui,/lambdas/po/site_extractordependabot entries (pin first — floor pins make dependabot entries no-ops); reduce wo/web_ui's dead manifest to empty-with-comment. - Delete the 44 MB
package/dir (one benign asset-hash redeploy; do it before/with Phase 2's excludes).
Phase 8 — Test-root consolidation (last PR — validates the new boundaries) · effort: M · risk: low
See §4. Also the small correctness/doc items batched here: WO invalid_status reason-code fix (+ grep dashboards for malformed_site_code first), WO Bedrock-error metric fix (wrap the call: emit ai_fallback/bedrock_error in an except-and-reraise — NOT a naive reorder, which double-counts against wo_stack's "rejected emits nothing else" alarm contract), _validate_new_po_values per-rule split (V4 anchor-frame dataclass must carry summary_matches/price for V13; add a ruff config enabling PLR/C901 or the noqa-drop is meaningless; include extract_new_po C901=35), docstring/README drift batch (findings 33–38 appendix refs).
4. Test-hardening plan
Target layout — resolving the two-roots question
Keep both roots; fix the loading. The split itself is principled (per-lambda golden suites beside the code; cross-pipeline suites at top). Changes:
- One loader. New repo-root
conftest.py(required anyway for package mapping;tests/conftest.pydoes not load for standalonepytest lambdas/poruns, so it cannot carry session invariants): dummy AWS env, moto BUILTIN_HANDLERS registration before any handler import (with the explanatory comment currently buried in_po_parser_support.py:33), and a singleload_lambda_module(pipeline, name)keeping one copy of the sys.modules save/restore (still needed for template_parser's duplicated bare name). - Rewrite
_wo_parser_support.pyoff the bare-import strategy (it is the source of the collision the other two loaders defend against). Sharedtests/support/package: supersetFakeTable(update_item + WO's put_item/keyed store),FakeDynamoResource,load_email,load_goldenkeeping PO'sparse_float=Decimal(load-bearing for exact money comparison). - File moves:
test_po_merge.py,test_pad_zip.py→lambdas/po/email_processor/tests/.test_parse_raw_email.pyandtest_ses_auth.pystay at root (genuinely cross-pipeline, parameterized over both handlers). - Delete
test_local.py(globs a nonexistentsamples/, WO-only, bypasses the gate; the golden suites cover its role) or rewrite with--pipeline— deletion preferred.
Missing scenarios by module (priority order)
- PO AI-fallback negatives (with Phase 1): non-dict output (list/str/int/None — today AttributeErrors at the logger f-string into retries/DLQ), injected po_number/email_type/status, XML-wrap + neutralization + linear-time regex, markdown-fence stripping (currently untested in PO).
- Bedrock transport errors, both pipelines: ThrottlingException (assert PO's pre-call metric survived + no partial write + exception propagates into the errors-alarm/DLQ path — this doubles as the only real proof of PO's metric-before-call ordering), missing
contentkey, empty content list, non-JSON model text. Pin WO's no-datapoint-on-throttle behavior with a documenting test (do not "fix" by reordering — see Phase 8 note). - Handler-level SES auth seam: today every dispatch test monkeypatches
authenticate_inbound_email=True; deleting the gate line would pass all 568 tests. Add per pipeline: reject-path with no auth monkeypatch + emptyALLOWED_DKIM_DOMAINS(assert zero Bedrock calls, zero writes, no raise; env is read at call time so setenv suffices; FakeS3 still needed — the gate sits after get_object). Accept-path is turnkey for PO (scrubbed Coupa fixtures authenticate); WO needs an SES-stamped fixture synthesized fromWO_SES_HEADERin test_ses_auth. - web_ui (both, 0% today — auth is the mandatory-review surface): fail-closed on unset ARN (requires module reload — ARN read at import), fail-closed on Secrets Manager exception, TTL cache refresh (reload fixture resets globals), Bearer/X-Auth-Token/case-insensitivity, wrong-token 401, 401 without table scan, non-ASCII token (hmac.compare_digest TypeError → 500 today, worth pinning/fixing), hostile-field escaping (regression lock — escaping is currently correct on inspection). PO web_ui lacks
__init__.py— use the loader, not package imports. - WO merge semantics:
tests/test_wo_merge.pymoto-backed mirror of test_po_merge (table nameWorkOrders, not kebab): null-status never clobbers wo_status, created_at immutable via if_not_exists, status→wo_status mapping, None fields absent from SET, record_type only-when-present. - site_extractor characterization (before Phase 6): extract_site_code shapes incl. KLAL divergence and prefix-match overlong acceptance (
SNY55), parse_address variants, stream routing (INSERT/MODIFY/REMOVE, pending-review fallback), upsert_site SS-ADD expression. - Small pins: PO-DC-02 64-char EMF clamp regression test (~10 lines in test_po_derived_wiring); multi-record event failure-isolation test per pipeline (documents the all-or-retry contract); reprocess.py synthetic-event-shape contract test (also pins "raw key, no URL-decoding").
Fixtures/goldens
Golden .eml + expected-JSON suites are the model — extend, don't replace. Shared tests/support/ loaders; per-pipeline fixtures stay per-pipeline (real scrubbed samples). WO gains one SES-stamped fixture (synthesized header block, not new scraped mail).
CI changes
- Add
--covwith an explicit module list (or add__init__.pyso--cov=lambdasstops silently skipping web_ui/site_extractor); fail-under once web_ui/site_extractor suites exist. - Add the Phase 0 ast bundle-consistency test to the standard run.
- Add ruff config enabling C901/PLR so complexity ceilings are enforced, not decorative.
- Lint scope currently omits
scripts/andtest_local.py— include scripts/ (or delete test_local.py and moot half of it). - Keep the strict
ci / cirequired check; no admin-bypass pushes to main while migration PRs are in flight (bypass_mode=always currently allows a zero-CI production deploy).
5. Findings appendix
55 confirmed (survived the verification pass, several with sharpening corrections noted in §3/§4), 16 unverified (plausible on the evidence given but not independently re-verified — treat as candidates, not commitments). No finding was refuted outright; verification corrections were incorporated into the plan above.
Duplication / drifted copies
| # | Finding | Verdict | Impact |
|---|---|---|---|
| D1 | AI-fallback hardening (#104) landed in WO only; PO fallback already drifted behind it | confirmed | high |
| D2 | ses_auth.py byte-identical 452-line copy, sync policed by test fixture | confirmed | high |
| D3 | web_ui fail-closed auth gate byte-identical ~61-line block in both handlers | confirmed | high |
| D4 | No lambdas/shared/ despite 4 duplicated modules (handbook location unused; "prescribes" softened to "provides, and the need condition is met") | confirmed | high |
| D5 | EMF ParseMethod emitter near-identical copy, already drifting (actually 3×, incl. derived-agreement emitter) | confirmed | medium |
| D6 | parse_raw_email drifted copy (WO Cc delta is intentional; poor standalone ROI — fold into D2's plumbing) | confirmed | medium |
| D7 | Bedrock-fallback test suites drifted mirroring source drift (WO has 8 tests PO lacks, not 4) | confirmed | medium |
| D8 | template_parser pair: extract only common substrate (_plain_lines etc.), do NOT merge parsers | unverified | low |
| D9 | save_new_po / save_revision byte-identical bodies | unverified | low |
| D10 | Three hand-rolled DynamoDB SET-expression builders | unverified | low |
| D11 | test_local.py + backfill_sites.py entrench single-pipeline layout | unverified | low |
Architecture
| # | Finding | Verdict | Impact |
|---|---|---|---|
| A1 | PO ai_fallback path has no validation-gate module (structural parity gap; unvalidated po_number becomes partition key; sticky-Cancel blast radius) | confirmed | high |
| A2 | Both email-processor handlers are God-modules (WO ~5 concerns, not 7) | confirmed | high |
| A3 | site_extractor is a third, contradictory site-code classifier (all-letter codes can never self-register) | confirmed | high |
| A4 | Module-import boto3 clients force the bespoke loader tax (paid 3×) | confirmed | medium |
| A5 | Trade/site/fiscal rules in two authoritative texts (prompt + derived_fields); keep independent during bake | confirmed | medium |
| A6 | Two test roots, three loading idioms, load-bearing moto import order | confirmed | medium |
| A7 | web_ui interleaves auth/data/HTML rendering | unverified | low |
Code quality / consistency
| # | Finding | Verdict | Impact |
|---|---|---|---|
| Q1 | PO fallback lacks fail-closed gate + injection hardening (consistency view of D1/A1; write path uses non-null-key spray vs WO's whitelist) | confirmed | high |
| Q2 | site_extractor superseded by derived_fields (skip-list sub-claim corrected: real divergence is prefix-match false-accepts + dead all-letter alternation) | confirmed | medium |
| Q3 | _validate_new_po_values 294-line monolith, 60 returns; noqa suppressions currently inert (no ruff config); extract_new_po C901=35 is the worse peer | confirmed | medium |
| Q4 | WO gate misleading reason codes ("malformed_site_code" for bad status — and validate_ai_fallback already returns "invalid_status" for the same failure, so one failure → two codes by path) | confirmed | medium |
| Q5 | Stale 44 MB package/ vendored tree (real cost: synth staging + docker mount + asset-hash divergence, not sys.path shadowing) | confirmed | medium |
| Q6 | web_ui full-table scan + in-memory sort + magic 500 (page-cap option rejected — scan order isn't recency; GSI or accept arbitrary order) | confirmed | medium |
| Q7 | test_local.py rotted (missing samples/, WO-only, bypasses gate) | unverified | low |
| Q8 | Type hints absent/degenerate on newest modules | unverified | low |
| Q9 | _classify_desc C901=21 priority ladder — defer past bake | unverified | low |
| Q10 | WO template_parser function-local imports / sentinel-after-use | unverified | low |
| Q11 | backfill_sites.py sys.path hack keeps stale extractor alive | unverified | low |
Tests
| # | Finding | Verdict | Impact |
|---|---|---|---|
| T1 | PO ai_fallback: zero negative tests for hostile model output | confirmed | high |
| T2 | Bedrock transport errors untested both pipelines; metric-ordering divergence unpinned (WO reorder would break its alarm contract — pin, don't move) | confirmed | high |
| T3 | Handler-level SES auth wiring never tested (every dispatch test bypasses) | confirmed | high |
| T4 | web_ui completely untested incl. auth gate (escaping itself verified correct — tests are regression locks) | confirmed | high |
| T5 | Three divergent test-bootstrap loaders + byte-identical fake-Dynamo helpers (only FakeDynamoResource/_stems identical; FakeTable/load_golden intentionally divergent — superset needed) | confirmed | medium |
| T6 | WO save_work_order merge semantics no direct tests | confirmed | medium |
| T7 | Loader choreography: moto import-order invariant enforced only by comments (not breakable by reordering today; maintenance landmine) | confirmed | medium |
| T8 | PO-DC-02 64-char clamp no regression test | unverified | low |
| T9 | Multi-record S3 event batch-abort semantics untested | unverified | low |
| T10 | test_rule7_bad_status locks in the wrong reason code | unverified | low |
| T11 | scripts/ untested and exclusion undocumented | unverified | low |
CDK / infrastructure
| # | Finding | Verdict | Impact |
|---|---|---|---|
| C1 | ~400 (measured 379) identical lines between stacks; extract plain-function cdk/common.py; Construct-wrapping forbidden (logical-ID replacement of RETAIN tables) | confirmed | high |
| C2 | PO fallback-rate alarm omits rejected series; no PO rejected alarm (retune for 57/day — WO's sparse idiom structurally dead at PO volume) | confirmed | high |
| C3 | Alarm helpers duplicated verbatim; per-function alarm boilerplate 13+9 sites (variance to preserve: p99/p95, per-function alarm subsets, wo web_ui has none) | confirmed | medium |
| C4 | Duration statistic drift p99 vs p95, no rationale | unverified | low |
| C5 | WorkOrders/WorkOrderComments naming violates kebab-case but replacement-locked → document as legacy, don't rename | unverified | low |
| C6 | constructs floor-pinned; stale version comments | unverified | low |
| C7 | cdk.Environment omits account; Bedrock ARN hardcodes it | unverified | low |
| C8 | No function-ARN CfnOutputs; WO stack has zero outputs | unverified | low |
Contracts & docs
| # | Finding | Verdict | Impact |
|---|---|---|---|
| X1 | README "Data is never corrupted" false for PO fallback path (docs/po-template-parser.md contradicts it via issue #101) | confirmed | high |
| X2 | Three inconsistent site_code definitions; stream field-contract subsection missing (README does list site_extractor as consumer — gap is field-level) | confirmed | high |
| X3 | WO emits ParseOutcome after Bedrock → throttle drops record from alarm denominator, contradicting README ("emit-before" fix rejected — double-counts; use except-and-reraise with bedrock_error reason) | confirmed | medium |
| X4 | Stale quantity/price prompt-type comments; doc self-contradicts §8 vs §10 | confirmed | medium |
| X5 | README omits derived-field suites/derived_fields.py/docs/; 64-char clamp undocumented | confirmed | medium |
| X6 | derived_fields "FAITHFUL v1 port" docstring false (contradicts its own line-100 backtest comments); reword to spec-superset + keep runtime-authority nuance | confirmed | medium |
| X7 | Web UI invocation contract stale ×3; README example guaranteed 401 | confirmed | medium |
| X8 | verified-sites contract: lat/long/notes provenance unknown; INFRA-138 by-state GSI breach live (restore would be a sparse GSI — relevant to the decision) | confirmed | medium |
Deploy pipeline & bundling (gap dimension)
| # | Finding | Verdict | Impact |
|---|---|---|---|
| P1 | Deploy runs zero tests; cd-cdk pre-flight/health-check dormant (no stack-name); admin bypass allows zero-CI production deploy | confirmed | high |
| P2 | cp allowlist invisible to every gate → production ImportError class (bit PR #105); glob + ast test | confirmed | high |
| P3 | Migration sequencing: guards → root-move → extraction, each deployed/verified (PR-1 corrections: WO zip not byte-identical; pip -r path change) | confirmed | high |
| P4 | Divergent bundling strategies; WO ships tests/ + pycache; from_asset ignores .gitignore (hash-poisoning is in WO cp -r and the three non-bundled assets, not PO's allowlist) | confirmed | medium |
| P5 | Broken-bundle detection traffic-dependent; no synthetic canary (even with stack-name set, CFN status check passes a broken bundle — sync invoke + FunctionError check is the load-bearing part) | confirmed | medium |
DLQ / recovery (gap dimension)
| # | Finding | Verdict | Impact |
|---|---|---|---|
| R1 | No WO recovery tooling — reprocess.py hardcoded PO-only; async-destination DLQ has no console redrive, so scripted re-invoke is the ONLY path | confirmed | high |
| R2 | No DLQ recovery runbook; 14-day-DLQ vs 90-day-S3 windows stated nowhere | confirmed | high |
| R3 | reprocess.py --execute: full-prefix replay not order-safe (Event invocation = concurrent; sorting alone doesn't serialize); same-object replay idempotent | confirmed | medium |
| R4 | Batch-abort → whole-event DLQ message; redrive re-runs succeeded records (safe by idempotency, undocumented; do NOT add per-record swallowing) | confirmed | low |
Dependency hygiene (gap dimension)
| # | Finding | Verdict | Impact |
|---|---|---|---|
| H1 | Floor-pinned boto3 in Docker bundles → non-reproducible artifacts; vendored copy entirely unnecessary (runtime boto3 suffices) | confirmed | high |
| H2 | Target-state manifest table; layer-vs-shared-dir Dependabot implication (shared/ source dir with no manifest needs no new entry) | confirmed | medium |
| H3 | po/web_ui + site_extractor: no requirements.txt, invisible to Dependabot (cite cdk-project-layout.md, not lambda-template's SAM rationale) | confirmed | low |
| H4 | wo/web_ui requirements.txt is a dead manifest (10 no-op Dependabot PRs merged; file ships as dead weight in the zip) | confirmed | low |
| H5 | tests/requirements.txt uncovered + floor pin (pin first or the dependabot entry is a no-op) | confirmed | low |
6. Explicitly out of scope / rejected
| Rejected | Why |
|---|---|
| Merging the two template parsers | 15.4% similarity; extractors and validation rules are genuinely domain-specific (Coupa PO vs Hexagon WO). Only the byte-identical low-level helpers are extraction candidates, and even that is low-impact/unverified (D8). |
| CDK Construct-subclass composition | Changes every child logical ID → CloudFormation replacement of RETAIN tables and create-failure on named buckets. Plain functions with unchanged scope/ids, gated on zero cdk diff, achieve the dedup safely. |
| Lambda layer for shared code | Creates a cross-stack export (layer owned by one stack, consumed by the other) that blocks independent updates under cdk deploy --all. Asset-root widening keeps each function self-contained. Revisit only if shared code grows real third-party deps. |
| Renaming WorkOrders/WorkOrderComments/non-prefixed resources to kebab-case | table_name/function_name changes force resource replacement (production data; cross-stack fromTableName consumers). Correct move is a handbook Legacy-section entry; revisit at the INFRA-6 CMK table touch. |
| Reordering WO's emit_parse_metric before the Bedrock call ("PO parity") | Would double-count gate-rejected emails against wo_stack's fallback-rate MathExpression and violate the documented "rejected emits nothing else" contract. Fix is except-and-reraise with a bedrock_error reason code + a pinning test. |
| Moving PO's pre-Bedrock metric emission after the gate | The pre-call emit is deliberate (Bedrock errors still record ai_fallback) and load-bearing for the alarm; ai_fallback_rejected is additive instead. |
| Per-record exception swallowing in handler loops | Would remove the retry→DLQ capture and weaken fail-closed behavior. If isolation is ever wanted: collect failures, re-raise at end. Documented in the runbook instead. |
| Dedupe/skip logic inside handlers for replay safety | Weakens the deliberate always-upsert semantics; replay safety handled in the reprocess script (targeted-by-default) and runbook. |
| Generating EXTRACTION_PROMPT from derived_fields rule tables | Defeats the shadow bake's purpose (comparing two independent implementations). Appropriate only after Python becomes authoritative and the prompt sections are deleted. |
| Any derived_fields.py behavior change before the bake concludes | Shadow-telemetry constraint: the module is under agreement measurement; restructuring (incl. the _classify_desc ladder refactor, Q9) confounds the telemetry. Docstring fix (X6) is text-only and allowed. |
| Adding alarms to wo-web-ui as a side effect of the alarm helper | New paging surface; propose separately and deliberately, never silently via refactor. |
| Consolidating the fallback-rate alarms into one parameter-free helper | PO's 6h/≥8-floor tuning is a deliberate, documented retune for ~57 emails/day vs WO's 15-min/≥10; helper parameterizes, per-stack values stay. |
| fromTableName→CfnOutput coupling changes for slack-bot consumers | Cross-repo; out of this refactor. CfnOutputs are added (C8) but consumer migration is seahaven-slack-bot's change. |
| Full rewrite / repo split | Nothing measured supports it: the tested tree is healthy (93%, fast suite, clean lint); every problem is addressable by consolidation with production continuity. |
Cross-cutting compliance notes for execution: Phases 0, 1, 3 touch untrusted-input/auth surfaces → /sh-security-review mandatory before push. Phase 4 moves IAM policy construction and Phase 0 touches handler event contracts → cross-family cross_review.py review mandatory. Every PR: ruff check + ruff format --check locally before push; gh pr checks green before merge; deploy-then-merge for all deploy-affecting phases. README updates land in the same commits as the changes they describe; the Confluence "AWS Architecture Map" needs updating when Phase 4/7 alter the resource inventory (new outputs, runbook).