Describe the bug
AttackResultEntry (the SQLAlchemy model in
pyrit/memory/memory_models.py) persists a non-nullable timestamp
column, but AttackResultEntry.get_attack_result() does not copy that
column onto the hydrated AttackResult. The domain AttackResult
dataclass has no timestamp field at all.
Downstream, attack_result_to_summary in
pyrit/backend/mappers/attack_mappers.py looks up
ar.metadata["created_at"] and, when absent (PyRIT's own attack
engines never set it), falls back to datetime.now(timezone.utc).
Net effect: the backend UI shows today's date under "Created" for
every AttackResult, regardless of when it was actually persisted.
Relevant locations on main:
pyrit/models/attack_result.py — AttackResult dataclass has no timestamp field.
pyrit/memory/memory_models.py — AttackResultEntry.timestamp column is nullable=False; get_attack_result() rebuilds AttackResult without passing timestamp
through.
pyrit/backend/mappers/attack_mappers.py — attack_result_to_summary uses datetime.now(timezone.utc) as the fallback.
Steps/Code to Reproduce
from datetime import datetime, timezone
from unittest.mock import MagicMock
from pyrit.memory.memory_models import AttackResultEntry
from pyrit.models.attack_result import AttackResult, AttackOutcome
from pyrit.backend.mappers.attack_mappers import attack_result_to_summary
ar = AttackResult(
conversation_id="c1",
objective="demo",
outcome=AttackOutcome.SUCCESS,
)
entry = AttackResultEntry(entry=ar)
persisted_ts = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc)
entry.timestamp = persisted_ts
hydrated = entry.get_attack_result()
stats = MagicMock(message_count=0, last_message_preview="", labels=None)
summary = attack_result_to_summary(hydrated, stats=stats)
print(f"Persisted at: {persisted_ts}")
print(f"Shown in summary: {summary.created_at}")
print(f"hydrated has 'timestamp' attr: {hasattr(hydrated, 'timestamp')}")
assert summary.created_at == persisted_ts, "created_at should reflect DB timestamp"
Expected Results
summary.created_at equals persisted_ts (2026-04-17 12:00:00+00:00).
The assertion passes.
Actual Results
Persisted at: 2026-04-17 12:00:00+00:00
Shown in summary: 2026-04-24 12:10:29.669801+00:00
hydrated has 'timestamp' attr: False
AssertionError: created_at should reflect DB timestamp
summary.created_at is datetime.now(timezone.utc) at the moment the
mapper runs. hydrated does not even carry a timestamp attribute,
because AttackResult has no such field.
Screenshots
N/A — reproducible without the SPA.
Versions
- OS: macOS 26.4.1 (arm64); also expected to reproduce on any OS — the bug is OS-independent.
- Python version: 3.14.3
- PyRIT version: main in editable mode, 0.14.0.dev0. Also reproduces on 0.13.0.
- pyrit.show_versions() output:
System:
python: 3.14.3 (main, Feb 3 2026, 15:32:20) [Clang 17.0.0 (clang-1700.6.3.2)]
executable: /path/to/.venv/bin/python
machine: macOS-26.4.1-arm64-arm-64bit-Mach-O
Python dependencies:
pyrit: 0.14.0.dev0
Cython: None
numpy: 2.4.4
openai: 2.32.0
packaging: 26.1
pip: 26.0.1
scipy: 1.17.1
setuptools: 82.0.1
sqlite3: None
torch: None
transformers: 5.6.2
Proposed fix (happy to submit a PR if accepted)
Three small, additive changes:
- Add an optional timestamp: Optional[datetime] = None field to the
AttackResult dataclass.
- Pass timestamp=_ensure_utc(self.timestamp) in
AttackResultEntry.get_attack_result(). The _ensure_utc helper
already exists in memory_models.py and is used by sibling
hydrators.
- In attack_result_to_summary, consult ar.timestamp before falling
back to datetime.now(timezone.utc). Keep the
metadata["created_at"] path for callers that set it explicitly.
Backwards compatibility:
- Adding an optional dataclass field is additive.
- AttackResultEntries.timestamp has been non-nullable since the
entry was introduced, so every existing row already has a value to
hydrate from.
- Callers that construct AttackResult(...) without specifying
timestamp get None, which the mapper treats the same way it
already treats missing metadata["created_at"].
Tests would be added to tests/unit/models/test_attack_result.py and
tests/unit/backend/test_mappers.py covering (a) round-trip of
timestamp through AttackResult → AttackResultEntry → AttackResult
and (b) the mapper preferring ar.timestamp over datetime.now().
Describe the bug
AttackResultEntry(the SQLAlchemy model inpyrit/memory/memory_models.py) persists a non-nullabletimestampcolumn, but
AttackResultEntry.get_attack_result()does not copy thatcolumn onto the hydrated
AttackResult. The domainAttackResultdataclass has no
timestampfield at all.Downstream,
attack_result_to_summaryinpyrit/backend/mappers/attack_mappers.pylooks upar.metadata["created_at"]and, when absent (PyRIT's own attackengines never set it), falls back to
datetime.now(timezone.utc).Net effect: the backend UI shows today's date under "Created" for
every
AttackResult, regardless of when it was actually persisted.Relevant locations on
main:pyrit/models/attack_result.py—AttackResultdataclass has notimestampfield.pyrit/memory/memory_models.py—AttackResultEntry.timestampcolumn isnullable=False;get_attack_result()rebuildsAttackResultwithout passingtimestampthrough.
pyrit/backend/mappers/attack_mappers.py—attack_result_to_summaryusesdatetime.now(timezone.utc)as the fallback.Steps/Code to Reproduce
Expected Results
summary.created_at equals persisted_ts (2026-04-17 12:00:00+00:00).
The assertion passes.
Actual Results
Persisted at: 2026-04-17 12:00:00+00:00
Shown in summary: 2026-04-24 12:10:29.669801+00:00
hydrated has 'timestamp' attr: False
AssertionError: created_at should reflect DB timestamp
summary.created_at is datetime.now(timezone.utc) at the moment the
mapper runs. hydrated does not even carry a timestamp attribute,
because AttackResult has no such field.
Screenshots
N/A — reproducible without the SPA.
Versions
System:
python: 3.14.3 (main, Feb 3 2026, 15:32:20) [Clang 17.0.0 (clang-1700.6.3.2)]
executable: /path/to/.venv/bin/python
machine: macOS-26.4.1-arm64-arm-64bit-Mach-O
Python dependencies:
transformers: 5.6.2
Proposed fix (happy to submit a PR if accepted)
Three small, additive changes:
AttackResult dataclass.
AttackResultEntry.get_attack_result(). The _ensure_utc helper
already exists in memory_models.py and is used by sibling
hydrators.
back to datetime.now(timezone.utc). Keep the
metadata["created_at"] path for callers that set it explicitly.
Backwards compatibility:
entry was introduced, so every existing row already has a value to
hydrate from.
timestamp get None, which the mapper treats the same way it
already treats missing metadata["created_at"].
Tests would be added to tests/unit/models/test_attack_result.py and
tests/unit/backend/test_mappers.py covering (a) round-trip of
timestamp through AttackResult → AttackResultEntry → AttackResult
and (b) the mapper preferring ar.timestamp over datetime.now().