* Add deterministic template parser for WO emails The workorder-email-processor sends every one of ~22.9k emails/month to an LLM, but ~93.6% are the plain-text "AMAZON UPDATE WO DETAILS" comment template and ~6.4% the HTML "AMAZON assign Work Order" template. Parse those two shapes deterministically, offline, so the AI call is reserved for the long tail. The module is pure (no boto3, no network). try_deterministic_parse classifies by subject, extracts the shared contract fields, and returns a result ONLY when it passes a strict fail-closed validation gate: exact contract-key set, subject/id agreement, the literal "Work Order: <id>" double space, per-type required fields, site-code shape, and a label-bleed guard so a value that over-ran into the next field fails. Any miss, drift, or extractor exception yields None so the caller falls back to the AI extractor -- data is never corrupted, only the fallback rate rises. Refs: #23 * Migrate WO processor to Bedrock and fix comment_id collision Switch the AI path from the Anthropic SDK to bedrock-runtime InvokeModel on the inference profile us.anthropic.claude-haiku-4-5-20251001-v1:0 (BEDROCK_MODEL_ID env), so parsing no longer needs a provider API key or Secrets Manager secret. The EXTRACTION_PROMPT and JSON contract are kept byte-identical, so the AI-fallback output is unchanged. Try the new deterministic template parser first and only call Bedrock on a miss/invalid result. Fix issue #23: the WorkOrderComments range key was work_order_id#<comment_time>, so two emails on one WO with an identical or absent comment time collided and overwrote each other. Derive a 12-hex suffix from the S3 object key alone -- deterministic, so an async retry of the same object is byte-identical (idempotent) while distinct emails get distinct keys -- and keep wall-clock now() out of the key (literal 'nocomment' segment when comment_time is absent). Also emit one CloudWatch EMF line per record (Seahaven/WorkorderIngest ParseOutcome, dimensioned by ParseMethod/TemplateId) for parse-outcome observability, replace the deprecated datetime.utcnow() with datetime.now(timezone.utc), and drop the anthropic dependency. Refs: #23 * Migrate PO processor to Bedrock Switch the PO email processor's AI extraction from the Anthropic SDK to bedrock-runtime InvokeModel on the inference profile us.anthropic.claude-haiku-4-5-20251001-v1:0 (BEDROCK_MODEL_ID env), so it no longer needs a provider API key or Secrets Manager secret. PO parsing stays fully AI -- only the provider changes. The EXTRACTION_PROMPT is kept byte-identical and the Bedrock text output is still decoded with json.loads(..., parse_float=Decimal), which DynamoDB requires (it rejects floats). Replace the deprecated datetime.utcnow() with datetime.now(timezone.utc) and drop the anthropic dependency. * Grant Bedrock IAM, drop Anthropic secrets, add fallback alarm Both stacks moved their processors from the Anthropic API to the Bedrock inference profile us.anthropic.claude-haiku-4-5-20251001-v1:0. Grant each processor role bedrock:InvokeModel + bedrock:InvokeModelWithResponseStream on BOTH the inference-profile ARN AND the per-region foundation-model ARNs for us-east-1/us-east-2/us-west-2 (empty-account) -- the us.* profile routes cross-region, so a profile-only grant AccessDenies at runtime. Remove both anthropic-api-key Secret constructs, their grant_read, and the ANTHROPIC_API_KEY_SECRET_ARN env; add BEDROCK_MODEL_ID. The secrets had RemovalPolicy.RETAIN so they are orphaned, not deleted -- flagged in the README for manual post-deploy deletion and key revocation. Add the workorder-email-processor-template-fallback-rate alarm: a FILL(0) + >=10-sample volume-floor MathExpression over the EMF ParseOutcome metric (15-min periods) that pages when the AI-fallback share exceeds 15% sustained, catching Hexagon template drift. ALARM-only SnsAction to site-alerts, no OK action, NOT_BREACHING, matching the existing stack idiom. * Add offline WO parser test suite Cover the deterministic parser with golden-file tests over 55 real scrubbed .eml fixtures (both comment sub-shapes, username Submitted-By, address present/absent, br+CRLF assign addresses), fail-closed validation-gate rules, adversarial and prompt-injection cases that must route to ai_fallback or parse without corrupting other fields, the issue #23 comment_id idempotency invariants, and the Bedrock-fallback dispatch plus EMF-metric emission with a mocked invoke_model. Extend pytest.ini testpaths to discover the co-located suite, and update tests/conftest.load_handler to put a handler's own directory on sys.path so the WO handler's new `from template_parser import ...` resolves under the existing shared handler tests. Point test_local.py at the new template-first + Bedrock flow. Refs: #23 * Document Bedrock migration and WO parse flow in README Record the provider switch to the Bedrock inference profile (no Anthropic API key or Secrets Manager secret, with the retired secrets flagged for manual deletion), the WO deterministic-template-first + AI-fallback flow, the new ParseOutcome EMF metric and template-fallback-rate alarm, the issue #23 comment_id format change, the +00:00 aware-UTC timestamp shift, and offline test instructions. Refs: #23 * Fix f-string lint and formatting in backfill scripts Drop the f prefix from two f-strings that carry no placeholders (F541) and apply ruff format, so `ruff check` / `ruff format --check` pass in CI. * Emit ParseMethod-only EMF set so fallback alarm can fire The fallback-rate alarm queries the ParseOutcome series keyed on ParseMethod alone, but the emitter published only the joint (ParseMethod, TemplateId) dimension set. CloudWatch materializes exactly the listed dimension sets and does not auto-aggregate, so the alarm's series never received data: it evaluated a constant 0 and could never page on template-drift coverage collapse. Publish both ["ParseMethod"] and ["ParseMethod","TemplateId"] and update the EMF regression test to assert both sets are present. * Commit WO parser .eml fixtures for executable coverage The parser test suite globbed for input .eml fixtures that the repo's `*.eml` ignore rule kept uncommitted, so every parametrized golden and fail-closed test collected zero cases and CI could not exercise the deterministic parser that handles 100% of WO email volume. Add a fixtures-only negation to .gitignore and commit the 55 scrubbed positive samples (50 update-plaintext, 5 assign-html) plus 14 ai-fallback and 3 adversarial fixtures. The ai-fallback set covers each fail-closed reason code (subject_no_match, single_space_work_order, malformed_site_code, label_bleed, creation_time_unparseable, wo_id_mismatch, missing_required_field) and the adversarial set proves the parser is total and confines prompt-injection payloads to comment_text without steering the structured fields. * feature: Add PO template parser scaffold and design doc Mirror WO PR #99's template-first approach for the Coupa PO processor. Two templates identified from a full 3,448-email triage: - coupa_new_po (95.5%): scaffolded; fails closed to the LLM until extract_new_po lands. - coupa_cancellation (2.9%): implemented. Nested contract with recursive validation, Decimal money, and a fail-closed gate. Derived fields (site_code/trade/fiscal_year) are deferred to a shared post-stage. Comments, revisions, multi-line, and non-USD emails fall back to Bedrock. docs/po-template-parser.md records the investigation, decisions, and remaining work. Signed-off-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> * Implement PO new_po extraction and value-level gate Replace the extract_new_po scaffold stub with the full section-windowed extractor (duplicate-label anchoring, sentinel ship-to, label-keyed U+2022 bullet split, Decimal money from three anchored contexts only) and add value-level gate rules V1-V13. Both new_po_not_implemented scaffold guards are removed; rules 6-8 (unrecognized_status, multiline_unsupported, non_usd) go live. The gate re-derives every byte proof from the email body so an extractor bug cannot vouch for itself: amount re-serialization with a digit/comma border check (the thousands-separator truncation kill switch), sum(lines)==total against both Total blocks, anchor/supplier identity proofs, USPS address shape on the raw pre-enrichment zip, bullet label discipline, and sentinel/artifact hygiene. Any failure falls closed to the LLM; a validation failure is never a parsed result. Refs: #99 * Wire template-first parse into PO handler with EMF metric Run try_deterministic_parse ahead of the Bedrock extractor and fall back only on a miss/invalid (fail-closed) result. The shared enrich_parsed post-stage and the save_cancellation/save_revision/ save_new_po routing are untouched, so both paths write identical DynamoDB shapes and the po-ingest-site-extractor stream contract is preserved. Each record emits one ParseMethod EMF line (Seahaven/PoIngest/ ParseOutcome, dimension sets [ParseMethod] and [ParseMethod,TemplateId], ReasonCode/po_number ride-alongs) mirroring the WO idiom. The metric fires before the Bedrock call so a Bedrock-side error still records the ai_fallback outcome. Refs: #99 * Add PO fallback-rate alarm retuned for ~57 emails/day The WO alarm's 15-min period and >=10-sample floor assume ~760/day and would be structurally dead at PO volume (a 15-min period holds ~0.6 emails, so the floor is never met). Retune: 6-hour periods (~14.25 expected emails), IF((fb+tmpl)>=8,...) volume floor so a single email can never breach a datapoint (1/8 = 12.5% < 20%), threshold >20% against a ~1% expected baseline, eval 4 / datapoints 2 (24h span) so noise self-clears while total template drift pages within ~12h. No element-wise MAX in the math expression (post-#102 rule); ALARM-only SnsAction to site-alerts, NOT_BREACHING. Gated with 'npx cdk synth po-ingest'. Also add template_parser.py to the bundling cp list -- without it every deployed invocation would ImportError (unit tests cannot catch an asset-bundling omission). Refs: #99, #102 * Add offline PO parser suite with scrubbed fixture corpus 132 tests: golden-file comparison for all 25 positive fixtures (17 single-line new-PO + 8 cancellations, Decimal-exact via parse_float=Decimal), every fail-closed gate reason code covered (body-level triggers via 17 synthetic adversarial .eml mutations, candidate-level via direct validate() unit tests), real multi-line and comment/non-Coupa fallback fixtures, dual line-ending parse identity, two-path enrich/save parity (site-extractor stream guard), V10 URL-id corpus sweep, fixture hygiene (ses_auth pass + scrub-marker leak sweep), and Bedrock dispatch/EMF assertions. The suite loads handler/template_parser via importlib under unique module names and binds the handler's bare sibling imports around exec (tests/conftest.py load_handler gets the same treatment) -- the WO suite caches bare 'handler'/'template_parser' names in sys.modules, and bare imports here would silently bind to the wrong pipeline. moto is imported before the handler so its botocore stubber hook precedes boto3 session creation (the PO conftest chain now loads at pytest session start). Fixtures are scrubbed real S3 samples: transport/auth header values replaced with same-shape placeholders (structure kept so ses_auth still passes), per-file digit ciphers, amounts remapped with sum==total re-established. The .gitignore exception is scoped to the PO fixtures path only. Refs: #99 * Document PO template-first parser and retuned alarm README: PO flow is now template-first with Bedrock fallback; parser/gate section mirroring the WO writeup; Seahaven/PoIngest ParseOutcome namespace and the fallback-rate alarm numbers with their volume justification (deliberately not WO's settings); test-suite and repo-layout updates. Design doc: mark PR #1 complete in progress/checklist sections; document the six value-level gate reason codes and the scaffold guard removal; correct the stale data-access note (default CLI session is 328440206208) and note the ~90-day S3 lifecycle aging of the corpus; record the 2.3 layout addendum (leading Supplier bullet segment, EA evidence lines, summary unit-price tokens, decode-path line endings), the fixture-build pins (address join convention, quantity/unit/price source), the V10 sweep outcome, and resolutions for open questions Q3/Q6. Cross-family review and the Confluence architecture-map update are flagged outstanding for merge. Refs: #99 * Record cross-family review outcome for handler wiring GPT-4.1 cross_review.py run against the real handler diff returned no BLOCK and no security findings; both FIX items verified as no-change-needed (fallback logging already correct; non-dict AI output is the pre-existing issue #101 pattern this PR deliberately does not touch). Refs: #99 * Pin line-item currency to USD in the PO gate The non_usd rule only checked the Total-block top-level currency, so a new_po whose line item read 'for 55,206.00 CAD' under a USD Total block still template-parsed as ok -- a fail-open hole in the fail-closed gate. Every line item's captured currency and its re-derived body token must now byte-equal the proven-USD top-level currency; covered by a line-level CAD adversarial fixture (the existing adv-non-usd only exercised the Total-block variant) and a candidate-mutation unit test. * Scrub residual transport tokens from PO fixtures The first-pass harvest scrub sanitized only the primary SES/DKIM header blocks, leaving the real SES Feedback-ID sender-identity hash in 49 committed fixtures and, on the two non-Coupa fixtures, an embedded second SES block's X-Ses-Receipt, the Exchange cross-tenant UPN ciphertext, and Gmail ARC fh= / X-Gm-* tokens -- exactly the token classes the PR #99 fixture lesson requires placeholdered. Replace each with a same-shape ScrubbedFixture value (byte-safe, CRLF and folding preserved) so header structure and ses_auth behavior are unchanged. * Converge quantity/price to Decimal on both paths EXTRACTION_PROMPT declares quantity and price as JSON strings, so a prompt-obedient Bedrock response stores DynamoDB Strings where the template parser stores Numbers -- divergent attribute types for the same email on the purchase-orders stream. Coerce numeric strings to Decimal in the shared enrich_parsed post-stage (thousands-separator safe; non-numeric strings kept verbatim) so both paths converge; prompt rewording itself remains PR #2 scope. The two-path parity test was circular -- it replayed the parser- derived golden as 'the LLM output', so it could never see the type divergence. It now feeds a prompt-shaped payload (string quantity/ price, LLM-filled site_code) through enrich_parsed and save_new_po, and the fixture-hygiene test now asserts the scrubbed transport-token header classes so fixture regressions are caught. * Coerce bare-int quantity/price to Decimal in enrich_parsed GPT-4.1 cross-family review of the final PR diff (no BLOCK) flagged residual type drift: parse_float=Decimal rules out floats on the LLM path, but a bare JSON int survived as Python int. Coerce it so both parse paths emit one canonical Decimal type. * Scrub fixture-body PII and harden cancellation gate (sec review) /sh-security-review of PR #105 (5 fresh-context detectors + proof-or-kill verifier) confirmed two diff-introduced findings; both fixed here. F3 (medium, real PII in new fixtures): the harvest scrub replaced header tokens but left real third-party PII in message BODIES -- an Amazon contact's name/phone/personal email in non-coupa-02.eml and an internal t.corp.amazon.com ticket URL in comment-02.eml, plus real submitter/attn names recurring across the new_po corpus. Replaced every personal name, phone, personal email, and internal URL with synthetic placeholders (QP-soft-wrap aware) across both .eml bodies and expected goldens. Extended test_fixture_hygiene to scan BODIES (phone shapes, corp URLs, the leaked tokens), closing the header-only gap that let this through. F1 (medium, cancellation gate): _CANCELLATION_SUBJECT was unanchored and matched with .search(), unlike the anchored new_po pattern -- a subject merely ending with the cancellation phrase could be routed to the sticky- Cancelled write. Fully anchored it and switched to .match, and added a body-corroboration gate (the real Coupa body independently restates 'Purchase Order #<po> ... has been cancelled'); a near-miss/misrouted subject whose body does not corroborate now fails closed to the LLM (new reason code cancellation_body_unconfirmed). Pre-existing (advisory, not this PR): the LLM-fallback else->save_new_po dispatch and undelimited extraction prompt (issue #101 family) are byte-identical to main and unchanged here. 401 tests pass; ruff/format clean; cdk synth po-ingest clean. --------- Signed-off-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
33 KiB
Procurement Ingest
Unified email ingestion pipelines for Amazon procurement data. Two independent pipelines — purchase orders (Coupa) and work orders (APM/Hexagon EAM) — share a single repo and CDK app but deploy as separate CloudFormation stacks.
Pipelines
Purchase Orders (po-ingest stack)
Coupa PO emails are received at amazon_po@int.seahaven.com, parsed deterministic-template-first with a Claude-Haiku-4.5-on-Bedrock fallback, and written to the purchase-orders DynamoDB table.
Flow:
- Coupa sends a PO email (new, revision, or cancellation).
- SES (
INBOUND_MAILrule set) drops the raw MIME intos3://po-ingest-emails-{AccountId}/inbound/. - S3
ObjectCreatedtriggers thepo-email-processorLambda. - Fail-closed sender authentication (INFRA-107): the SES-stamped
Authentication-Resultsheader must showdkim=passforamazon.coupahost.com(see Sender authentication); otherwise the email is logged and dropped. - Parse: a pure, offline template parser (
template_parser.py) tries the two known Coupa templates first —coupa_new_po(95.5% of traffic) andcoupa_cancellation(2.9%) — behind a strict fail-closed validation gate. Only on a miss/invalid result does the Lambda fall back to the Claude-on-Bedrock AI extractor (InvokeModel), which extracts the identical structured-JSON contract (PO number, status, supplier, nested ship-to, line items). Numeric amounts areDecimalon both paths (the AI decode usesparse_float=Decimal; DynamoDB rejects floats). The derived classification fields (site_code,trade,fiscal_year) stay LLM-computed for now: the template parser leaves themnull, and the sharedenrich_parsed()post-stage (zip padding,ship_to_raw/statepromotion, metadata) runs identically on both paths so downstream shapes are byte-identical. - Merge write to DynamoDB:
new_po— merge insert. Creates the PO, or backfills data into a pre-existingCancelledskeleton left by an out-of-order cancellation (preserving theCancelledstatus). No longer silently dropped when a record already exists.revision— field-level merge (update_itemSETs only the fields present in the revision). A revision that omitsline_items/supplierno longer deletes them. Will not un-cancel aCancelledPO.cancellation— marks the rowCancelled(creating a minimal skeleton if the cancellation arrives before thenew_po).
- DynamoDB Streams feeds downstream consumers:
- LedgerFlow (
seahaven-slack-bot/po-sync) — daily KB sync - Site extractor (
po-ingest-site-extractor) — real-time site address extraction intoverified-sitestable
- LedgerFlow (
Deterministic template parser. template_parser.try_deterministic_parse() classifies by exact subject regex, extracts the nested contract (supplier{}, ship_to{} — 8 keys, line_items[] — 10 keys per item), and returns a parsed result only if it passes a fail-closed validation gate: recursive exact key-set at every nesting level; email_type emitted only on the exact new-PO subject and a confirmed-safe Status (never defaulted — a cancellation misrouted as new_po would defeat the sticky-Cancelled guard); po_number shape + byte-equality with the subject, the body PO ID, the Amazon Purchase Order # heading, and the orders/<id> URL; duplicate-label anchor integrity (Supplier/Shipping/Total each appear twice, the first Shipping must be the literal None placeholder); money fidelity (every Decimal re-serializes byte-identically to its source token with a digit/comma border check — the thousands-separator-truncation kill switch — plus sum(line amounts) == total, proven against both Total blocks); bullet-metadata label discipline (closed label set, assigned by leading label, never ordinal — immune to the optional Part Number segment); USPS address shape on the raw pre-enrichment value; and rejection of any unparseable sentinel or residual \r/\xa0 artifact. Multi-line-item (0.18%) and non-USD (0 observed) new-POs, comment emails, and anything else falls back to the AI extractor. Data is never corrupted; only the fallback rate rises. Every record emits one CloudWatch EMF metric (see below).
Lambdas (lambdas/po/):
| Function | Trigger | Purpose |
|---|---|---|
po-email-processor |
S3 ObjectCreated | Claude extraction + DynamoDB write |
po-ingest-site-extractor |
DynamoDB Streams | Site code/address extraction -> verified-sites |
po-web-ui |
Manual invoke | HTML dashboard (public Function URL removed 2026-06-08, INFRA-74) |
Tables:
purchase-orders(PK:po_number, Streams: NEW_IMAGE) — shared with seahaven-slack-bot (read-only; see Shared Resources)verified-sites(PK:siteCode) — ~1,100 unique Amazon facility sites (by-stateGSI removed 2026-06-03, audit M-20)pending-site-review(PK:po_number) — unresolvable POs for manual Payee Central verification
Work Orders (WorkorderIngestStack stack)
Amazon APM work order emails (from Hexagon EAM / HxGN SmartCloud) are received at apm@int.seahaven.com, parsed deterministic-template-first with a Claude-on-Bedrock fallback, and written to the WorkOrders DynamoDB table.
Flow:
- Hexagon EAM sends email notifications (new assignments, comments, updates, cancellations) to
amazon@seahavenind.com. - Gmail filter forwards APM emails to
apm@int.seahaven.com(SES). - SES drops the raw MIME into
s3://workorder-ingest-emails-{AccountId}/inbound/. - S3 triggers the
workorder-email-processorLambda. - Fail-closed sender authentication (INFRA-107): the SES-stamped
Authentication-Resultsheader must showdkim=passfor the domain that re-signs the forward (currently allowlisted asseahaven.com— see the validation caveat under Sender authentication); otherwise the email is logged and dropped. - Parse: a pure, offline template parser (
template_parser.py) tries the two known Hexagon templates first, behind a strict fail-closed validation gate. Only on a miss/invalid result does the Lambda fall back to the Claude-on-Bedrock AI extractor. Both paths emit the identical structured-JSON contract (work order ID, site code, severity, priority, dates, assigned technician). - Work order upserted to
WorkOrders, event/comment appended toWorkOrderComments.
Deterministic template parser. ~93.6% of WO traffic is the plain-text "AMAZON UPDATE WO DETAILS <id>" comment template (T1) and ~6.4% is the HTML "AMAZON assign Work Order <id> on building <SITE>" assignment template (T2). template_parser.try_deterministic_parse() classifies by subject, extracts the contract fields, and returns a parsed result only if it passes a strict validation gate (exact contract-key set; work_order_id matches the subject and is all-digits; the T1 Work Order: <id> double-space is literally present; email_type matches the template; site_code shape; per-type required fields; and a label-bleed guard so a value that over-ran into another field fails). Anything that fails — the rare update/cancellation shapes, Hexagon template drift, or an extractor exception — falls back to the AI extractor. The AI path is gated too: the untrusted email reaches Bedrock inside a neutralized <email> data block (tag lookalikes in the body are defanged), and the raw model output must pass the fail-closed validate_ai_fallback() schema/enum/date gate before any DynamoDB write — output that fails is dropped and paged (see the ai-fallback-rejected alarm below), a prompt-injection defence for DKIM-passing but attacker-influenced mail. Data is never corrupted; only the fallback rate rises. Every record emits one CloudWatch EMF metric (see below).
Lambdas (lambdas/wo/):
| Function | Trigger | Purpose |
|---|---|---|
workorder-email-processor |
S3 ObjectCreated | Claude extraction + DynamoDB write |
workorder-web-ui |
Manual invoke | HTML dashboard (public Function URL removed 2026-06-08, INFRA-74) |
Tables:
WorkOrders(PK:work_order_id) —site-code-indexandstatus-indexGSIs removed 2026-06-03 (audit M-20)WorkOrderComments(PK:work_order_id, SK:comment_id) — see thecomment_idformat note below
Architecture
IaC: AWS CDK (Python), two stacks in one app, region us-east-1.
All Lambdas: Python 3.12, ARM64, 60-day log retention.
LLM provider — Amazon Bedrock. Both email processors call Claude Haiku 4.5 through the Bedrock inference profile us.anthropic.claude-haiku-4-5-20251001-v1:0 (bedrock-runtime InvokeModel), configured via the BEDROCK_MODEL_ID env var. There is no Anthropic API key and no Secrets Manager secret any more. Each processor role is granted bedrock:InvokeModel + bedrock:InvokeModelWithResponseStream on both the inference-profile ARN and the per-region foundation-model ARNs for us-east-1 / us-east-2 / us-west-2 (empty-account foundation-model ARNs) — the us.* profile can route cross-region, so a profile-only grant would AccessDenied at runtime under load.
Retired secrets (manual cleanup outstanding): the former secrets
po-ingest/anthropic-api-keyandworkorder-ingest/anthropic-api-keyhadRemovalPolicy.RETAIN, so removing them from the CDK stacks orphans them rather than deleting them. Delete both by hand post-deploy and revoke the stored keys at the provider. The processors no longer read anyANTHROPIC_API_KEY_SECRET_ARN— authentication to Bedrock is via the Lambda execution-role IAM grant, so there is no provider API key or Secrets Manager fetch on the parse path.
SES: Both stacks add rules to the shared INBOUND_MAIL receipt rule set on int.seahaven.com.
Sender authentication (INFRA-107)
The From header and any Authentication-Results header inside the raw MIME are attacker-forgeable, so neither is trusted. Instead, both email processors (lambdas/*/email_processor/ses_auth.py) authenticate the sender against the verdicts SES itself stamps at delivery time, failing closed:
- Take only the topmost
Authentication-Resultsheader (SES prepends its trace headers; any lower copies arrived inside the message and are ignored). - Require its authserv-id to be
amazonses.com. - Require a
dkim=passclause whoseheader.d=/header.i=domain is in the pipeline's allowlist.
The allowlist is the ALLOWED_DKIM_DOMAINS Lambda environment variable (comma-separated, set per stack in CDK — no code change needed to adjust):
| Pipeline | ALLOWED_DKIM_DOMAINS |
Why |
|---|---|---|
| Work orders | seahaven.com |
APM mail reaches apm@int.seahaven.com via a forward off amazon@seahavenind.com; the allowlist trusts the domain that re-signs DKIM on that forward (the original hxgnsmartcloud.com signature does not survive it). Validated against live SES-stamped headers (2026-07-16) — real APM deliveries carry dkim=pass header.i=@seahaven.com. Note a plain Gmail auto-forward re-signs under the sending Workspace domain (seahavenind.com / a *.gappssmtp.com key), not seahaven.com; only a Google Group (or Workspace routing) with "sign as seahaven.com" produces dkim=pass header.i=@seahaven.com. If the observed re-signing domain differs, update this value (do not widen it to a shared key like *.gappssmtp.com, which any Google customer's mail would pass). The workorder-email-processor-sender-auth-rejected alarm pages if this assumption is wrong instead of silently dropping every work order. |
| Purchase orders | amazon.coupahost.com |
Coupa signs as amazon.coupahost.com. amazonses.com also passes but is deliberately not allowlisted — every SES customer's mail passes for it |
Clause-injection hardening: SES echoes attacker-controlled SMTP-session tokens (
envelope-from,helo,header.from) into its ownAuthentication-Resultsvalue, and an RFC 5321 quoted-local-part MAIL FROM may legally contain;and spaces. The parser therefore tokenises comment- and quoted-string-aware (RFC 8601 / RFC 5322): CFWS comments(...)are stripped and clauses are split only on semicolons outside a quoted string, so a;inside a quotedenvelope-from=value can never be torn into a forgeddkim=passclause. The DKIM signer domain is read fromheader.d=when present (falling back toheader.i=, taking the domain after the AUID's last top-level@so a quoted local-part cannot smuggle an allowlisted domain). Two latent comment-parsing edge cases (early comment-close, no-separator-on-strip) are tracked as hardening follow-ups — see the SES-AR-01/02 issue; neither is reachable through SES's real header encoding today.
Risk acceptance — forwarder-domain binding (INFRA-107, accepted 2026-07-16): for work orders this control authenticates the domain that re-signs the
apm@forward (seahaven.com), not the Hexagon originator (hxgnsmartcloud.com, whose signature does not survive the forward). Its strength therefore rests on theapm@Google Group's posting policy being restricted to trusted internal senders — that restriction is the load-bearing control and is accepted as documented risk. If theapm@group is ever opened to external posting, this finding escalates to HIGH (anyone able to post to the group could inject a forged work order) and the correct fix is to bind acceptance to the originator via DMARC alignment rather than the forwarder's re-signature. The PO pipeline is unaffected —amazon.coupahost.comis an external domain an attacker cannot get SES to sign.
On any failure (env var unset, header missing/unparseable, verdict fail, unaligned domain) the processor logs a structured sender_auth_rejected warning with the reason and S3 key, skips the email, and returns normally — rejected mail never triggers Lambda retries or DLQ messages, but the <fn>-sender-auth-rejected CloudWatch alarm (see CloudWatch alarms) pages on a rejection spike so a drift-induced outage is not silent. Unit tests live in tests/test_ses_auth.py.
Failure handling (INFRA-41): Each email-processor is async-invoked (S3 → Lambda). Both have a CDK-managed SQS dead-letter queue (dead_letter_queue=, 14-day retention, SSL-enforced) so a failed parse is captured rather than silently dropped after Lambda's retries.
CloudWatch alarms
Every alarm is ALARM-only (no OK action), sends to the shared site-alerts SNS topic (imported once per stack via Topic.from_topic_arn), and uses TreatMissingData.NOT_BREACHING.
Lambda alarms (AWS/Lambda, FunctionName dimension):
| Alarm | Functions | Metric / config |
|---|---|---|
<fn>-errors |
po-email-processor, po-ingest-site-extractor, workorder-email-processor |
Errors Sum, 5 min, > 0, eval 1 |
<fn>-throttles |
po-email-processor, po-ingest-site-extractor, po-web-ui, workorder-email-processor |
Throttles Sum, 5 min, > 0, eval 1 |
<fn>-duration |
po-email-processor, po-ingest-site-extractor, po-web-ui (p99); workorder-email-processor (p95) |
Duration percentile, 5 min, >= 45000 ms (75% of the 60s timeout), eval 3 / datapoints 2 |
<fn>-sender-auth-rejected |
po-email-processor, workorder-email-processor |
Log-metric-filter count (namespace Seahaven/ProcurementIngest, default_value=0) on sender_auth_rejected warnings, Sum 5 min, >= 1, eval 3 / datapoints 2 |
The <fn>-sender-auth-rejected alarm closes the silent-drop gap in INFRA-107: a rejected email returns normally (no error, no retry, no DLQ message), so without a log-metric filter a signing-domain drift or a wrong allowlist would discard 100% of legitimate mail while every other alarm stayed green. It counts sender_auth_rejected warnings per 5-minute period (default_value=0 keeps the series continuous) and pages when 2 of the last 3 periods each see at least one rejection — a lone stray spoof probe to the internal ingest address self-clears, but a sustained false-reject storm pages within ~10–15 minutes even at low mail volume; the config is easy to tune in the CDK helper. (A residual gap remains for a very sparse total-reject outage — see the SES-AR-01/02 hardening issue.)
The <fn>-duration and <fn>-throttles alarms for po-email-processor and workorder-email-processor supersede the orphaned, CLI-created Lambda-Duration-* / Lambda-Throttles-* alarms (deleted post-deploy).
DLQ alarms (AWS/SQS): po-email-processor-dlq-messages and workorder-email-processor-dlq-messages fire when any message is visible on an email-processor DLQ (ApproximateNumberOfMessagesVisible Maximum, 5 min, > 0, eval 1) — a message there means an email was dropped after Lambda exhausted its async retries.
Parse-outcome metric + fallback-rate alarm (workorder-ingest): the WO processor writes one CloudWatch EMF line per email to namespace Seahaven/WorkorderIngest, metric ParseOutcome (Unit Count, value 1), dimensioned by ParseMethod (template | ai_fallback | ai_fallback_rejected) and TemplateId (update_plaintext | assign_html | unknown). ai_fallback_rejected counts AI-fallback output that failed the fail-closed validate_ai_fallback() gate (schema/enum/date contract on raw Bedrock output — prompt-injection defence) and was dropped without a DynamoDB write. Non-dimension EMF properties ReasonCode and work_order_id are queryable in Logs Insights but not promoted to metrics (kept low-cardinality). EMF is used instead of PutMetricData so there is no extra sync call / latency / IAM grant on the async hot path (the role already has logs:PutLogEvents). The alarm workorder-email-processor-template-fallback-rate fires when the AI-fallback share of parses — rejected fallback parses included, so a drift outage whose AI output also fails the gate cannot lower the observed rate while dropping mail — exceeds 15% sustained (a MathExpression with FILL(...,0) and a ≥10-sample volume floor over 15-minute periods, eval 3 / datapoints 2) — catching Hexagon template-drift coverage collapse while the volume floor + FILL prevent low-volume false pages / INSUFFICIENT_DATA. ALARM-only SnsAction to site-alerts, no OK action, NOT_BREACHING. The 15-minute period is a deliberate deviation from the 5-minute house style to accumulate a stable denominator at the low ~760/day volume. A second alarm, workorder-email-processor-ai-fallback-rejected, pages on the rejected series itself (≥1 rejection per 5-min period, 2 of the last 6 periods — the sender-auth-rejected sparse-arrival idiom) because a gate rejection drops mail without error/retry/DLQ and would otherwise be silent.
Parse-outcome metric + fallback-rate alarm (po-ingest): the PO processor emits the same EMF shape to namespace Seahaven/PoIngest, metric ParseOutcome, dimensioned by ParseMethod (template | ai_fallback) and TemplateId (coupa_new_po | coupa_cancellation | unknown), with ReasonCode (the fail-closed gate reason) and po_number as Logs-Insights ride-alongs. The alarm po-email-processor-template-fallback-rate is deliberately retuned for PO volume — do NOT copy the WO numbers: at ~57 emails/day a 15-minute period holds ~0.6 emails, so the WO ≥10-sample floor would never be met and the alarm would be structurally dead. Instead: 6-hour periods (~14.25 expected emails each), an IF((fb+tmpl)>=8, …) volume floor (at the floor a single fallback email is 12.5% < the threshold, so one email can never breach a datapoint; a breach needs ≥2 fallbacks in one window, or ≥3 at typical volume), threshold >20% (expected baseline fallback ≈1%: comments 0.55% + multi-line 0.18% + non-USD 0), eval 4 / datapoints 2 (a 24h span — isolated noise self-clears while total template drift at 100% fallback pages within ~12h). Sparse overnight/weekend windows below the floor evaluate to 0 (non-breaching by design; accepted trade: a Friday-evening drift may not page until weekend volume accrues). Same idiom otherwise: ALARM-only SnsAction to site-alerts, NOT_BREACHING, and no element-wise MAX in the math expression (the post-#102 rule — the IF floor guarantees the non-zero denominator).
DynamoDB alarms (AWS/DynamoDB): each owned table gets <table>-throttles (ThrottledRequests) and <table>-system-errors (SystemErrors). These metrics emit only at the TableName + Operation dimension set, so each alarm is a Sum math expression across the operations the table uses (Get/BatchGet/Query/Scan/Put/Update/Delete/BatchWrite). Tables covered: purchase-orders, verified-sites, pending-site-review (po-ingest); WorkOrders, WorkOrderComments (workorder-ingest).
Security
Web UI auth (defense-in-depth). The po-web-ui / workorder-web-ui handlers refuse unauthenticated requests even though their public Function URLs were removed (INFRA-74). Each requires a shared secret in the X-Auth-Token header (or Authorization: Bearer <token>), compared in constant time against the configured token. The handler fails closed if the token is unset or unreadable (denies all). The token lives in the Secrets Manager secret procurement-ingest/web-ui-auth-token; only its ARN is passed to the Lambda (WEB_UI_AUTH_TOKEN_SECRET_ARN), and the value is fetched at runtime — never embedded in the CloudFormation template or Lambda env vars. The fetched value is cached in the warm container with a short TTL (5 min) so a rotated secret propagates without waiting for the execution environment to recycle. This is a defense-in-depth floor for a detached URL, not primary auth.
Output escaping. All caller-influenced values (including prompt-injectable strings Claude may return for total_amount/line-item amounts) are HTML-escaped before interpolation to prevent stored XSS.
Shared Resources
purchase-orders table (owned here)
The purchase-orders DynamoDB table is owned by this repo's po-ingest stack (defined in cdk/po_stack.py with RemovalPolicy.RETAIN, StreamViewType.NEW_IMAGE, and SSE-KMS encryption with the shared customer-managed CMK alias/seahaven-dynamodb, INFRA-95 / M-3). The po-email-processor Lambda is the authoritative writer — it performs the merge inserts, field-level revision merges, and cancellation updates described above.
Consumers (read-only):
| Repo | How it reads | Purpose |
|---|---|---|
seahaven-slack-bot |
po-sync (DynamoDB Streams + daily scan) and wo-po-lookup |
Daily KB sync + Bedrock agent PO lookups |
The consumer imports the table via Table.fromTableName(...) and is granted read-only access (grantReadData); it does not own or define it.
Schema-coordination rule: Any change to the purchase-orders schema (partition key, item shape, attribute names, streams view type) must be coordinated with seahaven-slack-bot. The owner here ships the change; the consumer must be updated in lockstep so its readers do not break. Treat schema changes as a cross-repo migration, not a local edit.
Known exception (INFRA-51): amazon-po-parser currently writes directly to purchase-orders outside this stack (backfill/enrichment scripts). This second writer is being folded into the po-ingest pipeline so this stack is the sole writer; until INFRA-51 closes, coordinate any schema change with amazon-po-parser as well.
WorkOrders and WorkOrderComments tables (owned here)
Both tables are owned by this repo's WorkorderIngestStack (cdk/wo_stack.py, RemovalPolicy.RETAIN):
WorkOrders— PKwork_order_id(S).WorkOrderComments— PKwork_order_id(S), SKcomment_id(S).
comment_idformat change (issue #23). TheWorkOrderCommentsrange key is nowwork_order_id#<comment_time|nocomment>#<sha256(s3_object_key)[:12]>(e.g.11144580730#2026-04-27T23:51:48#a1b2c3d4e5f6, or…#nocomment#…when the source email carries no comment time). Previously it waswork_order_id#<timestamp>, where two emails on the same WO with an identical/absent comment time collided and overwrote each other. The 12-hex suffix is derived from the S3 object key alone — deterministic, so a Lambda async retry of the same object produces a byte-identical key (idempotent, no duplicate row), while two distinct emails on the same WO get distinct keys. Wall-clocknow()is kept out of the key. Consumers that split on#and read index[0]/[1]are unaffected; anything that treated "everything after the first#" as a bare timestamp now also captures the hash segment.
Timestamp format shift. All stored ISO timestamps (
created_at,updated_at,ingested_aton WO;processed_at,cancelled_aton PO) moved from naivedatetime.utcnow().isoformat()to timezone-awaredatetime.now(timezone.utc).isoformat(), so they now carry a+00:00suffix (e.g.2026-07-15T12:00:00+00:00). Downstream parsers that assumed a naive/no-offset string must accept the offset.
Both currently use default DynamoDB encryption — they are not yet on the shared customer-managed CMK (alias/seahaven-dynamodb, INFRA-95 / M-3); that migration is tracked in INFRA-6. The workorder-email-processor role no longer holds a pre-emptive encrypt/decrypt grant on that CMK (removed in the 2026-06-17 security sweep — it was unused while the tables are unencrypted and extended the role's decrypt reach to the CMK protecting purchase-orders). Re-add the grant as part of the INFRA-6 migration, at which point grant_read_write_data on the then-encrypted tables propagates the needed key permissions automatically.
Consumer (read-only) — data contract: seahaven-slack-bot imports both tables via Table.fromTableName(...) (grantReadData) and reads them from two Lambdas: workorder-sync (daily full-table scan into the Bedrock knowledge base) and wo-po-lookup (the Bedrock agent's direct WO lookup action group). The bot depends on the PK/SK schema above and these attributes: on WorkOrders — description, wo_status, customer, site_code, building, address, severity, priority, assigned_to, date_reported, scheduled_start, due_date, updated_at; on WorkOrderComments — created_at (used to sort comments), commenter, text. Any change to table name, key schema, these attribute names, or the encryption configuration (e.g. the INFRA-6 CMK migration) must be coordinated with seahaven-slack-bot before it ships, or the Bedrock agent breaks at runtime (not at deploy — the tables are imported by name, so there is no compile-time link).
verified-sites table (owned here)
Owned by this repo's po-ingest stack (cdk/po_stack.py). PK siteCode (S); default DynamoDB encryption (NOT the shared CMK).
Consumer (read-only) — data contract: seahaven-slack-bot's wo-po-lookup Lambda imports this table via Table.fromTableName(...) for the Bedrock agent's lookup_site action. It does point lookups by siteCode and reads address, fullAddress, city, state, zip, latitude, longitude, notes. Coordinate any change to the table name, key schema, or these attribute names with seahaven-slack-bot.
GSI drift (INFRA-138): the
by-stateGSI was removed here on 2026-06-03 (audit M-20, "0 reads in 30d"), butseahaven-slack-botstill queriesIndexName: 'by-state'for its state-listing path, so that path fails at runtime today. Restoring the GSI or removing the consumer's state path needs to be reconciled cross-repo. This is the kind of silent owner-side lifecycle change this data-contract note exists to prevent.
Documentation
The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This project's po-ingest and WorkorderIngestStack stacks are represented there as Mermaid subgraphs.
- AWS Architecture Map (Confluence, IT space, page 1540098)
CI/CD
GitHub Actions with reusable workflows from Sea-Haven-Industries/.github (all pinned to a commit SHA of main):
- CI (
ci.yaml, PR tomain): linting +cdk synthviaci-python-sam.yaml - CD (
deploy.yaml, push tomain): CDK deploy viacd-cdk.yaml(OIDC auth) - Plus dependency review and PR labeler workflows on every PR
Branch protection on main — all changes through PR.
Setup
- Bootstrap CDK:
cdk bootstrap aws://{AccountId}/us-east-1 - Ensure the Bedrock inference profile
us.anthropic.claude-haiku-4-5-20251001-v1:0is enabled inus-east-1(it is; the CDK grants cover cross-region routing tous-east-2/us-west-2). No API key or secret to set — the processors authenticate to Bedrock via their IAM roles. - Create the web UI auth-gate shared secret. This secret is imported by name
(
Secret.from_secret_name_v2), not CDK-managed, so it must exist before deploy or the web-ui Lambdas fail closed:aws secretsmanager create-secret --name procurement-ingest/web-ui-auth-token --secret-string "$(openssl rand -hex 32)" - Deploy both stacks:
cd cdk pip install -r requirements.txt cdk deploy --all - Post-deploy cleanup (one-time): the retired
RETAIN-policy secretspo-ingest/anthropic-api-keyandworkorder-ingest/anthropic-api-keyare orphaned by this deploy, not deleted. Remove them and revoke the keys at the provider:aws secretsmanager delete-secret --secret-id po-ingest/anthropic-api-key --force-delete-without-recovery aws secretsmanager delete-secret --secret-id workorder-ingest/anthropic-api-key --force-delete-without-recovery - Dashboards:
po-web-uiandworkorder-web-uihave no public endpoint (the Function URLs were removed 2026-06-08, INFRA-74). Invoke them through an authenticated path that forwards theX-Auth-Tokenheader, e.g.aws lambda invoke --function-name po-web-ui /tmp/out.json.
Tests
Offline unit tests (no AWS, no network) run via pytest from the repo root:
pip install pytest
pytest
Coverage:
tests/— shared handler + cross-pipeline tests:parse_raw_emailandpad_zip, the PO merge-write semantics (test_po_merge.py, #97, moto-backed), and the fail-closed sender-authentication parser (test_ses_auth.py, INFRA-107).lambdas/wo/email_processor/tests/— the deterministic WO parser suite: golden-file tests over 55 real scrubbed.emlfixtures (test_parser.py), fail-closed validation-gate rules and adversarial/injection cases (test_validation_gate.py), the issue #23comment_ididempotency invariants (test_comment_id.py), and the Bedrock-fallback dispatch/EMF-metric behavior with a mockedinvoke_model(test_bedrock_fallback.py). Golden JSON lives undertests/fixtures/expected/.lambdas/po/email_processor/tests/— the deterministic PO parser suite: golden-file tests over real scrubbed.emlfixtures (17 single-line new-PO + 8 cancellations, exactDecimal-aware golden comparison viaparse_float=Decimal), fail-closed validation-gate coverage for every gate reason code (fixture-driven for body-level triggers underfixtures/adversarial/, directvalidate()unit tests for candidate-level mutations), real multi-line and comment/non-Coupa fallback fixtures underfixtures/ai-fallback/, dual line-ending (CRLF/LF) parse-identity, two-pathenrich_parsed/save_new_poparity (the site-extractor stream-contract guard), fixture hygiene (ses_authpass + scrub-marker leak sweep), and the Bedrock-fallback dispatch/EMF-metric behavior (test_po_bedrock_fallback.py).
All three roots are discovered by pytest.ini (testpaths).
Scripts
Reprocess PO emails (re-run parser against all emails still in S3):
python scripts/reprocess.py # dry-run
python scripts/reprocess.py --execute # invoke po-email-processor for each
Backfill verified sites (one-time scan of historical POs):
python scripts/backfill_sites.py
Directory Structure
cdk/
app.py # Two stacks: po-ingest + WorkorderIngestStack
po_stack.py # Purchase order pipeline resources
wo_stack.py # Work order pipeline resources
lambdas/
po/ # PO pipeline Lambdas
email_processor/
handler.py # template-first + Bedrock fallback, EMF metric, merge writes
template_parser.py # pure deterministic Coupa parser + fail-closed validation gate
tests/ # golden-file + validation-gate + fallback-dispatch tests + fixtures
site_extractor/
web_ui/
wo/ # WO pipeline Lambdas
email_processor/
handler.py # template-first + Bedrock fallback, EMF metric, #23 comment_id
template_parser.py # pure deterministic parser + fail-closed validation gate
tests/ # golden-file + validation-gate + comment_id + fallback tests
web_ui/
scripts/
reprocess.py
backfill_sites.py
test_local.py # Parse sample emails through Bedrock locally (no AWS mutation)
tests/
requirements.txt # Test-only deps (moto)
conftest.py # AWS env stubs + per-pipeline module loader
test_pad_zip.py # PO zip-code padding tests
test_parse_raw_email.py # MIME parsing tests (PO + WO handlers)
test_po_merge.py # PO merge-write semantics tests (#97)
test_ses_auth.py # Sender-authentication parser tests (INFRA-107)