fix(demo): anchor seeded dates to the real reset moment

Booking/inspection/maintenance/outbox dates were authored as absolute
timestamps around a fixed 2026-08-01 anchor and never re-anchored at
seed/reset time, so demo scenarios (e.g. BK-DEMO-RETURN) silently drifted
into the past. Every reset now shifts seeded dates by (today - authored
anchor); dashboard's "today" filter uses real wall-clock time instead of
the now-removed frozen demo_today setting. Adds seed-validation tests
proving scenarios S1/S2/S4/S5 are present and internally consistent after
every reset.
This commit is contained in:
NuklearRabbit
2026-08-03 13:08:02 +02:00
parent 7c94eb9e87
commit 8989ffb23c
9 changed files with 218 additions and 15 deletions
-1
View File
@@ -8,7 +8,6 @@ POSTGRES_DB=mobilityops
POSTGRES_USER=mobilityops
POSTGRES_PASSWORD=mobilityops
APP_SECRET=replace-in-production
DEMO_TODAY=2026-08-01
TZ=Europe/Brussels
# Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the
# current Unraid review environment); set true only once MobilityOps is served over HTTPS,
+35
View File
@@ -507,3 +507,38 @@ any change at `docs/functional-completion/server-baseline.md`.
re-verified against the live server after every batch. See
`artifacts/functional-completion/final-summary.md` for the definitive acceptance
evidence.
## Demo productization (in progress, same branch `feat/mobilityops-functional-completion`)
Follows the functional-completion work above; turns the now feature-complete PoC into a
guided, honestly-labelled demo (fictional org "Northstar Mobility", guided tour, 5 named
scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-demo-gap-audit.md`.
### Batch 1 — seed date anchoring (complete)
- **Real bug fixed**: `seed/bookings.csv` etc. store absolute ISO timestamps authored
around a fixed anchor (`2026-08-01`). Nothing previously re-anchored them at seed/reset
time, so scenario bookings (e.g. `BK-DEMO-RETURN`) silently drifted into the past every
day the environment wasn't reset. `dashboard.py::_today()` compounded this by filtering
"today's movements" against the same frozen `demo_today` setting instead of real time.
- Fix: `seed_loader.py` now computes `shift = today - SEED_AUTHORED_ANCHOR` once per
`load_seed()` call and applies it to every seeded booking/inspection/maintenance/outbox
datetime column, so scenarios stay "today"/"near-future" relative to the actual reset
moment. `SeedResult` now also carries `anchor_date`/`seeded_at`; `POST /api/v1/demo/reset`
returns them; a `demo_data_seeded` audit event records the anchor for traceability.
`dashboard.py::_today()` switched from the frozen `demo_today` setting to real wall-clock
UTC date. The now-dead `demo_today` setting/env var was removed from `config.py`,
`compose.yaml`, `.env`, `.env.example` (nothing else referenced it).
- Added seed-validation tests (`backend/tests/test_seed.py`) proving S1 (`BK-DEMO-RETURN`/
`MO-024`), S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`), S4 (`MO-016`/
`BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) and S5 (seeded failed outbox event
`00000000-0000-4000-8000-000000000020`, confirmed genuinely `failed` immediately after a
fresh reset, not silently auto-healed by the background dispatcher since it only claims
`pending` rows) are fully present after every reset, plus a dedicated anchoring test
asserting the shift and the audit marker.
- Live-verified locally: reseeded and confirmed via `psql` that `BK-DEMO-RETURN` now ends
today and `BK-DEMO-NEXT`/overlap bookings sit in the near future (today = 2026-08-03).
- Evidence: `pytest` **122 passed** (117 + 5 new/expanded seed tests), `ruff check .`
clean, `mypy app` clean (46 files, canonical `make` scope).
- Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent
demo badge (task #30), then the Demo Guide + scenario overview (task #31).
+4 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import date, datetime
from datetime import UTC, date, datetime
from typing import Literal
from fastapi import APIRouter, Depends
@@ -30,7 +30,9 @@ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _today() -> date:
return datetime.fromisoformat(settings.demo_today).date()
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor
# shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting.
return datetime.now(UTC).date()
@router.get("", response_model=DashboardOut)
+10 -2
View File
@@ -99,8 +99,16 @@ def demo_reset(
actor_label=user.display_name,
action="demo_reset",
entity_type="system",
metadata={"counts": result.counts},
metadata={
"counts": result.counts,
"anchor_date": result.anchor_date.isoformat(),
},
)
db.commit()
response.delete_cookie(settings.session_cookie_name)
return {"status": "reset", "counts": result.counts}
return {
"status": "reset",
"counts": result.counts,
"anchor_date": result.anchor_date.isoformat(),
"seeded_at": result.seeded_at.isoformat(),
}
-1
View File
@@ -32,7 +32,6 @@ class Settings(BaseSettings):
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
mcp_hub_registration_enabled: bool = False
cors_allow_origins: str = "http://localhost:1228"
demo_today: str = "2026-08-01"
@lru_cache
+37 -7
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import csv
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from sqlalchemy import delete, insert, update
@@ -20,6 +20,7 @@ from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
from app.models.user import User
from app.models.vehicle import Vehicle
from app.services.audit import record_audit_event
settings = get_settings()
@@ -36,6 +37,17 @@ DEMO_USERS = [
},
]
# seed/generate_seed.py authored the committed CSVs relative to this fixed date
# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every
# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps
# right now" scenarios stay true to the actual reset moment instead of decaying as real
# time passes between resets -- a fixed anchor with no shift goes stale within days.
SEED_AUTHORED_ANCHOR = date(2026, 8, 1)
def _seed_anchor_shift(today: date) -> timedelta:
return today - SEED_AUTHORED_ANCHOR
def _parse_dt(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
@@ -53,6 +65,8 @@ def _parse_optional_int(value: str) -> int | None:
@dataclass
class SeedResult:
counts: dict[str, int]
anchor_date: date
seeded_at: datetime
def _seed_dir() -> Path:
@@ -83,6 +97,8 @@ def clear_all(db: Session) -> None:
def load_seed(db: Session) -> SeedResult:
counts: dict[str, int] = {}
today = datetime.now(UTC).date()
shift = _seed_anchor_shift(today)
user_rows = [
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
@@ -154,8 +170,8 @@ def load_seed(db: Session) -> SeedResult:
"public_ref": row["public_ref"],
"customer_id": customer_id_by_ref[row["customer_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"starts_at": _parse_dt(row["starts_at"]),
"ends_at": _parse_dt(row["ends_at"]),
"starts_at": _parse_dt(row["starts_at"]) + shift,
"ends_at": _parse_dt(row["ends_at"]) + shift,
"status": row["status"],
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
@@ -179,7 +195,7 @@ def load_seed(db: Session) -> SeedResult:
"damage_reported": _parse_bool(row["damage_reported"]),
"technical_warning": _parse_bool(row["technical_warning"]),
"odometer_km": int(row["odometer_km"]),
"completed_at": _parse_dt(row["completed_at"]),
"completed_at": _parse_dt(row["completed_at"]) + shift,
"completed_by": None,
}
)
@@ -193,7 +209,7 @@ def load_seed(db: Session) -> SeedResult:
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"occurred_at": _parse_dt(row["occurred_at"]),
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
"odometer_km": int(row["odometer_km"]),
"category": row["category"],
"summary": row["summary"],
@@ -266,7 +282,7 @@ def load_seed(db: Session) -> SeedResult:
},
"aggregate_ref": row["aggregate_ref"],
},
"occurred_at": _parse_dt(row["occurred_at"]),
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
"delivery_status": row["status"],
"attempts": int(row["attempts"]),
"next_attempt_at": None,
@@ -277,7 +293,21 @@ def load_seed(db: Session) -> SeedResult:
db.execute(insert(OutboxEvent), outbox_rows)
counts["workflow_runs"] = len(outbox_rows)
return SeedResult(counts=counts)
seeded_at = datetime.now(UTC)
record_audit_event(
db,
actor_type="system",
actor_label="seed loader",
action="demo_data_seeded",
entity_type="system",
metadata={
"anchor_date": today.isoformat(),
"seed_authored_anchor": SEED_AUTHORED_ANCHOR.isoformat(),
"counts": counts,
},
)
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
def reset_and_seed(db: Session) -> SeedResult:
+118 -1
View File
@@ -1,13 +1,16 @@
from datetime import UTC, datetime
from sqlalchemy import func, select
from app.core.db import SessionLocal
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent
from app.models.user import User
from app.models.vehicle import Vehicle
from app.seed_loader import reset_and_seed
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
def test_seed_counts_match_deterministic_dataset():
@@ -52,3 +55,117 @@ def test_seed_demo_scenarios_present():
assert failed_run is not None
finally:
db.close()
def _by_ref(db, model, ref):
return db.scalar(select(model).where(model.public_ref == ref))
def test_seed_scenario_s1_odometer_regression_return():
"""S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a
below-canonical odometer reading, using the vehicle's own current odometer."""
db = SessionLocal()
try:
reset_and_seed(db)
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
vehicle = _by_ref(db, Vehicle, "MO-024")
assert booking is not None and vehicle is not None
assert booking.vehicle_id == vehicle.id
assert booking.status == "active"
assert booking.end_odometer_km is None
# A demo return reading must sit below the vehicle's canonical odometer to
# reproduce the odometer-regression anomaly deterministically.
assert vehicle.odometer_km > 0
finally:
db.close()
def test_seed_scenario_s2_duplicate_customer_pair():
"""S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue."""
db = SessionLocal()
try:
reset_and_seed(db)
primary = _by_ref(db, Customer, "CUS-0012")
duplicate = _by_ref(db, Customer, "CUS-0178")
assert primary is not None and duplicate is not None
assert primary.email == duplicate.email
assert duplicate.merged_into_customer_id is None
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE")
assert issue is not None
assert issue.rule_type == "possible_duplicate_customer"
assert issue.status == "open"
related = issue.evidence_json.get("related_refs", [])
assert "CUS-0012" in related or "CUS-0178" in related
finally:
db.close()
def test_seed_scenario_s4_booking_overlap():
"""S4: MO-016 carries two overlapping reservations plus a matching open issue."""
db = SessionLocal()
try:
reset_and_seed(db)
vehicle = _by_ref(db, Vehicle, "MO-016")
booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A")
booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B")
assert vehicle is not None and booking_a is not None and booking_b is not None
assert booking_a.vehicle_id == vehicle.id
assert booking_b.vehicle_id == vehicle.id
assert booking_a.starts_at < booking_b.ends_at
assert booking_b.starts_at < booking_a.ends_at
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP")
assert issue is not None
assert issue.rule_type == "booking_overlap"
assert issue.status == "open"
finally:
db.close()
def test_seed_scenario_s5_failed_workflow_run():
"""S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely
pending, so the background dispatcher never silently auto-heals it away."""
db = SessionLocal()
try:
reset_and_seed(db)
failed = db.scalar(
select(OutboxEvent).where(
OutboxEvent.event_id == "00000000-0000-4000-8000-000000000020"
)
)
assert failed is not None
assert failed.delivery_status == "failed"
assert failed.attempts >= 1
assert failed.last_error
finally:
db.close()
def test_seed_dates_are_anchored_to_reset_moment():
"""Every reset shifts seeded dates by (real today - authored anchor), so scenario
bookings stay 'today'/'near-future' relative to whenever the reset actually ran,
instead of decaying back to the fixed 2026-08-01 authoring date."""
db = SessionLocal()
try:
result = reset_and_seed(db)
today = datetime.now(UTC).date()
assert result.anchor_date == today
shift = today - SEED_AUTHORED_ANCHOR
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
assert booking is not None
# Authored ends_at was 2026-08-01T09:00Z; after shifting it must land on the
# real reset date, not the frozen authoring date (unless shift is exactly zero).
assert booking.ends_at.date() == today or shift.days == 0
marker = db.scalar(
select(AuditEvent)
.where(AuditEvent.action == "demo_data_seeded")
.order_by(AuditEvent.occurred_at.desc())
)
assert marker is not None
assert marker.metadata_json["anchor_date"] == today.isoformat()
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
finally:
db.close()
-1
View File
@@ -24,7 +24,6 @@ services:
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops}
TZ: ${TZ:-Europe/Brussels}
APP_SECRET: ${APP_SECRET:-replace-in-production}
DEMO_TODAY: ${DEMO_TODAY:-2026-08-01}
CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228}
KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo}
RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000}
+14
View File
@@ -42,6 +42,19 @@ One seeded outbox/workflow record is failed with a safe simulated connection err
Question: “What must I do when a vehicle returns with damage?” Expected: answer cites damage handling and return inspection procedures.
## Date anchoring
The committed CSVs store absolute ISO timestamps authored around a fixed anchor date
(`SEED_AUTHORED_ANCHOR = 2026-08-01` in `backend/app/seed_loader.py`, matching the
`--anchor` used to generate them). Every seed/reset shifts every seeded booking,
inspection, maintenance and outbox timestamp by `today SEED_AUTHORED_ANCHOR`, so
"today"/"near-future"/"currently overlapping" scenarios stay true to the real moment the
environment was (re)seeded instead of decaying as real time passes between resets. Public
refs and entity relationships are untouched by the shift — only datetime columns move.
`load_seed()` returns the resolved `anchor_date`/`seeded_at`, and records a
`demo_data_seeded` audit event carrying both the resolved anchor and the original
authoring anchor, so the shift applied on any given reset stays traceable.
## Demo reset
Reset must:
@@ -49,6 +62,7 @@ Reset must:
- require Operations Manager;
- rebuild the deterministic dataset;
- re-establish scenario references;
- re-anchor scenario dates to the real reset moment (see above);
- clear non-seed audit/workflow state;
- complete safely and visibly;
- be covered by a test.