fix(explorer): /api/decisions returns 422 — coerce decision timestamp to str (#937)

* fix(explorer): coerce decision timestamp to str to prevent 422 on /api/decisions

ContextGraph stores decision timestamps as POSIX floats (e.g. 1786513069.69),
but DecisionResponse.timestamp is typed Optional[str]. Pydantic strict
validation rejects the float and the whole /api/decisions endpoint returns
HTTP 422 "Invalid input", which breaks the Decisions workspace in the
Knowledge Explorer entirely (no decision can be listed).

Coerce the value to str (preserving None) in _node_to_decision so the
response validates. Verified: /api/decisions now returns 200 and the 3
sample decisions render in the Decisions workspace.

* test(explorer): cover decision timestamp coercion in _node_to_decision

Regression tests for the 422 fix in _node_to_decision. Covers the cases
that produced HTTP 422 (float / int timestamps from ContextGraph) and
the ones that must keep working (None, already-string, missing key).

Verified the suite catches the regression: with the fix reverted, the
float / int / nan / inf cases fail with the same ValidationError that
caused the 422; with the fix applied all 6 pass.

* fix(explorer): preserve decision timestamp normalization

The route-level str() cast introduced in the initial fix bypasses
DecisionResponse._normalize_timestamp, the field validator on main that
converts POSIX float epochs to ISO-8601 strings via
datetime.fromtimestamp(value, tz=UTC).isoformat().

With the cast in place the API emits raw numeric strings such as
'1786513069.69' instead of '2026-08-12T05:37:49+00:00', breaking
datetime.fromisoformat() for every caller and failing
TestRecordedDecisions::test_list_decisions_serializes_float_timestamp.
It also silently accepts nan/inf/out-of-range epochs that the validator
is designed to reject.

Restore _node_to_decision() to pass the raw stored value through
unchanged so DecisionResponse._normalize_timestamp remains the single
normalization boundary for all three affected endpoints:
  GET /api/decisions
  GET /api/decisions/{id}
  GET /api/decisions/{id}/precedents

Rewrite test_decision_route_timestamp.py so every assertion uses
datetime.fromisoformat() to verify ISO-8601 output and explicitly
asserts ValidationError for nan, inf, -inf and out-of-range epochs.
Add three TestClient integration tests covering the full production
path: record_decision() -> float stored in graph -> HTTP GET -> JSON.

---------

Co-authored-by: administrator <administrator@administratordeMac-mini.local>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
This commit is contained in:
logan-jl-cc
2026-08-26 16:02:01 +05:30
committed by GitHub
co-authored by administrator Sameer Kadam
parent fa6d645eea
commit 599729f2c0
@@ -0,0 +1,237 @@
"""Regression tests for the /api/decisions 422 bug.
``ContextGraph.record_decision()`` stores ``timestamp`` as a POSIX float
(``datetime.now().timestamp()``). ``DecisionResponse.timestamp`` is typed
``Optional[str]``. Without the ``_normalize_timestamp`` field-validator on
``DecisionResponse`` the raw float fails Pydantic validation and every decision
endpoint returns 422.
The validator lives on ``DecisionResponse`` in ``semantica/explorer/schemas.py``
and converts float/int epochs to ISO-8601 strings via
``datetime.fromtimestamp(value, tz=timezone.utc).isoformat()``.
``_node_to_decision()`` must pass the raw stored value through unchanged so the
validator can do its job. A route-level ``str()`` cast would pre-empt the
validator and produce raw numeric strings instead of ISO-8601, breaking the API
contract and all callers that call ``datetime.fromisoformat()`` on the result.
Each test below is written so that it *fails* when the route-level ``str()``
cast is present (i.e. it would have caught the regression introduced by the
incorrect fix).
"""
import math
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.routes.decisions import _node_to_decision
from semantica.explorer.schemas import DecisionResponse
from semantica.explorer.session import GraphSession
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _decision_node(timestamp):
"""Minimal node dict as returned by the graph session layer."""
return {
"id": "d-test",
"type": "decision",
"properties": {
"category": "loan_underwriting",
"scenario": "A-7291 review",
"reasoning": "DTI within policy",
"outcome": "approved",
"confidence": 0.94,
"timestamp": timestamp,
},
}
def _recorded_client():
"""TestClient backed by a graph built with real record_decision() calls.
This is the path that was broken in production: record_decision() stores
timestamp as a float epoch, which must come out the other side as an
ISO-8601 string, not a raw numeric string.
"""
graph = ContextGraph(advanced_analytics=False)
graph.record_decision(
category="credit_application",
scenario="Personal loan, $85k income, 31% DTI",
reasoning="Income meets threshold; employment stable",
outcome="proceed_to_underwriting",
confidence=0.88,
entities=["applicant_A7291"],
)
return TestClient(create_app(session=GraphSession(graph)))
# ---------------------------------------------------------------------------
# Unit tests: _node_to_decision() → DecisionResponse
#
# Each assertion must fail when the route contains the incorrect str() cast:
# timestamp=None if ... is None else str(properties.get("timestamp"))
# because that cast turns floats into numeric strings such as "1786513069.69"
# rather than ISO-8601 strings such as "2026-08-12T05:37:49+00:00".
# ---------------------------------------------------------------------------
def test_float_timestamp_becomes_iso8601():
"""A POSIX-float epoch must be normalised to an ISO-8601 string.
Fails with the str() cast because str(1786513069.694965) ==
'1786513069.694965', which is not a valid isoformat string.
"""
decision = _node_to_decision(_decision_node(timestamp=1786513069.694965))
assert isinstance(decision.timestamp, str)
# Must parse as a valid ISO-8601 datetime — this is the key assertion that
# the incorrect str() cast breaks.
parsed = datetime.fromisoformat(decision.timestamp)
# Round-trip: parsed timestamp must be within 1 s of the original epoch.
assert abs(parsed.timestamp() - 1786513069.694965) < 1.0
def test_int_timestamp_becomes_iso8601():
"""An integer epoch (no sub-second component) must also become ISO-8601.
Fails with the str() cast because str(1786513069) == '1786513069'.
"""
decision = _node_to_decision(_decision_node(timestamp=1786513069))
assert isinstance(decision.timestamp, str)
parsed = datetime.fromisoformat(decision.timestamp)
assert abs(parsed.timestamp() - 1786513069) < 1.0
def test_none_timestamp_stays_none():
"""A stored None must remain None, not become the string 'None'."""
decision = _node_to_decision(_decision_node(timestamp=None))
assert decision.timestamp is None
def test_missing_timestamp_key_stays_none():
"""A node without a timestamp key at all must not raise and must be None."""
node = {
"id": "d-no-ts",
"type": "decision",
"properties": {"category": "x", "outcome": "y"},
}
decision = _node_to_decision(node)
assert decision.timestamp is None
def test_iso_string_passes_through_unchanged():
"""An already-ISO-8601 string must be returned verbatim."""
iso = "2026-08-12T10:04:20+00:00"
decision = _node_to_decision(_decision_node(timestamp=iso))
assert decision.timestamp == iso
def test_nan_timestamp_raises_validation_error():
"""NaN must be rejected by the validator, not silently accepted.
With the str() cast, str(nan) == 'nan' bypasses the validator's finiteness
check and is silently accepted — this test would pass the incorrect version
of the code if it expected 'nan', but it correctly expects a ValidationError.
"""
with pytest.raises(ValidationError):
_node_to_decision(_decision_node(timestamp=float("nan")))
def test_inf_timestamp_raises_validation_error():
"""Positive infinity must be rejected, not silently accepted as 'inf'."""
with pytest.raises(ValidationError):
_node_to_decision(_decision_node(timestamp=float("inf")))
def test_negative_inf_timestamp_raises_validation_error():
"""Negative infinity must be rejected, not silently accepted as '-inf'."""
with pytest.raises(ValidationError):
_node_to_decision(_decision_node(timestamp=float("-inf")))
def test_out_of_range_epoch_raises_validation_error():
"""A millisecond epoch accidentally passed as seconds must be rejected.
With the str() cast, str(1723600000000) is silently accepted as a string.
The validator correctly raises ValidationError for out-of-range epochs.
"""
with pytest.raises(ValidationError):
_node_to_decision(_decision_node(timestamp=1723600000000))
# ---------------------------------------------------------------------------
# Integration tests: full HTTP path through TestClient
#
# These exercise the complete production path:
# record_decision() → float stored in graph → HTTP GET → JSON response
#
# They are the definitive check: if the route emits numeric strings instead of
# ISO-8601 the fromisoformat() assertion below fails immediately.
# ---------------------------------------------------------------------------
def test_list_decisions_float_timestamp_serialised_as_iso8601():
"""GET /api/decisions must return ISO-8601 timestamps for all decisions.
This is the exact production failure path. record_decision() stores
timestamp as a float; the endpoint must return an ISO-8601 string, not a
raw numeric string like '1786513069.69'.
"""
with _recorded_client() as client:
response = client.get("/api/decisions")
assert response.status_code == 200
payload = response.json()
assert len(payload) >= 1
for item in payload:
ts = item["timestamp"]
assert isinstance(ts, str), f"timestamp must be str, got {type(ts)}"
# This is the line that fails when the str() cast is present:
datetime.fromisoformat(ts)
def test_get_decision_float_timestamp_serialised_as_iso8601():
"""GET /api/decisions/{id} must return an ISO-8601 timestamp."""
with _recorded_client() as client:
decision_id = client.get("/api/decisions").json()[0]["decision_id"]
response = client.get(f"/api/decisions/{decision_id}")
assert response.status_code == 200
ts = response.json()["timestamp"]
assert isinstance(ts, str)
datetime.fromisoformat(ts)
def test_get_precedents_float_timestamp_serialised_as_iso8601():
"""GET /api/decisions/{id}/precedents must return ISO-8601 timestamps."""
graph = ContextGraph(advanced_analytics=False)
for i in range(3):
graph.record_decision(
category="risk",
scenario=f"loan assessment scenario {i}",
reasoning="standard criteria",
outcome="approved",
confidence=0.9,
)
with TestClient(create_app(session=GraphSession(graph))) as client:
decision_id = client.get("/api/decisions").json()[0]["decision_id"]
response = client.get(f"/api/decisions/{decision_id}/precedents")
assert response.status_code == 200
for item in response.json():
ts = item["timestamp"]
assert isinstance(ts, str)
datetime.fromisoformat(ts)