Skip to content
8 changes: 5 additions & 3 deletions frontend/src/app/docs/api/ApiReferenceClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,17 +141,19 @@ const API_SERVICES: { name: string; baseUrl: string; icon: React.ComponentType<{
{
method: 'GET',
path: '/trace/{tlc}',
description: 'Trace the transformation graph for a traceability lot code (upstream sources and downstream products). Requires the fda.read scope. Unlike the Records API, FDA endpoints take tenant_id as an explicit query parameter (it must match your key’s workspace).',
description: 'Trace the transformation graph for a traceability lot code. direction selects upstream sources (backward), downstream products (forward) or both (default). depth_exhausted is true when a lot sat at the depth limit and was not expanded, so the graph may continue past the response. Requires the fda.read scope. Unlike the Records API, FDA endpoints take tenant_id as an explicit query parameter (it must match your key’s workspace).',
auth: true,
headers: ['X-RegEngine-API-Key'],
params: ['tenant_id={your-workspace-uuid}'],
params: ['tenant_id={your-workspace-uuid}', 'direction=both', 'depth=5'],
response: JSON.stringify({
seed_tlc: "<the lot code you traced>",
tenant_id: "<your workspace ID>",
traversal_depth: 5,
direction: "both",
depth_exhausted: false,
node_count: 1,
edge_count: 0,
nodes: [{ tlc: "<the lot code you traced>", is_seed: true, role: "seed", event_count: 1 }],
nodes: [{ tlc: "<the lot code you traced>", is_seed: true, role: "seed", depth: 0, event_count: 1 }],
edges: [],
total_events: 1
}, null, 2),
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/trust/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const STATUS_SUMMARY = [
},
{
label: 'Preview',
detail: 'Capability is usable for evaluation in supported workspaces, but is not GA. GS1 EPCIS 2.0 export and ingest stay Preview until separately promoted.',
detail: 'Capability is usable for evaluation in supported workspaces, but has not reached general release. GS1 EPCIS 2.0 export and ingest stay Preview until separately promoted.',
},
{
label: 'Pilot',
Expand Down
26 changes: 0 additions & 26 deletions scripts/copy-lint/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,12 @@
"frontend/src/app/auth/verify/page.tsx": {
"empty-render": 2
},
"frontend/src/app/blog/fda-food-traceability-requirements/page.tsx": {
"epcis-unlabeled": 1
},
"frontend/src/app/blog/food-traceability-software/page.tsx": {
"epcis-unlabeled": 1,
"sample-demo": 2
},
"frontend/src/app/blog/fsma-204-compliance-checklist-2026/page.tsx": {
"invented-entity": 2
},
"frontend/src/app/blog/fsma-204-compliance-guide/page.tsx": {
"epcis-unlabeled": 2
},
"frontend/src/app/blog/fsma-204-cte-kde-guide/page.tsx": {
"epcis-unlabeled": 5
},
"frontend/src/app/contact/layout.tsx": {
"sample-demo": 2
},
Expand All @@ -38,7 +28,6 @@
"empty-render": 1
},
"frontend/src/app/design-system/page.tsx": {
"invented-entity": 1,
"sample-demo": 1,
"sandbox": 1
},
Expand All @@ -58,9 +47,6 @@
"dash-placeholder": 3,
"empty-render": 2
},
"frontend/src/app/fsma-204/[cte]/page.tsx": {
"invented-entity": 1
},
"frontend/src/app/identity/page.tsx": {
"dash-placeholder": 2,
"empty-render": 1
Expand All @@ -78,9 +64,6 @@
"frontend/src/app/legal/vulnerability-disclosure/page.tsx": {
"sample-demo": 1
},
"frontend/src/app/product/page.tsx": {
"sandbox": 2
},
"frontend/src/app/records/page.tsx": {
"dash-placeholder": 3,
"empty-render": 1
Expand All @@ -100,9 +83,6 @@
"frontend/src/app/tools/tlc-validator/components/TLCValidatorClient.tsx": {
"sample-demo": 1
},
"frontend/src/app/why-regengine/page.tsx": {
"epcis-unlabeled": 1
},
"frontend/src/components/dashboard/compliance-status-widget.tsx": {
"empty-render": 1
},
Expand Down Expand Up @@ -133,9 +113,6 @@
"frontend/src/lib/dev-compliance-snapshots.ts": {
"dash-placeholder": 2
},
"frontend/src/lib/epcis-upload.ts": {
"epcis-unlabeled": 4
},
"frontend/src/lib/mock-tenants.ts": {
"sample-demo": 1
},
Expand All @@ -144,8 +121,5 @@
},
"frontend/src/lib/trust-subprocessors.ts": {
"sample-demo": 1
},
"frontend/src/lib/utils.ts": {
"dash-placeholder": 1
}
}
99 changes: 50 additions & 49 deletions services/ingestion/app/canonical_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,49 @@ class TraceResponse(BaseModel):
# exports should either fail-closed or re-query with a higher cap
# when this flag is set. Defaults to False for existing clients.
truncated: bool = False
# ``depth_exhausted=True`` means at least one lot was reached at
# ``max_depth`` and was not expanded: the graph may continue beyond
# the returned links. Reported separately from ``truncated`` so a
# client can tell the result cap from the depth limit. Conservative:
# the unexpanded lot may have no further links. Defaults to False
# for existing clients.
depth_exhausted: bool = False
# The depth limit the walk ran with, so a client reading
# ``depth_exhausted`` knows what to raise on a re-query.
max_depth: int = 5


def _trace_response(store: Any, tid: str, tlc: str, direction: str, max_depth: int) -> TraceResponse:
"""Run one directional walk and shape it as a ``TraceResponse``.

Forward links carry ``output_quantity`` / ``output_unit``; backward
links carry ``input_quantity`` / ``input_unit``. Both map onto the
response's ``quantity`` / ``unit``.
"""
walk = store.trace_walk(tid, tlc, direction, max_depth=max_depth)
qty_key, unit_key = ("output_quantity", "output_unit") if direction == "forward" else ("input_quantity", "input_unit")
links = [
TraceLink(
input_tlc=r["input_tlc"],
output_tlc=r["output_tlc"],
transformation_event_id=r["transformation_event_id"],
process_type=r.get("process_type"),
depth=r["depth"],
confidence_score=r.get("confidence_score", 1.0),
quantity=r.get(qty_key),
unit=r.get(unit_key),
)
for r in walk.links
]
return TraceResponse(
tlc=tlc,
direction=direction,
links=links,
total_hops=max(r["depth"] for r in walk.links) if walk.links else 0,
truncated=walk.truncated,
depth_exhausted=walk.depth_exhausted,
max_depth=walk.max_depth,
)


@router.get(
Expand All @@ -493,31 +536,11 @@ def trace_forward(
set_tenant_guc(db_session, tid)

store = CanonicalEventStore(db_session, dual_write=False)
# #1282: trace_forward now returns (links, truncated). ``truncated``
# means the BFS hit max_results and stopped early; surface it so
# API clients can distinguish a complete trace from a capped one.
raw_links, truncated = store.trace_forward(tid, tlc, max_depth=max_depth)

links = [
TraceLink(
input_tlc=r["input_tlc"],
output_tlc=r["output_tlc"],
transformation_event_id=r["transformation_event_id"],
process_type=r.get("process_type"),
depth=r["depth"],
confidence_score=r.get("confidence_score", 1.0),
quantity=r.get("output_quantity"),
unit=r.get("output_unit"),
)
for r in raw_links
]
return TraceResponse(
tlc=tlc,
direction="forward",
links=links,
total_hops=max(r["depth"] for r in raw_links) if raw_links else 0,
truncated=truncated,
)
# #1282: ``truncated`` means the BFS hit max_results and stopped early;
# ``depth_exhausted`` means ``max_depth`` cut the walk short. Both are
# surfaced so API clients can distinguish a complete trace from a
# capped one.
return _trace_response(store, tid, tlc, "forward", max_depth)


@router.get(
Expand All @@ -538,27 +561,5 @@ def trace_backward(
set_tenant_guc(db_session, tid)

store = CanonicalEventStore(db_session, dual_write=False)
# #1282: trace_backward now returns (links, truncated). See
# trace_forward handler for the rationale.
raw_links, truncated = store.trace_backward(tid, tlc, max_depth=max_depth)

links = [
TraceLink(
input_tlc=r["input_tlc"],
output_tlc=r["output_tlc"],
transformation_event_id=r["transformation_event_id"],
process_type=r.get("process_type"),
depth=r["depth"],
confidence_score=r.get("confidence_score", 1.0),
quantity=r.get("input_quantity"),
unit=r.get("input_unit"),
)
for r in raw_links
]
return TraceResponse(
tlc=tlc,
direction="backward",
links=links,
total_hops=max(r["depth"] for r in raw_links) if raw_links else 0,
truncated=truncated,
)
# See trace_forward for the ``truncated`` / ``depth_exhausted`` contract.
return _trace_response(store, tid, tlc, "backward", max_depth)
54 changes: 43 additions & 11 deletions services/ingestion/app/fda_export/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,12 +850,20 @@ def fetch_trace_graph_data(
tenant_id: str,
tlc: str,
depth: int,
direction: str = "both",
) -> dict:
"""Fetch the transformation trace graph for a TLC.

Returns a dict with nodes, edges, and summary stats.
``direction`` is ``forward`` (downstream outputs), ``backward``
(upstream inputs) or ``both`` (the union of the two directed walks).
Returns a dict with nodes, edges, summary stats, the ``direction``
walked and ``depth_exhausted``: True when a lot was reached at
``depth`` and not expanded, so the graph may continue past what is
returned.
"""
linked_tlcs = persistence._expand_tlcs_via_transformation_links(tenant_id=tenant_id, seed_tlc=tlc, depth=depth)
expansion = persistence._expand_tlcs_detailed(tenant_id=tenant_id, seed_tlc=tlc, depth=depth, direction=direction)
linked_tlcs = expansion.tlcs
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved
reached_via = expansion.reached_via

# Fetch event counts per TLC in one query
if linked_tlcs:
Expand Down Expand Up @@ -887,13 +895,20 @@ def fetch_trace_graph_data(
else:
tlc_stats = {}

# Also fetch direct link edges for the graph visualization
# Edges for the graph visualization: only links whose BOTH ends are
# lots the directed walk returned, so ``nodes`` and ``edges`` describe
# the same graph. A link touching one returned lot and one excluded
# lot (a sibling branch, an opposite-direction neighbour, or a lot
# past the depth limit) used to leak in through an OR predicate and
# name lots absent from ``nodes``.
node_set = set(linked_tlcs)
link_rows = db_session.execute(
text("""
SELECT input_tlc, output_tlc, process_type, confidence_score
FROM fsma.transformation_links
WHERE tenant_id = :tid
AND (input_tlc = ANY(:tlcs) OR output_tlc = ANY(:tlcs))
AND input_tlc = ANY(:tlcs)
AND output_tlc = ANY(:tlcs)
"""),
{"tid": tenant_id, "tlcs": linked_tlcs},
).fetchall()
Expand All @@ -905,19 +920,32 @@ def fetch_trace_graph_data(
"confidence_score": float(row[3]) if row[3] is not None else None,
}
for row in link_rows
# Defence in depth for persistence stubs and older catalogs: the
# SQL already restricts both ends.
if row[0] in node_set and row[1] in node_set
]

def _role(t: str) -> str:
# The role comes from the walk that reached the lot, not from a
# direct edge to the seed: a lot two hops downstream used to be
# labelled "upstream" because only seed-adjacent edges were checked.
if t == tlc:
return "seed"
via = reached_via.get(t)
if via is None:
# Persistence without reach data (older stubs): fall back to
# the seed-adjacent heuristic.
return "downstream" if any(e["input_tlc"] == tlc and e["output_tlc"] == t for e in edges) else "upstream"
if via.get("downstream") and via.get("upstream"):
return "both"
return "downstream" if via.get("downstream") else "upstream"

nodes = [
{
"tlc": t,
"is_seed": t == tlc,
"role": (
"seed"
if t == tlc
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved
else "downstream"
if any(e["input_tlc"] == tlc and e["output_tlc"] == t for e in edges)
else "upstream"
),
"role": _role(t),
"depth": reached_via.get(t, {}).get("depth"),
**tlc_stats.get(t, {"event_count": 0, "first_event": None, "last_event": None}),
}
for t in linked_tlcs
Expand All @@ -927,6 +955,10 @@ def fetch_trace_graph_data(
"seed_tlc": tlc,
"tenant_id": tenant_id,
"traversal_depth": depth,
"direction": expansion.direction,
# True when a lot sat at the depth limit unexpanded: the trace may
# continue past these nodes. Never silently complete (#3701 S2).
"depth_exhausted": expansion.depth_exhausted,
"node_count": len(nodes),
"edge_count": len(edges),
"nodes": nodes,
Expand Down
16 changes: 13 additions & 3 deletions services/ingestion/app/fda_export/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -1283,9 +1283,13 @@ async def get_merkle_proof(
summary="Trace transformation graph for a TLC",
description=(
"Returns all TLCs reachable from the given lot code via "
"fsma.transformation_links — both upstream inputs and downstream outputs. "
"Also returns the full CTE event count per linked lot so the UI can "
"show the trace tree without fetching the full FDA export."
"fsma.transformation_links. ``direction`` selects upstream inputs "
"(backward), downstream outputs (forward) or the union of both walks "
"(both, the default). ``depth_exhausted`` in the response is true when "
"a lot was reached at the depth limit and not expanded, so the graph "
"may continue past what is returned. Also returns the full CTE event "
"count per linked lot so the UI can show the trace tree without "
"fetching the full FDA export."
),
tags=["Traceability"],
)
Expand All @@ -1296,6 +1300,11 @@ def trace_transformation_graph(
tlc: str,
tenant_id: str = Query(..., description="Tenant identifier"),
depth: int = Query(default=5, ge=1, le=10, description="Max traversal depth"),
direction: str = Query(
default="both",
pattern="^(forward|backward|both)$",
description="forward = downstream outputs, backward = upstream inputs, both = union of the two walks",
),
principal: IngestionPrincipal = Depends(require_permission("fda.read")),
_subscription=Depends(require_active_subscription),
) -> dict:
Expand All @@ -1318,6 +1327,7 @@ def trace_transformation_graph(
tenant_id=tenant_id,
tlc=tlc,
depth=depth,
direction=direction,
)

except HTTPException:
Expand Down
Loading
Loading