procurement-ingest/docs/refactor-evaluation.md
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

305 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
1. **Security drift between twin pipelines (fix first, not a refactor).** The AI-fallback hardening from #104 (XML-delimited prompt, tag neutralization, `validate_ai_fallback` fail-closed gate, `ai_fallback_rejected` metric + alarm) exists **only in WO**. PO's `extract_with_claude` sends the raw untrusted body bare after the prompt and routes unvalidated LLM output straight to `save_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.
2. **Duplication with no shared home.** `ses_auth.py` is 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's `lambdas/shared/` location is unused, blocked today by per-function `Code.from_asset` roots that can't reach it.
3. **The deploy pipeline cannot catch the refactor's main failure mode.** The hand-maintained `cp` allowlist 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.ini` is the only config; `test_local.py` deliberately excluded (imports handler → boto3 clients at collection). CI runs bare `pytest` at 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.py` independent importlib reimplementation + load-bearing `import moto` ordering; `_wo_parser_support.py` bare sys.path `import 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: PLR09xx` suppressions on `_validate_new_po_values` are inert, and `extract_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 the `sender_auth_rejected` substring 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 the `FunctionError` response field (init ImportError returns HTTP 200 + `FunctionError=Unhandled` — exit-code checks false-pass). Wire via cd-cdk's `post-deploy-script` input (stack-name input takes only one stack; this repo has two).
- Replace PO's four-file `cp` allowlist with `cp ./*.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-review` mandatory**; 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-specific `validate_ai_fallback` (NOT a WO copy — PO contract is nested: `CONTRACT_KEYS` exact key-set with missing-key normalization, `po_number` vs `_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 with `parse_float=Decimal`). Call immediately after `extract_with_claude`, before `enrich_parsed`/dispatch; on failure emit `ParseMethod=ai_fallback_rejected` then `continue` (skip, never raise — avoids DLQ churn on attacker-controlled input).
- Port `<email>` data-block wrapping + `_EMAIL_TAG_RE` tag neutralization + `temperature=0` into `extract_with_claude`.
- **Preserve the deliberate PO metric ordering:** PO emits `ai_fallback` BEFORE the Bedrock call (so Bedrock-side errors still record the outcome — handler.py:656-669). Do not move it. `ai_fallback_rejected` is an additive second datapoint on rejection; document the intentional double-count.
- `cdk/po_stack.py` in the same PR: (a) fold `FILL(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-new `EmailProcessorAiFallbackRejectedAlarm` — **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 into `save_new_po` via 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_rejected` to 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")` with `command: cp po/email_processor/*.py /asset-output/` (resp. wo) and `pip install -r po/email_processor/requirements.txt` (the `-r` path change is required — bundling cwd is the asset root). Add `exclude=['**/__pycache__/**','**/tests/**','**/package/**']` — `from_asset` does NOT honor .gitignore, and the stale 44 MB `package/` 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 `exclude` to the three plain `from_asset` calls (po web_ui :502, site_extractor :581, wo web_ui :548) — local `__pycache__` currently makes their hashes nondeterministic.
- **Gates:** `aws lambda get-function` zip 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/` (handbook `cdk-project-layout.md`). Move in dependency-risk order:
1. **`ses_auth.py`** (byte-identical; the prime target — security-critical auth that currently requires every hardening fix to land twice). Add `cp shared/*.py /asset-output/` to both bundling commands; module lands flat so `from ses_auth import authenticate_inbound_email` is unchanged — zero handler diff keeps fail-closed auth byte-identical.
2. **`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).
3. **`email_parsing.py`**: `parse_raw_email` superset returning `cc` unconditionally (PO ignores it — harmless).
4. **`emf.py`**: generic emitter parameterized by namespace/dimension-sets/properties, serving all **three** hand-built `_aws` envelopes (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, parameterizing `json.loads` kwargs, `max_tokens` 2048/1024, prompt), per-pipeline `validate_ai_fallback` gates.
- Test plumbing in the same PR: update `_SIBLING_MODULES` resolution, `_po_parser_support.py:71`, the fixture-hygiene test, drop the `ses_auth` fixture 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-protected `purchase-orders`/`WorkOrders` tables 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 from `Stack.account`/`Stack.region` instead 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 diff` on 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 both `cdk.Environment` calls, pin `constructs==` 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-identical `save_new_po`/`save_revision` into one `_save_merge`). WO: ~5 concerns, not 7 — its split must keep `validate_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) to `prompts.py` with cross-reference headers to derived_fields; keep a re-export since 4 tests dereference `handler.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_code` already prefers `record["site_code"]` (line 36) — the defect is the digit-requiring `SITE_CODE_PATTERN.match` (prefix-anchored, rejects KLAL, accepts overlong junk like `DLI6X`). Fix: validate the direct field with the canonical `derived_fields` shape + skip-list semantics (fullmatch), import `derive_site_code` for the fallback path (ship `derived_fields.py` into 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.py` at `derive_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 `--all` with 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-pin `moto==` and add `/tests`, `/lambdas/po/web_ui`, `/lambdas/po/site_extractor` dependabot 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.py` does not load for standalone `pytest lambdas/po` runs, 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 single `load_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.py` off the bare-import strategy (it is the source of the collision the other two loaders defend against). Shared `tests/support/` package: superset `FakeTable` (update_item + WO's put_item/keyed store), `FakeDynamoResource`, `load_email`, `load_golden` **keeping PO's `parse_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.py` and `test_ses_auth.py` **stay at root** (genuinely cross-pipeline, parameterized over both handlers).
- Delete `test_local.py` (globs a nonexistent `samples/`, WO-only, bypasses the gate; the golden suites cover its role) or rewrite with `--pipeline` — deletion preferred.
### Missing scenarios by module (priority order)
1. **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).
2. **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 `content` key, 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).
3. **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 + empty `ALLOWED_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 from `WO_SES_HEADER` in test_ses_auth.
4. **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.
5. **WO merge semantics:** `tests/test_wo_merge.py` moto-backed mirror of test_po_merge (table name `WorkOrders`, 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.
6. **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.
7. **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 `--cov` with an explicit module list (or add `__init__.py` so `--cov=lambdas` stops 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/` and `test_local.py` — include scripts/ (or delete test_local.py and moot half of it).
- Keep the strict `ci / ci` required 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).*