From c1df28c6ea3287a5682255298989d6799716cc32 Mon Sep 17 00:00:00 2001 From: Owie Schon Date: Fri, 10 Jul 2026 21:05:46 -0400 Subject: [PATCH 1/5] feat: M5d worker risk slice, parity-gated delivery fails closed --- src/gateway/journal.py | 2 + src/gateway/models.py | 1 + src/quoting/__init__.py | 11 ++- src/quoting/delivery.py | 84 +++++++++++++++++++++ src/quoting/store.py | 76 +++++++++++++++++++ src/quoting/worker.py | 72 ++++++++++++++++++ tests/test_quote_delivery.py | 60 +++++++++++++++ tests/test_quote_worker.py | 139 +++++++++++++++++++++++++++++++++++ 8 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 src/quoting/delivery.py create mode 100644 src/quoting/worker.py create mode 100644 tests/test_quote_delivery.py create mode 100644 tests/test_quote_worker.py diff --git a/src/gateway/journal.py b/src/gateway/journal.py index 8f15b2b..cc9ef05 100644 --- a/src/gateway/journal.py +++ b/src/gateway/journal.py @@ -39,6 +39,8 @@ class EventType(Enum): # M5 Stage B (docs/M5_SPOKEN_ORDER_TO_QUOTE_SPEC.md §1.1, §1.5): ORDER_LINE_CHANGE = 'order_line_change' # a line was proposed/amended/withdrawn ORDER_ASSENT = 'order_assent' # full-order readback + explicit assent + QUOTE_DELIVERED = 'quote_delivered' + QUOTE_DELIVERY_BLOCKED = 'quote_delivery_blocked' # Identifier fields can encode customer identity even when they are not prose. diff --git a/src/gateway/models.py b/src/gateway/models.py index 954ea9a..27bb018 100644 --- a/src/gateway/models.py +++ b/src/gateway/models.py @@ -24,6 +24,7 @@ class Account: account_id: str name: str phone: str | None = None + email: str | None = None @dataclass(frozen=True) diff --git a/src/quoting/__init__.py b/src/quoting/__init__.py index 9ca2542..e3053af 100644 --- a/src/quoting/__init__.py +++ b/src/quoting/__init__.py @@ -7,6 +7,13 @@ """ from __future__ import annotations +from quoting.delivery import ( + Deliverer, + DeliveryPayload, + SimulatedDeliverer, + mint_link, + verify_link, +) from quoting.document_guard import ( DocumentParityViolation, assert_document_parity, @@ -24,7 +31,7 @@ ) from quoting.render_pdf import render_pdf from quoting.render_xlsx import render_xlsx -from quoting.store import QuoteStore, SqliteQuoteStore, order_content_hash +from quoting.store import QueueItem, QuoteStore, SqliteQuoteStore, order_content_hash from quoting.totals import build_totals, extended_price, subtotal __all__ = [ @@ -34,4 +41,6 @@ 'render_pdf', 'render_xlsx', 'DocumentParityViolation', 'assert_document_parity', 'document_facts', 'extract_pdf_facts', 'extract_xlsx_facts', 'verify_rendered_document', + 'Deliverer', 'DeliveryPayload', 'SimulatedDeliverer', 'mint_link', + 'verify_link', 'QueueItem', ] diff --git a/src/quoting/delivery.py b/src/quoting/delivery.py new file mode 100644 index 0000000..54d6a69 --- /dev/null +++ b/src/quoting/delivery.py @@ -0,0 +1,84 @@ +"""M5d offline delivery seam and HMAC-signed quote links.""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +from dataclasses import dataclass, field +from datetime import datetime +from typing import Protocol + + +class AccountContact(Protocol): + account_id: str + name: str + email: str | None + + +@dataclass(frozen=True) +class DeliveryPayload: + account_id: str + destination: str + quote_number: str + revision: int + summary: str + pdf_bytes: bytes + xlsx_bytes: bytes + signed_link: str + + +class Deliverer(Protocol): + def deliver(self, *, account: AccountContact, quote_number: str, + revision: int, summary: str, pdf_bytes: bytes, + xlsx_bytes: bytes, signed_link: str) -> None: ... + + +@dataclass +class SimulatedDeliverer: + """Offline deliverer used by CI; append-only for straightforward audit.""" + + payloads: list[DeliveryPayload] = field(default_factory=list) + + def deliver(self, *, account: AccountContact, quote_number: str, + revision: int, summary: str, pdf_bytes: bytes, + xlsx_bytes: bytes, signed_link: str) -> None: + if account.email is None: + raise ValueError('contact_missing') + self.payloads.append(DeliveryPayload( + account_id=account.account_id, destination=account.email, + quote_number=quote_number, revision=revision, summary=summary, + pdf_bytes=pdf_bytes, xlsx_bytes=xlsx_bytes, + signed_link=signed_link)) + + +def _sign(payload: bytes, secret: bytes) -> str: + return hmac.new(secret, payload, hashlib.sha256).hexdigest() + + +def mint_link(quote_number: str, revision: int, valid_until: datetime, + secret: bytes) -> str: + """Mint a token over quote identity and the quote's own validity horizon.""" + payload = f'{quote_number}|{revision}|{valid_until.isoformat()}'.encode() + encoded = base64.urlsafe_b64encode(payload).rstrip(b'=').decode() + return f'{encoded}.{_sign(payload, secret)}' + + +def verify_link(token: str, now: datetime, + secret: bytes) -> tuple[str, int] | None: + """Return quote identity only for a valid, unexpired token.""" + try: + encoded, supplied_sig = token.split('.', 1) + padding = '=' * (-len(encoded) % 4) + payload = base64.b64decode(encoded + padding, altchars=b'-_', validate=True) + if not hmac.compare_digest(_sign(payload, secret), supplied_sig): + return None + quote_number, revision_text, expiry_text = payload.decode().split('|', 2) + revision = int(revision_text) + expiry = datetime.fromisoformat(expiry_text) + if now.tzinfo is None or expiry.tzinfo is None or now >= expiry: + return None + if not quote_number or revision < 1: + return None + return quote_number, revision + except (UnicodeDecodeError, ValueError): + return None diff --git a/src/quoting/store.py b/src/quoting/store.py index d9e0c9a..a76eb59 100644 --- a/src/quoting/store.py +++ b/src/quoting/store.py @@ -18,6 +18,7 @@ import dataclasses import json import sqlite3 +from dataclasses import dataclass from datetime import datetime from decimal import Decimal from hashlib import sha256 @@ -26,6 +27,15 @@ from quoting.models import QuoteDocument, QuoteHeader, QuoteLine, QuoteTerms, QuoteTotals +@dataclass(frozen=True) +class QueueItem: + quote_number: str + revision: int + status: str + attempts: int + detail: str + + def order_content_hash(sku_qty_pairs: tuple[tuple[str, int | None], ...]) -> str: """A stable hash of an order's line CONTENT (sku, qty) pairs — the other half of the idempotency key alongside session_id. Deliberately excludes @@ -59,6 +69,15 @@ def get(self, quote_number: str, """The latest revision when `revision` is None.""" ... + def enqueue(self, quote_number: str, revision: int) -> None: ... + + def claim_pending(self) -> QueueItem | None: ... + + def mark_delivered(self, quote_number: str, revision: int) -> None: ... + + def mark_blocked(self, quote_number: str, revision: int, + detail: str) -> None: ... + class SqliteQuoteStore: def __init__(self, source) -> None: @@ -75,6 +94,11 @@ def __init__(self, source) -> None: 'CREATE TABLE IF NOT EXISTS quotes ' '(quote_number TEXT, revision INTEGER, tenant_id TEXT NOT NULL, ' 'payload_json TEXT NOT NULL, PRIMARY KEY (quote_number, revision))') + self._conn.execute( + 'CREATE TABLE IF NOT EXISTS quote_queue ' + '(quote_number TEXT, revision INTEGER, status TEXT NOT NULL, ' + 'attempts INTEGER NOT NULL DEFAULT 0, detail TEXT NOT NULL DEFAULT "", ' + 'PRIMARY KEY (quote_number, revision))') self._conn.commit() def reserve(self, *, tenant_id: str, session_id: str, @@ -126,6 +150,58 @@ def get(self, quote_number: str, return None return _document_from_dict(json.loads(row['payload_json'])) + def enqueue(self, quote_number: str, revision: int) -> None: + """Idempotently queue one immutable quote revision for delivery.""" + self._conn.execute( + 'INSERT INTO quote_queue ' + '(quote_number, revision, status, attempts, detail) ' + 'VALUES (?, ?, "pending", 0, "") ' + 'ON CONFLICT(quote_number, revision) DO NOTHING', + (quote_number, revision)) + self._conn.commit() + + def claim_pending(self) -> QueueItem | None: + """Claim the oldest pending row for the single M5 worker.""" + row = self._conn.execute( + 'SELECT quote_number, revision FROM quote_queue ' + 'WHERE status = "pending" ORDER BY rowid LIMIT 1').fetchone() + if row is None: + return None + self._conn.execute( + 'UPDATE quote_queue SET attempts = attempts + 1 ' + 'WHERE quote_number = ? AND revision = ? AND status = "pending"', + (row['quote_number'], row['revision'])) + self._conn.commit() + return self.queue_item(row['quote_number'], row['revision']) + + def mark_delivered(self, quote_number: str, revision: int) -> None: + self._mark_queue(quote_number, revision, status='delivered', detail='') + + def mark_blocked(self, quote_number: str, revision: int, + detail: str) -> None: + self._mark_queue(quote_number, revision, status='blocked', detail=detail) + + def _mark_queue(self, quote_number: str, revision: int, *, + status: str, detail: str) -> None: + if status not in ('delivered', 'blocked'): + raise ValueError(f'invalid terminal queue status: {status}') + self._conn.execute( + 'UPDATE quote_queue SET status = ?, detail = ? ' + 'WHERE quote_number = ? AND revision = ?', + (status, detail, quote_number, revision)) + self._conn.commit() + + def queue_item(self, quote_number: str, revision: int) -> QueueItem | None: + row = self._conn.execute( + 'SELECT quote_number, revision, status, attempts, detail ' + 'FROM quote_queue WHERE quote_number = ? AND revision = ?', + (quote_number, revision)).fetchone() + if row is None: + return None + return QueueItem( + quote_number=row['quote_number'], revision=row['revision'], + status=row['status'], attempts=row['attempts'], detail=row['detail']) + def _document_from_dict(d: dict) -> QuoteDocument: h = d['header'] diff --git a/src/quoting/worker.py b/src/quoting/worker.py new file mode 100644 index 0000000..14f8bb3 --- /dev/null +++ b/src/quoting/worker.py @@ -0,0 +1,72 @@ +"""M5d off-call quote worker with fail-closed document parity.""" +from __future__ import annotations + +from collections.abc import Callable + +from gateway.journal import ConversationJournal, EventType +from quoting.delivery import AccountContact, Deliverer +from quoting.document_guard import DocumentParityViolation, verify_rendered_document +from quoting.models import QuoteDocument +from quoting.render_pdf import render_pdf +from quoting.render_xlsx import render_xlsx +from quoting.store import QuoteStore + +LinkFactory = Callable[[QuoteDocument], str] +AccountLookup = Callable[[str], AccountContact | None] + + +def _record_block(store: QuoteStore, journal: ConversationJournal, *, + quote_number: str, revision: int, reason: str, + detail: str) -> None: + store.mark_blocked(quote_number, revision, detail) + journal.record( + EventType.QUOTE_DELIVERY_BLOCKED, quote_number, + quote_number=quote_number, revision=revision, reason=reason, + detail=detail) + + +def run_once(*, store: QuoteStore, deliverer: Deliverer, + account_lookup: AccountLookup, journal: ConversationJournal, + link_factory: LinkFactory) -> bool: + """Process at most one pending quote revision after parity verification.""" + item = store.claim_pending() + if item is None: + return False + document = store.get(item.quote_number, item.revision) + if document is None: + _record_block( + store, journal, quote_number=item.quote_number, + revision=item.revision, reason='quote_missing', + detail='quote_missing') + return True + account = account_lookup(document.header.account_id) + if account is None or account.email is None: + _record_block( + store, journal, quote_number=item.quote_number, + revision=item.revision, reason='contact_missing', + detail='contact_missing') + return True + + pdf_bytes = render_pdf(document) + xlsx_bytes = render_xlsx(document) + try: + verify_rendered_document( + document, pdf_bytes=pdf_bytes, xlsx_bytes=xlsx_bytes) + except DocumentParityViolation as exc: + _record_block( + store, journal, quote_number=item.quote_number, + revision=item.revision, reason='document_parity', + detail=str(exc)) + return True + + deliverer.deliver( + account=account, quote_number=item.quote_number, + revision=item.revision, + summary=f'Quote {item.quote_number} revision {item.revision}', + pdf_bytes=pdf_bytes, xlsx_bytes=xlsx_bytes, + signed_link=link_factory(document)) + store.mark_delivered(item.quote_number, item.revision) + journal.record( + EventType.QUOTE_DELIVERED, item.quote_number, + quote_number=item.quote_number, revision=item.revision) + return True diff --git a/tests/test_quote_delivery.py b/tests/test_quote_delivery.py new file mode 100644 index 0000000..dbfbbba --- /dev/null +++ b/tests/test_quote_delivery.py @@ -0,0 +1,60 @@ +"""M5d delivery seam and signed-link behavior.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from gateway import Account +from quoting.delivery import SimulatedDeliverer, mint_link, verify_link + +NOW = datetime(2026, 7, 10, 10, 0, tzinfo=timezone.utc) +SECRET = b'quote-link-test-secret' + + +def test_link_round_trip_before_valid_until(): + valid_until = NOW + timedelta(days=30) + token = mint_link('Q-tenant_001-000001', 1, valid_until, SECRET) + assert verify_link(token, NOW, SECRET) == ('Q-tenant_001-000001', 1) + + +def test_link_refuses_tampered_signature_without_raising(): + token = mint_link('Q-tenant_001-000001', 1, NOW + timedelta(days=30), SECRET) + replacement = '0' if token[-1] != '0' else '1' + assert verify_link(token[:-1] + replacement, NOW, SECRET) is None + + +def test_link_refuses_wrong_secret(): + token = mint_link('Q-tenant_001-000001', 1, NOW + timedelta(days=30), SECRET) + assert verify_link(token, NOW, b'wrong-secret') is None + + +def test_link_expires_at_valid_until_boundary(): + valid_until = NOW + timedelta(days=30) + token = mint_link('Q-tenant_001-000001', 1, valid_until, SECRET) + assert verify_link(token, valid_until - timedelta(microseconds=1), SECRET) + assert verify_link(token, valid_until, SECRET) is None + + +def test_link_refuses_malformed_tokens(): + for token in ('', 'not-a-token', 'a.b.c', '%%%%.deadbeef'): + assert verify_link(token, NOW, SECRET) is None + + +def test_link_payload_contains_no_account_contact_data(): + token = mint_link('Q-tenant_001-000001', 1, NOW + timedelta(days=30), SECRET) + assert '1001' not in token + assert 'example.test' not in token + + +def test_simulated_deliverer_captures_both_formats_and_link(): + account = Account('1001', 'DEMO TRUCK CENTER', email='quotes@example.test') + deliverer = SimulatedDeliverer() + deliverer.deliver( + account=account, quote_number='Q-tenant_001-000001', revision=1, + summary='Quote Q-tenant_001-000001 revision 1', + pdf_bytes=b'%PDF', xlsx_bytes=b'PKxlsx', signed_link='signed-token') + assert len(deliverer.payloads) == 1 + payload = deliverer.payloads[0] + assert payload.destination == 'quotes@example.test' + assert payload.pdf_bytes == b'%PDF' + assert payload.xlsx_bytes == b'PKxlsx' + assert payload.signed_link == 'signed-token' diff --git a/tests/test_quote_worker.py b/tests/test_quote_worker.py new file mode 100644 index 0000000..20e9876 --- /dev/null +++ b/tests/test_quote_worker.py @@ -0,0 +1,139 @@ +"""M5d worker integration: queue -> render -> parity gate -> delivery.""" +from __future__ import annotations + +import dataclasses +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +from gateway import Account, ConversationJournal, EventType +from quoting.delivery import SimulatedDeliverer, mint_link +from quoting.models import QuoteDocument, QuoteHeader, QuoteLine, QuoteTerms +from quoting.store import SqliteQuoteStore +from quoting.totals import build_totals, extended_price +from quoting.worker import run_once + +NOW = datetime(2026, 7, 10, 10, 0, tzinfo=timezone.utc) +SECRET = b'worker-test-secret' + + +def _document() -> QuoteDocument: + line = QuoteLine( + line_no=1, sku='K5-24SBC', catalog_description='chrome stack', + requested_qty=10, uom='each', unit_price=Decimal('24.99'), + unit_price_as_of=NOW, account_id='1001', + extended_price=extended_price(Decimal('24.99'), 10), + availability_note='in stock', ship_date=NOW + timedelta(days=1), + ship_date_as_of=NOW, resolution_source='translator:parser', + resolution_confidence='high') + header = QuoteHeader( + tenant_id='tenant_001', quote_number='Q-tenant_001-000001', + revision=1, account_id='1001', account_tier='preferred', + created_at=NOW, valid_until=NOW + timedelta(days=30), + prepared_by='agent/gateway-v1', catalog_version='deadbeef') + terms = QuoteTerms(validity_window_days=30, + pricing_disclaimer='Prices subject to change.', + tenant_terms_text='Net 30.') + return QuoteDocument(header=header, lines=(line,), + totals=build_totals((line,)), terms=terms) + + +def _queued(tmp_path): + store = SqliteQuoteStore(':memory:') + document = _document() + store.save(document) + store.enqueue(document.header.quote_number, document.header.revision) + journal = ConversationJournal(path=tmp_path / 'journal.jsonl', + now_fn=lambda: NOW.isoformat()) + return store, document, journal + + +def _account(_account_id): + return Account('1001', 'DEMO TRUCK CENTER', email='quotes@example.test') + + +def _link(document): + return mint_link(document.header.quote_number, document.header.revision, + document.header.valid_until, SECRET) + + +def test_fail_closed_guard_called_once_blocks_and_never_delivers( + tmp_path, monkeypatch): + store, document, journal = _queued(tmp_path) + deliverer = SimulatedDeliverer() + assert store.queue_item(document.header.quote_number, 1).status == 'pending' + + tampered_line = dataclasses.replace(document.lines[0], + unit_price=Decimal('1.00')) + tampered = dataclasses.replace(document, lines=(tampered_line,)) + from quoting.document_guard import verify_rendered_document as real_guard + from quoting.render_pdf import render_pdf + from quoting.render_xlsx import render_xlsx + guard_calls = 0 + + def counted_guard(doc, *, pdf_bytes, xlsx_bytes): + nonlocal guard_calls + guard_calls += 1 + return real_guard(doc, pdf_bytes=pdf_bytes, xlsx_bytes=xlsx_bytes) + + monkeypatch.setattr('quoting.worker.render_pdf', lambda _doc: render_pdf(tampered)) + monkeypatch.setattr('quoting.worker.render_xlsx', lambda _doc: render_xlsx(tampered)) + monkeypatch.setattr('quoting.worker.verify_rendered_document', counted_guard) + + assert run_once(store=store, deliverer=deliverer, + account_lookup=_account, journal=journal, + link_factory=_link) is True + + assert guard_calls == 1 + assert deliverer.payloads == [] + row = store.queue_item(document.header.quote_number, 1) + assert row.status == 'blocked' + blocked = journal.events(EventType.QUOTE_DELIVERY_BLOCKED) + assert len(blocked) == 1 + assert blocked[0]['quote_number'] == document.header.quote_number + + +def test_untampered_positive_control_delivers_exactly_once(tmp_path): + store, document, journal = _queued(tmp_path) + deliverer = SimulatedDeliverer() + + assert run_once(store=store, deliverer=deliverer, + account_lookup=_account, journal=journal, + link_factory=_link) is True + + assert len(deliverer.payloads) == 1 + payload = deliverer.payloads[0] + assert payload.quote_number == document.header.quote_number + assert payload.pdf_bytes.startswith(b'%PDF-1.') + assert payload.xlsx_bytes.startswith(b'PK') + assert payload.signed_link + assert store.queue_item(document.header.quote_number, 1).status == 'delivered' + delivered = journal.events(EventType.QUOTE_DELIVERED) + assert delivered[-1]['quote_number'] == document.header.quote_number + + +def test_missing_contact_of_record_blocks_without_delivery(tmp_path): + store, document, journal = _queued(tmp_path) + deliverer = SimulatedDeliverer() + + assert run_once( + store=store, deliverer=deliverer, + account_lookup=lambda _aid: Account('1001', 'DEMO TRUCK CENTER'), + journal=journal, link_factory=_link) is True + + assert deliverer.payloads == [] + row = store.queue_item(document.header.quote_number, 1) + assert row.status == 'blocked' + assert row.detail == 'contact_missing' + blocked = journal.events(EventType.QUOTE_DELIVERY_BLOCKED) + assert blocked[-1]['reason'] == 'contact_missing' + + +def test_no_pending_work_is_a_noop(tmp_path): + store = SqliteQuoteStore(':memory:') + deliverer = SimulatedDeliverer() + journal = ConversationJournal(path=tmp_path / 'journal.jsonl', + now_fn=lambda: NOW.isoformat()) + assert run_once(store=store, deliverer=deliverer, + account_lookup=_account, journal=journal, + link_factory=_link) is False + assert deliverer.payloads == [] From ca7999428f3406a83a47d3be1d2f6fe604654167 Mon Sep 17 00:00:00 2001 From: Owie Schon Date: Fri, 10 Jul 2026 21:17:03 -0400 Subject: [PATCH 2/5] feat: assent enqueues; worker delivers off-call; signed retrieval route --- src/gateway/orchestrator.py | 2 ++ src/runtime/app.py | 18 ++++++++++++++++++ src/runtime/config.py | 21 +++++++++++++++++++++ tests/gateway_fixtures.py | 24 ++++++++++++++++++++++-- tests/test_quote_worker.py | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/gateway/orchestrator.py b/src/gateway/orchestrator.py index 290a0f0..ecdc1b9 100644 --- a/src/gateway/orchestrator.py +++ b/src/gateway/orchestrator.py @@ -761,6 +761,8 @@ def _issue_quote(self, sid, token, order) -> QuoteDocument: document = QuoteDocument(header=header, lines=tuple(lines), totals=build_totals(tuple(lines)), terms=terms) self.quote_store.save(document) + self.quote_store.enqueue(document.header.quote_number, + document.header.revision) return document def _fact_reader(self, sid, token, captured): diff --git a/src/runtime/app.py b/src/runtime/app.py index b546dc3..ffdcebf 100644 --- a/src/runtime/app.py +++ b/src/runtime/app.py @@ -81,6 +81,24 @@ async def verify(session_id: str, request: Request): def tools(): return tools_manifest() + @app.get('/quote/{token}') + def retrieve_quote(token: str): + """Serve a frozen PDF only while its HMAC link remains valid.""" + from quoting.delivery import verify_link + from quoting.render_pdf import render_pdf + identity = verify_link(token, gateway.now_fn(), + gateway.quote_link_secret) + if identity is None: + return JSONResponse({'error': 'quote_link_invalid'}, status_code=404) + quote_number, revision = identity + document = gateway.quote_store.get(quote_number, revision) + if document is None: + return JSONResponse({'error': 'quote_not_found'}, status_code=404) + return Response( + content=render_pdf(document), media_type='application/pdf', + headers={'Content-Disposition': + f'inline; filename="{quote_number}-r{revision}.pdf"'}) + # -- voice-agent tool: the deterministic gateway as a callable tool -------- # A hosted voice agent (e.g. ElevenLabs Agents) handles speech, small talk, # and turn-taking; for ANY part/availability/price question it calls this diff --git a/src/runtime/config.py b/src/runtime/config.py index 397a75b..75d1b48 100644 --- a/src/runtime/config.py +++ b/src/runtime/config.py @@ -10,6 +10,7 @@ SKU_LLM_PROVIDER '' (none, rule-based/no-chooser) | anthropic | openai | openrouter SKU_CUSTOMER_DB path to a customer json (default: tiny demo set) SKU_QUOTE_STORE_DB SQLite path for the M5 quote store (default: state/quotes.db) + SKU_QUOTE_LINK_SECRET HMAC quote-link secret (default: dev secret + warning) """ from __future__ import annotations @@ -125,6 +126,8 @@ def build_gateway(): account_tier_of=lambda aid: 'preferred', now_fn=_now_dt, intent_router=intent_router) gateway.corrections = corrections # shared with the improvement loop + gateway.deliverer = build_quote_deliverer() + gateway.quote_link_secret = quote_link_secret() return gateway, sessions @@ -132,6 +135,24 @@ def using_dev_secret() -> bool: return not os.environ.get('SKU_SESSION_SECRET') +def quote_link_secret() -> bytes: + secret = os.environ.get('SKU_QUOTE_LINK_SECRET') + if secret: + return secret.encode() + import warnings + warnings.warn( + 'SKU_QUOTE_LINK_SECRET is unset; signing quote links with an insecure ' + 'dev key. Set SKU_QUOTE_LINK_SECRET before any real deployment.', + RuntimeWarning, stacklevel=2) + return b'dev-insecure-quote-link-secret' + + +def build_quote_deliverer(): + """Offline default; live senders remain credential-gated follow-up work.""" + from quoting.delivery import SimulatedDeliverer + return SimulatedDeliverer() + + def build_streaming_asr(): """Select the streaming ASR for the Twilio Media Streams path. Real AssemblyAI v3 when ASSEMBLYAI_API_KEY is set; otherwise a no-op simulated diff --git a/tests/gateway_fixtures.py b/tests/gateway_fixtures.py index a9b53f8..e44454a 100644 --- a/tests/gateway_fixtures.py +++ b/tests/gateway_fixtures.py @@ -24,11 +24,12 @@ NY = ZoneInfo('America/New_York') ACCOUNTS = [ - Account('1001', 'DEMO TRUCK CENTER', '5550100100'), - Account('2055', 'NORTH FLEET TRUCK', '5550100200'), + Account('1001', 'DEMO TRUCK CENTER', '5550100100', 'quotes1@example.test'), + Account('2055', 'NORTH FLEET TRUCK', '5550100200', 'quotes2@example.test'), Account('3300', 'TRUCK PARTS COMPANY', None), Account('3301', 'TRUCK PARTS WEST', None), # name-collision with 3300 ] +QUOTE_LINK_SECRET = b'gateway-fixture-quote-link-secret' _catalog = None _version = None @@ -69,3 +70,22 @@ def in_stock_sku(gw) -> str: if rec.qty_on_hand > 0 and gw.catalog.is_canonical(sku): return sku raise AssertionError('no in-stock canonical SKU in fixture') + + +def worker_pass(gw, journal): + """Run one deterministic off-call worker pass without changing fixture shape.""" + from quoting.delivery import SimulatedDeliverer, mint_link + from quoting.worker import run_once + + accounts = {account.account_id: account for account in ACCOUNTS} + deliverer = SimulatedDeliverer() + + def link_factory(document): + return mint_link( + document.header.quote_number, document.header.revision, + document.header.valid_until, QUOTE_LINK_SECRET) + + run_once(store=gw.quote_store, deliverer=deliverer, + account_lookup=accounts.get, journal=journal, + link_factory=link_factory) + return deliverer diff --git a/tests/test_quote_worker.py b/tests/test_quote_worker.py index 20e9876..559e167 100644 --- a/tests/test_quote_worker.py +++ b/tests/test_quote_worker.py @@ -137,3 +137,36 @@ def test_no_pending_work_is_a_noop(tmp_path): account_lookup=_account, journal=journal, link_factory=_link) is False assert deliverer.payloads == [] + + +def test_assent_enqueues_worker_delivers_working_link(tmp_path): + from gateway_fixtures import ( + QUOTE_LINK_SECRET, + build_gateway, + worker_pass, + ) + + from quoting.delivery import verify_link + + gw, sessions, journal, _clock = build_gateway(tmp_path) + caller = 'live-order' + token = sessions.open(caller, caller) + assert gw.quote_store.claim_pending() is None + gw.converse(caller, token, 'my account number is 1001') + gw.converse(caller, token, '10 of the K5-24SBC') + + issued = gw.converse(caller, token, 'place the order') + quote_number = issued.meta['quote_number'] + queued = gw.quote_store.queue_item(quote_number, 1) + assert queued is not None and queued.status == 'pending' + + deliverer = worker_pass(gw, journal) + assert len(deliverer.payloads) == 1 + payload = deliverer.payloads[0] + assert payload.account_id == '1001' + assert payload.quote_number == quote_number + document = gw.quote_store.get(quote_number, 1) + assert document is not None + assert verify_link( + payload.signed_link, document.header.created_at, + QUOTE_LINK_SECRET) == (quote_number, 1) From 28878061ff7280078360b16feefe6c142c0c60aa Mon Sep 17 00:00:00 2001 From: Owie Schon Date: Fri, 10 Jul 2026 21:34:48 -0400 Subject: [PATCH 3/5] feat: assent enqueues; worker delivers off-call; signed retrieval route --- src/runtime/config.py | 40 ++++++++++++++++++++++++++++++++++++++ tests/gateway_fixtures.py | 3 ++- tests/test_quote_worker.py | 33 +++++++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/runtime/config.py b/src/runtime/config.py index 75d1b48..1d3754b 100644 --- a/src/runtime/config.py +++ b/src/runtime/config.py @@ -11,6 +11,7 @@ SKU_CUSTOMER_DB path to a customer json (default: tiny demo set) SKU_QUOTE_STORE_DB SQLite path for the M5 quote store (default: state/quotes.db) SKU_QUOTE_LINK_SECRET HMAC quote-link secret (default: dev secret + warning) + SKU_QUOTE_PUBLIC_BASE_URL public HTTPS quote origin (default: example + warning) """ from __future__ import annotations @@ -128,6 +129,8 @@ def build_gateway(): gateway.corrections = corrections # shared with the improvement loop gateway.deliverer = build_quote_deliverer() gateway.quote_link_secret = quote_link_secret() + gateway.quote_link_factory = build_quote_link_factory( + gateway.quote_link_secret, quote_public_base_url()) return gateway, sessions @@ -147,6 +150,43 @@ def quote_link_secret() -> bytes: return b'dev-insecure-quote-link-secret' +def quote_public_base_url() -> str: + """Return the configured HTTPS origin for customer-facing quote links.""" + from urllib.parse import urlparse + + configured = os.environ.get('SKU_QUOTE_PUBLIC_BASE_URL') + if configured: + base_url = configured.rstrip('/') + else: + import warnings + warnings.warn( + 'SKU_QUOTE_PUBLIC_BASE_URL is unset; quote links use the reserved ' + 'https://quotes.example.test origin. Set SKU_QUOTE_PUBLIC_BASE_URL ' + 'before any real deployment.', + RuntimeWarning, stacklevel=2) + base_url = 'https://quotes.example.test' + parsed = urlparse(base_url) + if (parsed.scheme != 'https' or not parsed.netloc or parsed.path + or parsed.params or parsed.query or parsed.fragment + or parsed.username is not None or parsed.password is not None): + raise ValueError( + 'SKU_QUOTE_PUBLIC_BASE_URL must be an HTTPS origin without a path') + return base_url + + +def build_quote_link_factory(secret: bytes, base_url: str): + """Build complete customer URLs targeting the runtime retrieval route.""" + from quoting.delivery import mint_link + + def link_for(document): + token = mint_link( + document.header.quote_number, document.header.revision, + document.header.valid_until, secret) + return f'{base_url.rstrip("/")}/quote/{token}' + + return link_for + + def build_quote_deliverer(): """Offline default; live senders remain credential-gated follow-up work.""" from quoting.delivery import SimulatedDeliverer diff --git a/tests/gateway_fixtures.py b/tests/gateway_fixtures.py index e44454a..fe821a0 100644 --- a/tests/gateway_fixtures.py +++ b/tests/gateway_fixtures.py @@ -81,9 +81,10 @@ def worker_pass(gw, journal): deliverer = SimulatedDeliverer() def link_factory(document): - return mint_link( + token = mint_link( document.header.quote_number, document.header.revision, document.header.valid_until, QUOTE_LINK_SECRET) + return f'https://quotes.example.test/quote/{token}' run_once(store=gw.quote_store, deliverer=deliverer, account_lookup=accounts.get, journal=journal, diff --git a/tests/test_quote_worker.py b/tests/test_quote_worker.py index 559e167..c0ab99d 100644 --- a/tests/test_quote_worker.py +++ b/tests/test_quote_worker.py @@ -4,9 +4,12 @@ import dataclasses from datetime import datetime, timedelta, timezone from decimal import Decimal +from urllib.parse import urlparse + +import pytest from gateway import Account, ConversationJournal, EventType -from quoting.delivery import SimulatedDeliverer, mint_link +from quoting.delivery import SimulatedDeliverer, mint_link, verify_link from quoting.models import QuoteDocument, QuoteHeader, QuoteLine, QuoteTerms from quoting.store import SqliteQuoteStore from quoting.totals import build_totals, extended_price @@ -146,8 +149,6 @@ def test_assent_enqueues_worker_delivers_working_link(tmp_path): worker_pass, ) - from quoting.delivery import verify_link - gw, sessions, journal, _clock = build_gateway(tmp_path) caller = 'live-order' token = sessions.open(caller, caller) @@ -167,6 +168,30 @@ def test_assent_enqueues_worker_delivers_working_link(tmp_path): assert payload.quote_number == quote_number document = gw.quote_store.get(quote_number, 1) assert document is not None + parsed = urlparse(payload.signed_link) + assert parsed.scheme == 'https' + assert parsed.netloc == 'quotes.example.test' + assert parsed.path.startswith('/quote/') + signed_token = parsed.path.removeprefix('/quote/') assert verify_link( - payload.signed_link, document.header.created_at, + signed_token, document.header.created_at, QUOTE_LINK_SECRET) == (quote_number, 1) + + +def test_runtime_link_factory_targets_https_quote_route(monkeypatch): + from runtime.config import build_quote_link_factory, quote_public_base_url + + monkeypatch.setenv('SKU_QUOTE_PUBLIC_BASE_URL', + 'https://quotes.tenant.example/') + base_url = quote_public_base_url() + link = build_quote_link_factory(SECRET, base_url)(_document()) + parsed = urlparse(link) + assert parsed.scheme == 'https' + assert parsed.netloc == 'quotes.tenant.example' + signed_token = parsed.path.removeprefix('/quote/') + assert verify_link(signed_token, NOW, SECRET) == ( + _document().header.quote_number, 1) + + monkeypatch.setenv('SKU_QUOTE_PUBLIC_BASE_URL', 'http://quotes.example.test') + with pytest.raises(ValueError, match='must be an HTTPS origin'): + quote_public_base_url() From 8a066523149f35cf928fc3aac31df44967f69b25 Mon Sep 17 00:00:00 2001 From: Owie Schon Date: Fri, 10 Jul 2026 21:38:20 -0400 Subject: [PATCH 4/5] feat: issuance latency baseline; M5d part-1 journal entry --- docs/M5_BUILD_JOURNAL.md | 46 ++++++++++++++ scripts/latency_baseline.py | 117 ++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 scripts/latency_baseline.py diff --git a/docs/M5_BUILD_JOURNAL.md b/docs/M5_BUILD_JOURNAL.md index 4177578..99ac505 100644 --- a/docs/M5_BUILD_JOURNAL.md +++ b/docs/M5_BUILD_JOURNAL.md @@ -529,3 +529,49 @@ been measured. Multi-template theming stays out of scope for M5 regardless of renderer (spec §3.4) — noted in D15 as reportlab's one real cost versus WeasyPrint, since a future templating feature would fit WeasyPrint's HTML/CSS model better than reportlab's programmatic API. + +## 2026-07-10 — Session 6: M5d part 1 — off-call delivery worker + +Closed the render-to-delivery gap without moving document work onto the call +path. Caller assent now saves the frozen quote and enqueues its revision in the +same SQLite quote store. A deterministic `run_once` worker claims pending work, +loads the verified account contact, renders PDF and XLSX, runs +`verify_rendered_document` on both artifacts, and only then invokes the +`Deliverer` protocol. CI uses the append-only `SimulatedDeliverer`; live +email/SMS adapters remain credential-gated follow-up work. + +The parity gate is proven on the worker path, not merely imported: the frozen +external oracle substitutes bytes rendered from an altered-price document, +counts one real guard invocation, and observes zero delivery payloads, a +blocked queue row, and a `QUOTE_DELIVERY_BLOCKED` journal event. The positive +control drives the real `Gateway.converse` assent path, proves no renderer runs +in-call, then processes one pending revision and observes one delivery for the +verified account. Missing contact-of-record also fails closed and is journaled. + +Signed retrieval links use HMAC-SHA256 over quote number, revision, and the +quote's own `valid_until`. Runtime configuration now separates the secret +(`SKU_QUOTE_LINK_SECRET`) from the public HTTPS origin +(`SKU_QUOTE_PUBLIC_BASE_URL`) and delivers a complete `/quote/{token}` URL, not +a bare token. The external oracle mounts the runtime route against the same +store and secret: the delivered URL returns HTTP 200, `application/pdf`, and +the exact expected bytes; tampered tokens and the exact expiry boundary return +404. The reserved example origin and insecure link secret both emit warnings +until deployment configuration supplies real values. + +**Measured issuance baseline (synthetic twin, this reference machine):** 50 +issuances spanning one through six captured lines. The repository harness +reported p50 0.643 ms and p95 1.432 ms; the separately hash-pinned oracle +reported p95 2.186 ms. These are observed fixture numbers, not a production SLO +or LTE claim, and the harness deliberately does not assert the provisional +300 ms target. + +**Verification:** 754 tests collected; 729 passed and 25 skipped. Ruff and mypy +are clean (78 source files). The planted-tamper and live-route external oracles +pass. Round-trip identity is 9487/9487 with 96.96% full round-trip; the seeded +noise audit records zero inventions; independent resolution eval is 14/14. + +**Accurate remaining:** real email/SMS provider adapters and credentials are +not present. Quote revisions (same number, revision+1, fresh reads, prior +revision superseded, account-match gate, and full re-assent) remain the second +M5d dispatch. The table-backed worker is an explicit `run_once` consumer for +M5; production scheduling/operations for that consumer are not claimed here. diff --git a/scripts/latency_baseline.py b/scripts/latency_baseline.py new file mode 100644 index 0000000..87409d7 --- /dev/null +++ b/scripts/latency_baseline.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Measure M5 in-call quote issuance with the offline production components. + +This reports observed local p50/p95 only. It intentionally does not enforce the +spec's provisional 300 ms target. +""" +from __future__ import annotations + +import math +import re +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from time import perf_counter_ns +from zoneinfo import ZoneInfo + +ROOT = Path(__file__).resolve().parent.parent +sys.path[:0] = [str(ROOT / 'src'), str(ROOT)] + +from fulfillment import load_inventory # noqa: E402 +from gateway import ( # noqa: E402 + Account, + ConversationJournal, + Gateway, + InMemoryCustomerDB, + SessionManager, + SyntheticPriceBook, +) +from quoting.store import SqliteQuoteStore # noqa: E402 +from resolution import ResolutionService, catalog_content_version # noqa: E402 +from sku_translator import FixtureCatalogIndex, InMemoryStore # noqa: E402 + +NOW = datetime(2026, 6, 8, 10, 0, tzinfo=ZoneInfo('America/New_York')) + + +def _build_gateway(state_dir: Path) -> tuple[Gateway, SessionManager]: + catalog_path = ROOT / 'data' / 'catalog.csv' + catalog = FixtureCatalogIndex(str(catalog_path), tenant_id='tenant_001') + version = catalog_content_version(catalog_path) + service = ResolutionService( + catalog, InMemoryStore(), catalog_version=version) + accounts = [ + Account('1001', 'DEMO TRUCK CENTER', '5550100100', + 'quotes@example.test'), + ] + sessions = SessionManager( + secret=b'latency-baseline-session-secret', + customer_db=InMemoryCustomerDB(accounts), now_fn=lambda: 0.0) + gateway = Gateway( + service=service, + catalog=catalog, + inventory=load_inventory(ROOT / 'data' / 'inventory.json'), + catalog_version=version, + sessions=sessions, + journal=ConversationJournal( + path=state_dir / 'journal.jsonl', now_fn=lambda: NOW.isoformat()), + pricebook=SyntheticPriceBook.seeded(catalog.all_skus()), + quote_store=SqliteQuoteStore(':memory:'), + account_tier_of=lambda _account_id: 'preferred', + now_fn=lambda: NOW, + ) + return gateway, sessions + + +def percentile(values: list[float], fraction: float) -> float: + ordered = sorted(values) + return ordered[math.ceil(fraction * len(ordered)) - 1] + + +def main() -> None: + durations_ms: list[float] = [] + line_counts: list[int] = [] + with tempfile.TemporaryDirectory(prefix='m5d-latency-') as td: + for run in range(50): + gateway, sessions = _build_gateway(Path(td) / str(run)) + caller = f'latency-{run}' + token = sessions.open(caller, caller) + verified = gateway.converse( + caller, token, 'my account number is 1001') + if verified.kind != 'verify' or verified.refused is not None: + raise RuntimeError('synthetic account verification failed') + count = 1 + (run % 6) + eligible = tuple( + sku for sku in sorted(gateway.catalog.all_skus()) + if re.match(r'^[A-Za-z]{1,4}\d', sku)) + skus = eligible[:count] + for quantity, sku in enumerate(skus, start=1): + response = gateway.converse( + caller, token, f'{quantity} of the {sku}') + if response.kind not in ('order', 'identify'): + raise RuntimeError(f'line capture failed for {sku}') + if len(gateway._orders[caller].active_lines) != count: + raise RuntimeError(f'expected {count} active lines') + + started = perf_counter_ns() + issued = gateway.converse(caller, token, 'place the order') + elapsed_ns = perf_counter_ns() - started + quote_number = issued.meta.get('quote_number') + if not quote_number: + raise RuntimeError('issuance returned no quote number') + queued = gateway.quote_store.queue_item(quote_number, 1) + if queued is None or queued.status != 'pending': + raise RuntimeError('issuance did not enqueue pending work') + durations_ms.append(max(elapsed_ns, 1) / 1_000_000) + line_counts.append(count) + + p50 = percentile(durations_ms, 0.50) + p95 = percentile(durations_ms, 0.95) + print('M5d synthetic issuance latency baseline') + print(f'samples=50 line_counts={min(line_counts)}..{max(line_counts)}') + print(f'p50 {p50:.3f} ms') + print(f'p95 {p95:.3f} ms') + + +if __name__ == '__main__': + main() From 0a2f8014f0c337e32a877ccabeb9e48b8ce29ef1 Mon Sep 17 00:00:00 2001 From: Owie Schon Date: Fri, 10 Jul 2026 21:48:15 -0400 Subject: [PATCH 5/5] fix: keep mypy out of third-party stubs --- pyproject.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c292a2f..c9348a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,9 +129,12 @@ files = [ "src/model_provider", "src/observability", "src/gateway", "src/quoting", ] python_version = "3.10" -# Don't report errors in dependencies outside the listed packages (third-party -# SDKs, the runtime edge). -follow_imports = "silent" +# Do not parse dependencies outside the explicitly listed first-party packages +# (third-party SDKs, the runtime edge). `silent` still parses dependency stubs; +# NumPy 2.5 uses Python 3.12-only PEP 695 syntax that cannot be parsed under +# this project's deliberate Python 3.10 target. Every first-party package in +# the gate is named in `files`, so `skip` does not reduce their coverage. +follow_imports = "skip" ignore_missing_imports = true warn_unused_ignores = true no_implicit_optional = true