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.
This commit is contained in:
Adam Moussa 2026-07-16 16:02:46 -04:00
parent 6eef832dca
commit 59e57ac5a1
No known key found for this signature in database
4 changed files with 618 additions and 3 deletions

View file

@ -155,7 +155,7 @@ Reason codes emitted by `validate()` / `try_deterministic_parse()` (closed set):
- `email_type` in enum and == template's expected type (`email_type_mismatch`).
- `po_number` valid shape `^[A-Z0-9]{1,6}-\d+$` **and** byte-equals the subject id (`po_id_mismatch` / `missing_required_field`).
**Implemented (new_po):** safe-status enum (`unrecognized_status`), single-line only (`multiline_unsupported`), USD only (`non_usd`).
**Implemented (new_po):** safe-status enum (`unrecognized_status`), single-line only (`multiline_unsupported`), USD only (`non_usd`) — enforced at **both** levels: the Total-block top-level currency must be exactly `USD` (rule 8) **and** every line item's captured currency plus its re-derived `for <amt> <CCY>` body token must byte-equal it (a single non-USD line item fails closed even when the Total block reads USD).
**Implemented (new_po value-level rules V1–V13 — ✅ done, one reason code each):** the gate **re-derives every byte proof from `email_data["body"]`** (never trusting extractor-carried state), with the six value-level reason codes:
- `amount_mismatch` — money fidelity (each Decimal re-serializes byte-identically to its re-derived source token, non-digit/comma border — the `18,624.05`→`624.05` kill switch), `sum(lines) == total` (exact Decimal; deliberately **no** `qty×price == amount` rule — corpus shows partial quantities), dual-`Total` byte-identity, and quantity/unit/price coherence vs the summary and `EA` evidence lines.
@ -238,7 +238,7 @@ Plus `missing_required_field` for the required labeled fields (per-item metadata
3. **Strict single-line/USD-only v1 acceptable?** ✅ **Resolved (PR #1): yes.** Covers ~99.8% of new_po; multi-line and non-USD fail closed with honest reason codes.
4. **Confirm the real cancellation `Status` spelling** — `handler.py` hardcodes `CANCELLED_STATUS='Cancelled'`; the cancellation body doesn't restate it. Verify against a known-cancelled PO in the table. (Doesn't affect parser output — `save_cancellation` hardcodes the string.)
5. **Comments** — should `New Comment on Purchase Order` emails create/update PO records at all, or be dropped? (Out of scope for the parser, but a real data-quality decision.)
6. **Repo fixtures** — ✅ **Resolved (PR #1): committed sanitized corpus** under `lambdas/po/email_processor/tests/fixtures/` (35 scrubbed `.eml`: 17 single-line new-PO, 8 cancellations, 4 comments, 3 non-Coupa, 3 multi-line; plus 17 synthetic adversarial mutations). Transport/auth headers scrubbed to same-shape placeholders (structure preserved, `ses_auth` still passes), per-file digit cipher on identifiers, amounts remapped with `sum==total` re-established, and a narrow `.gitignore` exception scoped to the PO fixtures path (never blanket `*.eml`). Source S3 keys deliberately unrecorded (they ARE the SES receipt tokens the scrub replaced).
6. **Repo fixtures** — ✅ **Resolved (PR #1): committed sanitized corpus** under `lambdas/po/email_processor/tests/fixtures/` (35 scrubbed `.eml`: 17 single-line new-PO, 8 cancellations, 4 comments, 3 non-Coupa, 3 multi-line; plus 18 synthetic adversarial mutations). Transport/auth headers scrubbed to same-shape placeholders (structure preserved, `ses_auth` still passes), per-file digit cipher on identifiers, amounts remapped with `sum==total` re-established, and a narrow `.gitignore` exception scoped to the PO fixtures path (never blanket `*.eml`). Source S3 keys deliberately unrecorded (they ARE the SES receipt tokens the scrub replaced).
**Fixture-build pins recorded during PR #1 (locked by goldens):**
- **`ship_to.street`/`ship_to.address` join convention:** multi-line segments joined with `'\n'`; `address` = the lines from `ship_to.name` through `United States` inclusive (this is what `enrich_parsed` promotes to `ship_to_raw` on the site-extractor stream).

View file

@ -722,6 +722,16 @@ def _validate_new_po_values(candidate, email_data): # noqa: PLR0911, PLR0912, P
return False, "amount_mismatch"
if not _money_border_ok(m.string, serialized):
return False, "amount_mismatch"
# Line-level currency pin (rule 8 companion): rule 8 already proved the
# top-level currency is exactly 'USD', but that only covers the Total
# block. Every line item's captured currency AND its re-derived body
# token must byte-equal it too -- a single non-USD line item fails
# closed even when the Total block reads USD (the non-USD path is
# entirely unexercised at every level, not just the total).
if item.get("currency") != candidate.get("currency"):
return False, "non_usd"
if m.group("cur") != candidate.get("currency"):
return False, "non_usd"
summary_matches = [
m
for i in range(more_detail)

View file

@ -0,0 +1,592 @@
Return-Path: <0100abcdef012345-abcdef01-2345-6789-abcd-ef0123456789-6789ab@mail.coupahost.com>
Received: from a41-238.smtp-out.amazonses.com (a41-238.smtp-out.amazonses.com [203.0.113.25])
by inbound-smtp.us-east-1.amazonaws.com with SMTP id fixture0123456789fixture0123456789fixtur
for amazon_po@int.seahaven.com;
Tue, 12 May 2026 14:02:54 +0000 (UTC)
Received-SPF: pass (spfCheck: domain of mail.coupahost.com designates 203.0.113.25 as permitted sender) client-ip=203.0.113.25; envelope-from=0100abcdef012345-abcdef01-2345-6789-abcd-ef0123456789-6789ab@mail.coupahost.com; helo=a41-238.smtp-out.amazonses.com;
Authentication-Results: amazonses.com;
spf=pass (spfCheck: domain of mail.coupahost.com designates 203.0.113.25 as permitted sender) client-ip=203.0.113.25; envelope-from=0100abcdef012345-abcdef01-2345-6789-abcd-ef0123456789-6789ab@mail.coupahost.com; helo=a41-238.smtp-out.amazonses.com;
dkim=pass header.i=@amazonses.com;
dkim=pass header.i=@amazon.coupahost.com;
dmarc=pass header.from=amazon.coupahost.com;
X-SES-RECEIPT: ScrubbedFixturePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvScrubbedFixturePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvScrubbedFixturePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvScrubbedFixturePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvScrubbedFixturePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvScrubbedFixturePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvScrubbedFixturePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvScrubbedFixturePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvScrubbedFixturePlaceholderTokenVal==
X-SES-DKIM-SIGNATURE: a=rsa-sha256; q=dns/txt; b=Scrub/bedFixturePlaceholderTokenValue01234+56789QqZzXxJjKkWwYyVvScrubbedFixturePlaceholder/Tok+enValue0123456789QqZzXxJjKkWwYyVvScrubbedFixturePlaceh/olderTokenValue012345=; c=relaxed/simple; s=224i4yxa5dv7c2xz3womw6peuasteono; d=amazonses.com; t=1778594574; v=1; bh=ScrubbedFi+xture+PlaceholderTokenValue01234=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple;
s=vtqzvxxcuybp44vxc3gxytxk7c3q5pf4; d=coupahost.com; t=1778594574;
i=@amazon.coupahost.com;
h=Date:From:To:Message-ID:Subject:Mime-Version:Content-Type:Content-Transfer-Encoding;
bh=ScrubbedFi+xture+PlaceholderTokenValue01234=;
b=ScrubbedFixt+urePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvS
crubbedFixturePla/ceholderTokenValue0123456789QqZzXxJ/jK/kWwYyV+vSc
rubbedFixturePlaceholderTokenValue012345=
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple;
s=224i4yxa5dv7c2xz3womw6peuasteono; d=amazonses.com; t=1778594574;
h=Date:From:To:Message-ID:Subject:Mime-Version:Content-Type:Content-Transfer-Encoding:Feedback-ID;
bh=ScrubbedFi+xture+PlaceholderTokenValue01234=;
b=ScrubbedFixturePlaceholderTokenValue0123456789QqZzXxJjKkWwYyVvSc
rubbedFixturePlaceholder+TokenValue0123456789QqZzXxJjK+kWwYyVvScrub
bedFixturePlaceholderTokenValue012345678=
Date: Tue, 12 May 2026 14:02:53 +0000
From: Amazon <do_not_reply@amazon.coupahost.com>
To: amazon_po@int.seahaven.com
Message-ID: <abcdef0123456789-abcdef01-2345-6789-abcd-ef0123456789-abcdef@email.amazonses.com>
Subject: ***Copy for Reference*** New Purchase Order 2D-12503765 has been
issued
Mime-Version: 1.0
Content-Type: multipart/alternative;
boundary="--==_mimepart_6a03330dccc3f_2224e2ae416689";
charset=UTF-8
Content-Transfer-Encoding: 7bit
auto-submitted: auto-generated
X-Auto-Response-Suppress: All
X-Coupa-Message-ID: abcdef01-2345-6789-abcd-ef0123456789@amazon.coupahost.com
Feedback-ID: ::1.us-east-1.ScrubbedFixturePlaceholderTokenValue0123456=:AmazonSES
X-SES-Outgoing: 2026.05.12-203.0.113.25
----==_mimepart_6a03330dccc3f_2224e2ae416689
Content-Type: text/plain;
charset=UTF-8
Content-Transfer-Encoding: quoted-printable
Powered by
http://www.coupa.com
Amazon Purchase Order #2D-12503765
Hi Amazon,
This is to inform you Purchase Order 2D-12503765 from Amazon has been issue=
d, and this same notification has been sent to your fellow team members wit=
h access to the Coupa Supplier Portal.
Submitted By
Priya Raghavan
On Behalf Of
Priya Raghavan
Supplier
SEA HAVEN INDUSTRIES:CORPORATE AP:SERVICES(OK6HE)
Total
55,206.00
USD
Items
GENERAL LABOURER
1.0 EACH x 55,206.00
55,206.00
USD
https://supplier.coupahost.com/orders/12503765?instance_url=3Dhttps%3A%2F%2=
Famazon.coupahost.com%2F&supplier_id=3D648018&
More Detail
PO ID
2D-12503765
Department
None
Status
Issued - Created
Last Opened
None
Order Date
05/12/26
Acknowledged At
None
Revision Date
05/12/26
Payment Term
90 NET
Req #
19886224
Shipping
None
Supplier
SEA HAVEN INDUSTRIES:CORPORATE AP:SERVICES(OK6HE)
80 ORVILLE DR STE 100
BOHEMIA, NY 11716-2505
United States
Shipping
Amazon.com Services LLC (DYO1)
500 Hudson Valley
New Windsor, NY 12553-4744
United States
Location Code: 207735629
Attn: Priya Raghavan
Lines
1.0 EA
GENERAL LABOURER for 55,206.00 CAD
Supplier SEA HAVEN INDUSTRIES:CORPORATE AP:SERVICES(OK6HE) =E2=80=A2 Need=
By 05/12/26 =E2=80=A2 Category Maintenance - Facilities =E2=80=A2 Part N=
umber "HVAC/Heating System Remova" =E2=80=A2 Account 1D-2S31-2231-73970-00=
00-0000-0000 =E2=80=A2 Period 2D Ops 2026
=C2=A0
Total
55,206.00
USD
https://www.coupa.com
Total Spend Management - Make Margins Multiply
To get immediate updates via SMS or change notification preferences, go htt=
ps://supplier.coupahost.com/notification_preferences
----==_mimepart_6a03330dccc3f_2224e2ae416689
Content-Type: text/html;
charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE html>
<html>
<body align=3D"left" style=3D"width: 100% !important; min-width: 100%; box-=
sizing: border-box; font-weight: normal; text-align: left; line-height: 1.3=
; font-size: 16px; margin: 0; padding: 0;" width=3D"100">
<table bgcolor=3D"#f5f7fa" class=3D"new_email_body_container" color=3D"#1b1=
f28" style=3D"background-color: #f5f7fa; width: 100%; color: #1b1f28; font-=
family: Roboto Regular,Arial,sans-serif;" width=3D"100">
<tr>
<td align=3D"center">
<center style=3D"width: 100%;">
<table align=3D"center" class=3D"email_container" style=3D"max-width: 700px=
; width: inherit; text-align: center;" width=3D"0">
<tr>
<td>
<table align=3D"right" class=3D"email_header_footer email_header" role=3D"p=
resentation" style=3D"border-collapse: collapse;">
<tr align=3D"right" style=3D"text-align: right; padding-top: 5px; line-heig=
ht: 20px;">
<td>
<table>
<tr>
<td>
<span color=3D"#6a6969" style=3D"font-size: 12px; line-height: 14px; paddin=
g-right: 2px; color: #6a6969;">
Powered by
</span>
</td>
</tr>
</table>
</td>
<td>
<table>
<tr>
<td>
<a target=3D"_blank" href=3D"http://www.coupa.com"><img style=3D"vertical-a=
lign: middle;" height=3D"21" alt=3D"Coupa Software" title=3D"Coupa Software=
" src=3D"https://amazon.coupahost.com/images/email/coupa_new_logo.png"></a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table bgcolor=3D"white" class=3D"email_primary_card" style=3D"width: 100%;=
background-color: white; min-height: 250px; border-color: #d5dee7; border-=
style: solid; border-width: 4px 0;" width=3D"100">
<tr>
<td>
<table class=3D"email_header_footer" role=3D"presentation" style=3D"width: =
100%; text-align: left; padding: 30px 30px 0;">
<tr>
<td>
<img style=3D"display: inline-block;" height=3D"30" alt=3D"Amazon" title=3D=
"Amazon" src=3D"https://amazon.coupahost.com/public_attachments/EY3V23j4?et=
ag=3D1d7b8acf01de02751f58c89e0463a4a2ba236c4741f2625455da15700a6fac93&amp;s=
tyle=3Dsmall">
</td>
</tr>
<tr>
<td>
<p color=3D"#0971f1" style=3D"color: #0971f1; font-size: 22px; margin: 5px =
0 0;">Amazon Purchase Order #2D-12503765</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class=3D"email_primary_card_body" role=3D"presentation" style=3D"wid=
th: 100%; padding: 15px 30px;">
<tr align=3D"left" color=3D"#1b1f28" style=3D"font-size: 16px; color: #1b1f=
28; line-height: 23px; text-align: left;">
<td>
<p>Hi Amazon,</p>
<p style=3D"width: 688px; max-width: 688px; padding-bottom: 12px;" width=3D=
"688">This is to inform you Purchase Order 2D-12503765 from Amazon has been=
issued, and this same notification has been sent to your fellow team membe=
rs with access to the Coupa Supplier Portal.</p>
<br>
<table style=3D"width: 700px;" width=3D"700">
<tr valign=3D"top">
<td align=3D"left" style=3D"vertical-align: top; text-transform: capitalize=
; padding-right: 3px; padding-bottom: 4px; text-align: left; width: 100px; =
white-space: nowrap;" valign=3D"top" width=3D"100">Submitted By</td>
<td style=3D"min-width: 100px; max-width: 190px; padding-left: 3px; padding=
-bottom: 4px;">Priya Raghavan</td>
</tr>
<tr valign=3D"top">
<td align=3D"left" style=3D"vertical-align: top; text-transform: capitalize=
; padding-right: 3px; padding-bottom: 4px; text-align: left; width: 100px; =
white-space: nowrap;" valign=3D"top" width=3D"100">On Behalf Of</td>
<td style=3D"min-width: 100px; max-width: 190px; padding-left: 3px; padding=
-bottom: 4px;">Priya Raghavan</td>
</tr>
<tr valign=3D"top">
<td align=3D"left" style=3D"vertical-align: top; text-transform: capitalize=
; padding-right: 3px; padding-bottom: 4px; text-align: left; width: 100px; =
white-space: nowrap;" valign=3D"top" width=3D"100">Supplier</td>
<td style=3D"min-width: 100px; max-width: 190px; padding-left: 3px; padding=
-bottom: 4px;">SEA HAVEN INDUSTRIES:CORPORATE AP:SERVICES(OK6HE)</td>
</tr>
<tr valign=3D"top">
<td align=3D"left" style=3D"vertical-align: top; text-transform: capitalize=
; padding-right: 3px; padding-bottom: 4px; text-align: left; width: 100px; =
white-space: nowrap;" valign=3D"top" width=3D"100">Total</td>
<td style=3D"min-width: 100px; max-width: 190px; padding-left: 3px; padding=
-bottom: 4px;">
<b><span title=3D"USD">55,206.00</span></b>
<span style=3D"font-weight: normal; font-size: 9px;">
USD
</span>
</td>
</tr>
<tr valign=3D"top">
<td align=3D"left" style=3D"vertical-align: top; text-transform: capitalize=
; padding-right: 3px; padding-bottom: 4px; text-align: left; width: 100px; =
white-space: nowrap;" valign=3D"top" width=3D"100">Items</td>
<td style=3D"min-width: 100px; max-width: 190px; padding-top: 0; padding-le=
ft: 0; padding-bottom: 4px;">
<table style=3D"width: 450px;" width=3D"700">
<tr>
<td style=3D"min-width: 100px; max-width: 190px; padding-left: 3px; padding=
-bottom: 4px;"><b>GENERAL LABOURER</b></td>
<td align=3D"left" style=3D"max-width: 150px; vertical-align: top; text-tra=
nsform: capitalize; padding-right: 3px; padding-bottom: 4px; text-align: le=
ft; width: 100px; white-space: unset;" valign=3D"top" width=3D"100">
1.0 EACH x <span title=3D"USD">55,206.00</span>
</td>
<td align=3D"right" style=3D"text-align: right; min-width: 100px; max-width=
: 190px; padding-left: 3px; padding-bottom: 4px;">
<b>
<span title=3D"USD">55,206.00</span>
</b>
<span style=3D"font-weight: normal; font-size: 9px;">
USD
</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<br>
<br><table style=3D"width: 100%; text-align: center;" width=3D"100" align=
=3D"center"><tr><td style=3D"text-align: center;" align=3D"center"><table a=
lign=3D"center" cellspacing=3D"0" style=3D"display: inline-block;"><tr>
<td><table cellpadding=3D"0" cellspacing=3D"0" align=3D"center" style=3D"ma=
rgin: 0px 10px 0 0;" class=3D"email_button"><tr><td style=3D"border-width: =
0;"><table cellpadding=3D"0" cellspacing=3D"0" align=3D"center"><tr class=
=3D"button_without_background"><td style=3D"text-align: center; border-radi=
us: 28px; height: 32px; box-sizing: border-box; min-width: 125px; backgroun=
d-color: #0971f1; padding: 6px 16px;" align=3D"center" height=3D"32" bgcolo=
r=3D"#0971f1"><a style=3D"text-decoration: none; text-align: center; font-w=
eight: 500; font-size: 14px; line-height: 20px; color: white;" align=3D"cen=
ter" color=3D"white" href=3D"https://supplier.coupahost.com/orders/12503765=
?instance_url=3Dhttps%3A%2F%2Famazon.coupahost.com%2F&amp;supplier_id=3D648=
018&amp;">View Order</a></td></tr></table></td></tr></table></td>
</tr></table></td></tr></table>
<br><br>
<table style=3D"margin: 0;">
<td bgcolor=3D"transparent" color=3D"#2f2f2f" height=3D"32" style=3D"height=
: 32px; background-color: transparent; font-size: 24px; font-weight: 700; c=
olor: #2f2f2f; font-family: Roboto Regular,Arial,sans-serif; padding: 0;">
More Detail
</td>
</table>
<div style=3D"padding: 10px;">
<table style=3D"width: 100%;" width=3D"100">
<tr>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">PO ID=
</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
">2D-12503765</td>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">Depar=
tment</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
">None</td>
</tr>
<tr>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">Statu=
s</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
">Issued - Created</td>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">Last =
Opened</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
"><span class=3D"show none">None</span></td>
</tr>
<tr>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">Order=
Date</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
">05/12/26</td>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">Ackno=
wledged At</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
"><span class=3D"show none">None</span></td>
</tr>
<tr>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">Revis=
ion Date</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
">05/12/26</td>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">Payme=
nt Term</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
">90 NET</td>
</tr>
<tr>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">Req #=
</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
">19886224</td>
<td style=3D"width: 0;" width=3D"0"></td>
<td align=3D"left" cellspacing=3D"5" style=3D"font-weight: bold; cell-spaci=
ng: 5px; text-transform: capitalize; text-align: left; padding: 3px;">Shipp=
ing</td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
"><span class=3D"show none">None</span></td>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
<td colspan=3D"6">
<br>
</td>
</tr>
</table>
<table style=3D"width: 100%;" width=3D"100">
<tr>
<td style=3D"width: 0;" width=3D"0"></td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
" valign=3D"top">
<div height=3D"42" style=3D"font-weight: bold; font-size: 14px; text-transf=
orm: capitalize; height: 42px;">Supplier</div>
<hr align=3D"left" bgcolor=3D"black" color=3D"black" height=3D"1" style=3D"=
width: 70%; height: 1px; color: black; background-color: black; border-widt=
h: 0;" width=3D"70">
SEA HAVEN INDUSTRIES:CORPORATE AP:SERVICES(OK6HE)
<span class=3D"address">
80 ORVILLE DR STE 100<br>BOHEMIA, NY 11716-2505<br>United States
</span>
</td>
<td style=3D"width: 0;" width=3D"0"></td>
<td style=3D"width: 0;" width=3D"0"></td>
<td cellspacing=3D"5" style=3D"cell-spacing: 5px; padding: 3px 3px 3xp 7px;=
" valign=3D"top">
<div height=3D"42" style=3D"font-weight: bold; font-size: 14px; text-transf=
orm: capitalize; height: 42px;">Shipping</div>
<hr align=3D"left" bgcolor=3D"black" color=3D"black" height=3D"1" style=3D"=
width: 70%; height: 1px; color: black; background-color: black; border-widt=
h: 0;" width=3D"70">
<span class=3D"address">
Amazon.com Services LLC (DYO1)<br>500 Hudson Valley<br>New Windsor, NY 1255=
3-4744<br>United States<br>Location Code: 207735629<br>Attn: Priya Raghavan
</span>
</td>
</tr>
</table>
<table style=3D"margin: 0;"><tr valign=3D"middle"><td style=3D"font-size: 2=
4px; font-weight: 700; color: #2f2f2f; background-color: transparent; paddi=
ng: 0;" color=3D"#2f2f2f" bgcolor=3D"transparent">Lines
</td></tr></table>
<span style=3D"height: 5px;"></span><span style=3D"font-size: 12px; margin-=
left: 12px;">
</span>
<table style=3D"width: 650px; margin-left: 10px; border-collapse: separate;=
" width=3D"650">
<tr>
<td bgcolor=3D"#2d90d9" style=3D"width: 4px; background-color: #2d90d9;" wi=
dth=3D"4"></td>
<td style=3D"padding-left: 3px; font-size: 12px;">
<span style=3D"font-size: 13px;">
1.0 EA
<b>GENERAL LABOURER</b> for <b>55,206.00 USD</b>
<br>
</span>
<span style=3D"font-size: 11px;">
Supplier <strong><span title=3D"35755339-USUSD00-101 80 ORVILLE DR STE 100=
, BOHEMIA, NY 11716-2505, United States">SEA HAVEN INDUSTRIES:CORPORATE AP:=
SERVICES(OK6HE)</span></strong> <span class=3D"bull">=E2=80=A2</span> Need=
By <strong>05/12/26</strong> <span class=3D"bull">=E2=80=A2</span> Catego=
ry <strong>Maintenance - Facilities</strong> <span class=3D"bull">=E2=80=A2=
</span> Part Number <strong>"HVAC/Heating System Remova"</strong> =E2=80=
=A2 Account <strong>1D-2S31-2231-73970-0000-0000-0000</strong> <span class=
=3D"bull">=E2=80=A2</span> Period <strong>2D Ops 2026</strong>
</span>
</td>
</tr>
<tr height=3D"8" style=3D"height: 8px; font-size: 5px;">
<td height=3D"8" style=3D"height: 8px; font-size: 5px;">
=C2=A0
</td>
<td></td>
</tr>
<tr>
<td align=3D"right" colspan=3D"2">
<div style=3D"width: 50%;"></div>
<div align=3D"right" bgcolor=3D"#f7f7f7" style=3D"text-align: right; backgr=
ound-color: #f7f7f7; border-top-width: 1px; border-top-color: black; border=
-top-style: solid; float: right; width: 50%; padding: 10px;" width=3D"50">
<span style=3D"text-align: left;">
<span style=3D"float: left;">
<b>Total</b>
</span>
<span style=3D"float: right;">
<b><span title=3D"USD">55,206.00</span></b>
<span style=3D"font-weight: normal; font-size: 9px;">
USD
</span>
</span>
<span style=3D"font-size: 13px;">
</span>
</span>
</div>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table style=3D"width: 100%;">
<tr>
<td class=3D"email_header_footer">
<table style=3D"width: 100%;">
<tr>
<td>
<div style=3D"text-align: center;" align=3D"center">
<a target=3D"_blank" role=3D"link" tabindex=3D"-1" href=3D"https://www.coup=
a.com"><img height=3D"40px" alt=3D"Coupa Software" title=3D"Coupa Software"=
src=3D"https://amazon.coupahost.com/images/email/coupa_new_logo.png"></a>
<br>
<span align=3D"center" color=3D"#1565c0" style=3D"text-align: center; font-=
size: 14px; color: #1565c0; margin: 10px;">
Total Spend Management - Make Margins Multiply
</span>
<br>
</div>
</td>
</tr>
</table>
<table color=3D"#6a6a6a" style=3D"width: 100%; font-size: 12px; font-family=
: Roboto Regular,Arial,sans-serif; line-height: 12px; color: #6a6a6a; margi=
n: 0;" width=3D"100">
<tr>
<td>
<div style=3D"text-align: center; padding: 5px 0;" align=3D"center">
To get immediate updates via SMS or change notification preferences, go <a =
href=3D"https://supplier.coupahost.com/notification_preferences">here and a=
djust your settings</a>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
</body>
</html>
----==_mimepart_6a03330dccc3f_2224e2ae416689--

View file

@ -50,8 +50,11 @@ ADVERSARIAL_REASONS = {
# safe-status enum
"adv-status-pending": "unrecognized_status",
"adv-missing-status": "unrecognized_status",
# synthetic CAD (no non-USD new_po exists in the corpus)
# synthetic CAD (no non-USD new_po exists in the corpus): Total-block
# variant AND line-item-only variant (Total block still reads USD -- the
# line-level pin must fail closed on its own, rule 8 alone cannot see it)
"adv-non-usd": "non_usd",
"adv-line-non-usd": "non_usd",
# identity proofs
"adv-po-id-mismatch": "po_id_mismatch",
"adv-url-id-mismatch": "po_id_mismatch",
@ -218,6 +221,16 @@ def test_v12_residual_artifact_rejected(artifact):
_assert_reason(cand, "residual_artifact")
def test_line_item_currency_must_equal_usd():
# The non_usd gate is NOT satisfied by the Total block alone: a line item
# whose captured currency diverges from the proven-USD top-level currency
# fails closed (candidate-level mutation; the body-level variant is the
# adv-line-non-usd adversarial fixture).
cand = _good_candidate()
cand["line_items"][0]["currency"] = "CAD"
_assert_reason(cand, "non_usd")
def test_v13_quantity_must_match_summary_line():
cand = _good_candidate()
cand["line_items"][0]["quantity"] = Decimal("2.0") # body says 1.0