Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/PRODUCT_DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ A browser verifier and a downloadable Python bundle verifier let reviewers inspe
| Readiness assessments and recall reporting | Release-enabled: [readiness](../services/ingestion/app/readiness_router.py), [recall reports](../services/ingestion/app/recall_report.py). |
| Bundle verifier and recall-drill endpoint | [Browser verifier](https://regengine.co/verify), [verifier source](../frontend/public/sdk/verify_chain.py). The recall-drill endpoint currently returns no drill data. |
| Supplier manifest remediation and correction links | Implemented in [remediation](../services/ingestion/app/remediation/) and the [buyer dashboard](../frontend/src/app/remediation/page.tsx); excluded from the production router allowlist. Notification delivery also requires recipient/provider configuration. |
| EPCIS JSON-LD export and broader recall simulations | Implemented in [EPCIS export](../services/ingestion/app/epcis_export.py) and [simulations](../services/ingestion/app/recall_simulations.py); these routers are experimental and excluded from the production allowlist. A single EPCIS export returns at most 2,000 events, newest first, and its `metadata.truncated` flag says when it was cut off. Public sample EPCIS downloads are a separate proof surface. |
| EPCIS JSON-LD export and broader recall simulations | Implemented in [EPCIS export](../services/ingestion/app/epcis_export.py) and [simulations](../services/ingestion/app/recall_simulations.py); these routers are experimental and excluded from the production allowlist. A single EPCIS export returns at most 2,000 events, newest first, and the `regengine:truncated` flag inside its `regengine:metadata` block says when it was cut off. Public sample EPCIS downloads are a separate proof surface. |
| Inbound email CSV ingestion | [Postmark integration](../services/ingestion/app/email_ingest/); excluded from the production allowlist, with no self-service provisioning UI. See [rollout notes](engineering/EMAIL_INGESTION.md). |
| Passwordless login and administration | [Email OTP/passkey auth](../services/admin/app/auth/challenges_router.py), tenant/user/API-key management, and session controls. Email delivery requires provider and identity provisioning. |
| Subscription access and usage metering | [Subscription gate](../services/ingestion/app/subscription_gate.py), [metering design and limitations](../DESIGN_metering-billing.md). Usage reporting to Stripe requires an operator call; there is no periodic reporting job. |
Expand Down
13 changes: 9 additions & 4 deletions docs/runbooks/FDA_RECALL_RESPONSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,19 @@ The ZIP bundle (`_build_fda_package()` in `services/ingestion/app/fda_export_ser
|------|----------|
| `fda_spreadsheet_<scope>_<timestamp>.csv` | FDA-oriented spreadsheet, one row per event (`FDA_COLUMNS`) |
| `chain_verification_<scope>_<timestamp>.json` | Hash chain verification result, row hash coverage, completeness summary, redaction flag |
| `evidence_context_<scope>_<timestamp>.json` | Rule catalog and the rule versions that evaluated these events, enforcement mode at export time, and a known-gaps section (KDE coverage, unchecked event types, missing hashes, whether the coverage gate was bypassed with `allow_incomplete`) |
| `VALIDATION_ERRORS_<scope>_<timestamp>.log` | Only when events are missing required KDEs: one line per affected event |
| `evidence_context_<scope>_<timestamp>.json` | Rule catalog and the rule versions that evaluated these events, enforcement mode at export time, and a known-gaps section (KDE coverage, unchecked event types, missing hashes, whether the coverage gate was bypassed with `allow_incomplete`). Before treating the rule-version evidence as complete, check `rule_versions.catalog_status` and `rule_versions.evaluated_status` (also mirrored as `known_gaps.rules_catalog_status` / `known_gaps.rules_evaluated_status`): `ok` means the read succeeded, `unavailable` means the export could not read that table and the section is empty for that reason, `not_requested` means no evaluations were looked up. |
| `VALIDATION_ERRORS_<scope>_<timestamp>.log` | Only when events are missing required KDEs: one line per affected event, built from the completeness summary's `missing_required_events` list, which is capped at 250 entries. When more events are affected than the log lists, `known_gaps.missing_required_events_truncated` in the evidence context is `true` and `known_gaps.missing_required_events_listed` gives the count written; `events_with_missing_required_fields` is the full count |
| `manifest.json` | Package metadata, per-file sizes and SHA-256 digests, Merkle root over the hashed members, pointer to the evidence context file. The manifest is not one of its own Merkle leaves. |
| `README.txt` | Human-readable description of the package and how to verify it |

The PDF format is a separate response (`format=pdf`), not a member of the ZIP. Every member except
`manifest.json` is hashed and listed in `manifest.files`, so a change to any of them, the known-gaps
section included, fails both the browser verifier and `services/admin/scripts/verify_audit_pack.py`.
`manifest.json` is hashed and listed in `manifest.files`. The browser verifier and
`services/admin/scripts/verify_audit_pack.py` compare each member against the per-file hashes and the
Merkle root in `manifest.json`, so a change to any member, the known-gaps section included, without a
matching manifest update fails both. These checks establish that the package is internally consistent;
they do not authenticate the issuer, so a package rebuilt with a regenerated manifest passes them. To
tie a ZIP to the original export, compare it against the `X-Package-Hash` value captured when it was
downloaded (section 3.4).

### 3.3 FDA CSV Column Specification

Expand Down
21 changes: 14 additions & 7 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@
"rollup": "4.59.0",
"esbuild": "0.27.3",
"fast-uri": "^3.1.5",
"minimatch": ">=3.1.3",
"follow-redirects": ">=1.16.2",
"minimatch": ">=11.0.1",
"postcss": "^8.5.18",
"serialize-javascript": "7.0.3",
"workbox-build": {
Expand Down
6 changes: 3 additions & 3 deletions packages/factory-local/requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1235,9 +1235,9 @@ pygments==2.21.0 \
--hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \
--hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c
# via pytest
pypdf==6.21.2 \
--hash=sha256:0a3e1c5c886a1be03646c16a04827cc166c2a0575c971b26a4bf89686551c2d4 \
--hash=sha256:1d44d1a3a200f25062adff0085b02040e4646431ac2ccac22e42679e91466084
pypdf==6.21.4 \
--hash=sha256:33e4fb41ca352810eb4e9838aa0d82d6888a5f8bf682f43e99cce5faa666255c \
--hash=sha256:c8817e3f888d47a087872ded69812c1ef30cacb871d6e053e0c00bbaf0bc2163
# via -r requirements.txt
pytest==9.1.1 \
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
Expand Down
2 changes: 1 addition & 1 deletion packages/factory-local/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ neo4j>=5.23,<6
streamlit>=1.38,<2
pytest>=9.0.3,<10
beautifulsoup4>=4.12,<5
pypdf>=6.21.2,<7
pypdf>=6.21.4,<7
openai>=1.40,<3
python-dotenv>=1.0,<2
2 changes: 1 addition & 1 deletion requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ beautifulsoup4>=4.15.0
lxml>=6.1.3
pdfminer.six>=20260107
pdfplumber>=0.11.10
pypdf>=6.21.2,<7 # CVE-2026-115572/115580/115585; CVE-2026-71852, CVE-2026-71870; earlier: CVE-2026-59935/59936 (inline-image DoS), CVE-2026-48155/48156, GHSA-jm82-fx9c-mx94
pypdf>=6.21.4,<7 # CVE-2026-119203/119204/119205 (6.21.4); CVE-2026-119206/119207/119208/119209 (6.21.3); earlier: CVE-2026-115572/115580/115585, CVE-2026-71852/71870, CVE-2026-59935/59936, CVE-2026-48155/48156, GHSA-jm82-fx9c-mx94
fpdf2>=2.8.9,<3
python-docx>=1.2.0
docx2txt>=0.9
Expand Down
12 changes: 6 additions & 6 deletions requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2999,9 +2999,9 @@ psycopg-binary==3.3.6 \
--hash=sha256:fa34eb47969297471db7b7f193622c7e3ee839ec05abd05f1fe104d5b1b1dcf4 \
--hash=sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8
# via psycopg
pyasn1==0.6.4 \
--hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \
--hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b
pyasn1==0.6.5 \
--hash=sha256:4017b0fce125096b8573b39c989fdb1a4b19cf761e683398184550fea0e66587 \
--hash=sha256:6b11616a6bcf3a40e635f082bb232fa57b5e0dd7f517bd7ddd69ec3794508a47
# via
# pyasn1-modules
# webauthn
Expand Down Expand Up @@ -3202,9 +3202,9 @@ pyotp==2.10.0 \
--hash=sha256:1df2f6a1bcc3bb0716172a5215ddc2f8c7c7fd26a13df9927d52e1746934836c \
--hash=sha256:d01e9703443616b03c57c700b5cbffd56a1f929c1b0f8f03131bc78c1fca9d3f
# via -r requirements.in
pypdf==6.21.2 \
--hash=sha256:0a3e1c5c886a1be03646c16a04827cc166c2a0575c971b26a4bf89686551c2d4 \
--hash=sha256:1d44d1a3a200f25062adff0085b02040e4646431ac2ccac22e42679e91466084
pypdf==6.21.4 \
--hash=sha256:33e4fb41ca352810eb4e9838aa0d82d6888a5f8bf682f43e99cce5faa666255c \
--hash=sha256:c8817e3f888d47a087872ded69812c1ef30cacb871d6e053e0c00bbaf0bc2163
# via -r requirements.in
pypdfium2==5.13.0 \
--hash=sha256:07f58e91b8c45ca144a1ff3008faf3c73ef8a5e9fb32988831788363288228cd \
Expand Down
16 changes: 13 additions & 3 deletions services/admin/tests/test_verify_audit_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,12 +521,22 @@ def test_pack_declares_evidence_context_member(tmp_path):
assert verifier.verify_pack(str(pack_path)) is True


def test_tampered_evidence_context_fails(tmp_path):
def test_tampered_evidence_context_fails(tmp_path, capsys):
# Flip one character inside a string value so the member keeps its
# serialized size: the rejection must come from the hash comparison,
# not from the cheaper size check that runs first.
pack_path, _ = _make_pack(tmp_path)
members = _read_members(pack_path)
name = _evidence_name(members)
context = json.loads(members[name])
context["known_gaps"]["coverage_gate_bypassed"] = not context["known_gaps"]["coverage_gate_bypassed"]
members[name] = json.dumps(context, indent=2, sort_keys=True).encode("utf-8")
status = context["known_gaps"]["verification_status"]
assert isinstance(status, str) and status
context["known_gaps"]["verification_status"] = status[:-1] + ("X" if status[-1] != "X" else "Y")
mutated = json.dumps(context, indent=2, sort_keys=True).encode("utf-8")
assert len(mutated) == len(members[name]), "the mutation must keep the serialized size"
members[name] = mutated
tampered = _write_members(tmp_path, "tampered-evidence.zip", members)
assert verifier.verify_pack(str(tampered)) is False
out = capsys.readouterr().out
assert f"hash mismatch for {name}" in out
assert "size mismatch" not in out
30 changes: 27 additions & 3 deletions services/ingestion/app/epcis/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,20 @@ def _parse_stored_event_time(value: object) -> datetime | None:
return parsed


_EPOCH_UTC = datetime(1970, 1, 1, tzinfo=timezone.utc)


def _fallback_export_sort_key(entry: dict) -> tuple[bool, datetime]:
"""Sort key for fallback-store records: event time, unparseable last.

Mirrors the DB export's ``ORDER BY event_timestamp DESC`` when used
with ``reverse=True``: dated records come first, newest to oldest,
then the records whose event time is missing or unparseable.
"""
event_dt = _parse_stored_event_time((entry.get("normalized_cte") or {}).get("event_time"))
return (event_dt is not None, event_dt or _EPOCH_UTC)


@router.post("/events", status_code=201, summary="Ingest EPCIS 2.0 event")
async def ingest_epcis_event(
event: dict,
Expand Down Expand Up @@ -367,9 +381,19 @@ async def export_epcis_events(
if product_id and normalized.get("product_id") != product_id:
continue
filtered_records.append(entry)
events = [entry["epcis_document"] for entry in filtered_records]
limit = None
truncated = False
if filtered_records:
# The DB path orders by event_timestamp DESC; the fallback
# store iterates in insertion order, so sort it the same way
# to keep ``regengine:ordering`` truthful. Records without a
# parseable event time go last.
filtered_records.sort(key=_fallback_export_sort_key, reverse=True)
events = [entry["epcis_document"] for entry in filtered_records]
# Only the fallback's own events are uncapped. When it supplies
# nothing, the DB page's limit/truncated still describe what
# the caller got: a truncated page that the product filter
# emptied must not read as a complete export.
limit = None
truncated = False

filters = {
f"regengine:{key}": value
Expand Down
45 changes: 45 additions & 0 deletions services/ingestion/tests/test_epcis_router_1342.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,51 @@ def test_fallback_path_reports_no_cap(self, client: TestClient, monkeypatch: pyt
assert meta["regengine:limit"] is None
assert meta["regengine:truncated"] is False

def test_truncated_db_page_emptied_by_filter_stays_truncated(
self, client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A truncated DB page that the product filter emptied is still an incomplete export.

The fallback branch runs because ``events`` is empty, but when the
fallback store has nothing to add it must not reset the DB page's
limit/truncated facts to "uncapped, complete".
"""
monkeypatch.setattr(
router_mod,
"_list_events_from_db_detailed",
lambda *a, **kw: _page([], truncated=True),
)
monkeypatch.setattr(router_mod, "_allow_in_memory_fallback", lambda: True)
monkeypatch.setattr(router_mod, "_fallback_store_for", lambda t: {})

resp = client.get("/api/v1/epcis/export?product_id=prod-rare")
assert resp.status_code == 200
meta = resp.json()["regengine:metadata"]
assert meta["regengine:event_count"] == 0
assert meta["regengine:truncated"] is True
assert meta["regengine:limit"] == persistence_mod.EXPORT_EVENT_LIMIT

def test_fallback_events_are_newest_first(self, client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""The fallback honours the advertised ``event_timestamp desc`` ordering.

Records are inserted oldest-first (store iteration order); the
response must still be newest-first, with undated records last.
"""
monkeypatch.setattr(router_mod, "_list_events_from_db_detailed", _page)
monkeypatch.setattr(router_mod, "_allow_in_memory_fallback", lambda: True)

early, _, late = _ordered_export_times()
fake_store = {
"undated": {"normalized_cte": {}, "epcis_document": {"eventID": "undated"}},
"older": {"normalized_cte": {"event_time": early}, "epcis_document": {"eventID": "older"}},
"newer": {"normalized_cte": {"event_time": late}, "epcis_document": {"eventID": "newer"}},
}
monkeypatch.setattr(router_mod, "_fallback_store_for", lambda t: fake_store)

body = client.get("/api/v1/epcis/export").json()
assert [e["eventID"] for e in body["epcisBody"]["eventList"]] == ["newer", "older", "undated"]
assert body["regengine:metadata"]["regengine:ordering"] == "event_timestamp desc"

def test_db_error_without_fallback_returns_503(self, client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""Line 225: DB error + fallback disabled → 503."""

Expand Down
16 changes: 14 additions & 2 deletions services/ingestion/tests/test_fda_export_recall_1342.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,11 +417,19 @@ def test_non_package_format_still_goes_csv_path(self, monkeypatch):
class TestPackageExport:
def test_package_happy_path(self, monkeypatch):
events = [{"id": "e1"}, {"id": "e2"}, {"id": "e3"}]
_, _, cap = _install_stubs(
sess, _, cap = _install_stubs(
monkeypatch,
rows=[{"id": 1}],
events=events,
)
snapshot = {"catalog_status": "ok", "catalog": {"rule-a": {"version": 2}}, "evaluated": [], "evaluated_status": "ok"}
seen: dict = {}

def _snapshot(db_session, tenant_id, evts):
seen["args"] = (db_session, tenant_id, evts)
return snapshot

monkeypatch.setattr(r, "_rule_versions_for_events", _snapshot)
resp = _call(format="package", include_pii=False)
assert resp == "PKG-RESP"
pkg_k = cap["package_response_kwargs"]
Expand All @@ -433,8 +441,12 @@ def test_package_happy_path(self, monkeypatch):
assert pkg_k["extra_headers"]["X-KDE-Coverage"] == "0.95"
assert pkg_k["include_pii"] is False
# S7: gate outcome and rule-version snapshot reach the package builder.
# The snapshot is taken on the export's own session, for this
# tenant, over exactly the exported events, and is forwarded as-is.
assert pkg_k["coverage_gate_bypassed"] is False
assert "catalog_status" in pkg_k["rule_versions"]
assert seen["args"] == (sess, "t-1", events)
assert seen["args"][0] is sess
assert pkg_k["rule_versions"] is snapshot
# Did NOT go through the CSV response path
assert cap["csv_response_kwargs"] is None

Expand Down
Loading
Loading