feat(summary): revision-gated cache with stale-while-revalidate (STATE-WP-0066)

Replace the fixed 15s TTL on GET /state/summary with per-table revision
watermarks, stale-while-revalidate background refresh, and a progress-tail
section split. SQLAlchemy write hooks invalidate core or progress sections
on mutation. Adds tests, benchmark script, and operator docs.
This commit is contained in:
2026-06-22 16:27:32 +02:00
parent f88e74288d
commit 94c7817339
10 changed files with 614 additions and 35 deletions

View File

@@ -67,6 +67,10 @@ test-python:
TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \
$(UV) run pytest -x -q
## Benchmark /state/summary revision cache (API must be running on :8000)
benchmark-summary-cache:
$(UV) run python scripts/benchmark_summary_cache.py
## ops-bridge managed tunnels
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
tunnels-up:

View File

@@ -185,6 +185,14 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
}
```
**Caching:** responses are revision-gated — the API compares cheap per-table
`MAX(updated_at)` / `MAX(created_at)` watermarks before rebuilding. Unchanged
data returns the cached snapshot (`X-StateHub-Cache: hit-revision`). When core
data changes, the last good snapshot may be served immediately while a
background refresh runs (`X-StateHub-Cache: stale`). Force a synchronous rebuild
with `?refresh=true` or `Cache-Control: no-cache`. Infrastructure probes should
use `/state/health`, not `/state/summary`.
### Router summary
| Prefix | Operations |

View File

@@ -43,6 +43,12 @@ from api.schemas.topic import TopicRead, TopicWithWorkstreams
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
from api.schemas.workstream_dependency import WorkstreamDepStub
from api.routers.workstreams import _workplan_index
from api.services.summary_cache import (
apply_progress_section,
fetch_summary_revision,
get_summary_cache,
register_summary_cache_invalidation,
)
from api.task_status import TERMINAL_TASK_STATUSES, status_value
from api.workplan_status import (
CLOSED_WORKPLAN_STATUSES,
@@ -53,28 +59,58 @@ from task_flow_engine import FlowEngine
router = APIRouter(prefix="/state", tags=["state"])
_SUMMARY_CACHE: StateSummary | None = None
_SUMMARY_CACHE_AT: float = 0.0
_SUMMARY_TTL = 15.0
_OVERVIEW_CACHE: DashboardOverview | None = None
_OVERVIEW_CACHE_AT: float = 0.0
_OVERVIEW_TTL = 10.0
def _summary_cache_headers(
response: Response,
*,
cache_status: str,
revision: str,
) -> None:
response.headers["X-StateHub-Cache"] = cache_status
response.headers["X-StateHub-Revision"] = revision
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120"
@router.get("/summary", response_model=StateSummary)
async def get_summary(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
refresh: bool = False,
) -> StateSummary:
global _SUMMARY_CACHE, _SUMMARY_CACHE_AT
no_cache = "no-cache" in request.headers.get("cache-control", "")
if not no_cache and _SUMMARY_CACHE is not None and (time.monotonic() - _SUMMARY_CACHE_AT) < _SUMMARY_TTL:
response.headers["X-StateHub-Cache"] = "hit"
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
return _SUMMARY_CACHE
response.headers["X-StateHub-Cache"] = "miss"
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
revision = await fetch_summary_revision(session)
revision_token = revision.combined_fingerprint()
force_refresh = refresh or "no-cache" in request.headers.get("cache-control", "")
cache = get_summary_cache()
cache_status, cached = cache.resolve(revision, force_refresh=force_refresh)
if cache_status == "hit-revision" and cached is not None:
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return cached
if cache_status == "progress-section" and cached is not None:
result = await apply_progress_section(session, cached, revision)
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return result
if cache_status == "stale" and cached is not None:
cache.schedule_refresh(revision)
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
return cached
result = await build_state_summary(session)
cache.store(result, revision)
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
return result
async def build_state_summary(session: AsyncSession) -> StateSummary:
"""Build the full state summary snapshot (cache miss / forced refresh)."""
# Run all queries sequentially on one session.
# AsyncSession does not support concurrent operations (no gather on same session).
@@ -370,11 +406,13 @@ async def get_summary(
for w in open_ws
],
)
_SUMMARY_CACHE = result
_SUMMARY_CACHE_AT = time.monotonic()
return result
get_summary_cache().configure(build_state_summary)
register_summary_cache_invalidation()
@router.get("/overview", response_model=DashboardOverview)
async def get_overview(
request: Request,

View File

@@ -0,0 +1,288 @@
"""Revision-gated cache for ``GET /state/summary`` with stale-while-revalidate."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from collections.abc import Awaitable, Callable
from typing import Literal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from api.models.capability_request import CapabilityRequest
from api.models.contribution import Contribution
from api.models.decision import Decision
from api.models.domain import Domain
from api.models.extension_point import ExtensionPoint
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.task import Task
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.progress_event import ProgressEventRead
from api.schemas.state import StateSummary
logger = logging.getLogger(__name__)
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
_MAX_STALE_AGE_SECONDS = 300.0
InvalidateScope = Literal["all", "core", "progress"]
CacheStatus = Literal["hit-revision", "stale", "miss", "progress-section"]
BuildSummaryFn = Callable[[AsyncSession], Awaitable[StateSummary]]
# Tables feeding the stable (non-progress) summary core.
_CORE_TABLES: tuple[tuple[str, type], ...] = (
("topics", Topic),
("workplans", Workplan),
("tasks", Task),
("decisions", Decision),
("workplan_dependencies", WorkplanDependency),
("managed_repos", ManagedRepo),
("contributions", Contribution),
("capability_requests", CapabilityRequest),
("domains", Domain),
("extension_points", ExtensionPoint),
("technical_debt", TechnicalDebt),
)
@dataclass(frozen=True)
class SummaryRevision:
"""Cheap fingerprints of hub data that affect ``/state/summary``."""
core: datetime
progress: datetime | None
sbom: datetime | None
def core_fingerprint(self) -> str:
return _fingerprint(self.core, self.sbom)
def progress_fingerprint(self) -> str:
return self.progress.isoformat() if self.progress else ""
def combined_fingerprint(self) -> str:
return f"{self.core_fingerprint()}|{self.progress_fingerprint()}"
def _fingerprint(*parts: datetime | None) -> str:
normalized = [
(part or _EPOCH).astimezone(timezone.utc).isoformat()
for part in parts
]
return "|".join(normalized)
async def fetch_summary_revision(session: AsyncSession) -> SummaryRevision:
"""Return per-section revision watermarks (indexed MAX scans)."""
core_parts: list[datetime] = []
for _name, model in _CORE_TABLES:
value = (
await session.execute(select(func.max(model.updated_at)))
).scalar_one_or_none()
if value is not None:
core_parts.append(value)
sbom_at = (
await session.execute(select(func.max(SBOMSnapshot.snapshot_at)))
).scalar_one_or_none()
progress_at = (
await session.execute(select(func.max(ProgressEvent.created_at)))
).scalar_one_or_none()
core = max(core_parts, default=_EPOCH)
if sbom_at is not None and sbom_at > core:
core = sbom_at
return SummaryRevision(core=core, progress=progress_at, sbom=sbom_at)
async def fetch_recent_progress(session: AsyncSession, *, limit: int = 20) -> list[ProgressEventRead]:
rows = await session.execute(
select(ProgressEvent)
.options(noload("*"))
.order_by(ProgressEvent.created_at.desc())
.limit(limit)
)
return [ProgressEventRead.model_validate(event) for event in rows.scalars().all()]
def merge_summary(core: StateSummary, recent_progress: list[ProgressEventRead]) -> StateSummary:
return core.model_copy(update={"recent_progress": recent_progress})
@dataclass
class _CacheEntry:
summary: StateSummary
core_revision: str
progress_revision: str
built_at: float
class SummaryCache:
def __init__(self) -> None:
self._entry: _CacheEntry | None = None
self._refresh_task: asyncio.Task | None = None
self.last_error: str | None = None
self._build_fn: BuildSummaryFn | None = None
def configure(self, build_fn: BuildSummaryFn) -> None:
self._build_fn = build_fn
def reset(self) -> None:
self._entry = None
self.last_error = None
if self._refresh_task is not None and not self._refresh_task.done():
self._refresh_task.cancel()
self._refresh_task = None
def invalidate(self, scope: InvalidateScope = "all") -> None:
if scope == "all" or self._entry is None:
self.reset()
return
if scope == "core":
self.reset()
elif scope == "progress":
self._entry.progress_revision = "__invalid__"
def store(self, summary: StateSummary, revision: SummaryRevision) -> None:
import time
self._entry = _CacheEntry(
summary=summary,
core_revision=revision.core_fingerprint(),
progress_revision=revision.progress_fingerprint(),
built_at=time.monotonic(),
)
self.last_error = None
def _entry_age(self) -> float | None:
import time
if self._entry is None:
return None
return time.monotonic() - self._entry.built_at
def _entry_matches(self, revision: SummaryRevision) -> tuple[bool, bool]:
if self._entry is None:
return False, False
core_match = self._entry.core_revision == revision.core_fingerprint()
progress_match = self._entry.progress_revision == revision.progress_fingerprint()
return core_match, progress_match
def resolve(
self,
revision: SummaryRevision,
*,
force_refresh: bool,
) -> tuple[CacheStatus, StateSummary | None]:
import time
if force_refresh:
return "miss", None
if self._entry is None:
return "miss", None
age = self._entry_age()
if age is not None and age > _MAX_STALE_AGE_SECONDS:
return "miss", None
core_match, progress_match = self._entry_matches(revision)
if core_match and progress_match:
return "hit-revision", self._entry.summary
if core_match and not progress_match:
return "progress-section", self._entry.summary
# Core changed — serve stale full snapshot while refreshing.
return "stale", self._entry.summary
def schedule_refresh(self, revision: SummaryRevision) -> None:
if self._build_fn is None:
return
if self._refresh_task is not None and not self._refresh_task.done():
return
self._refresh_task = asyncio.create_task(
self._refresh_background(revision),
name="summary-cache-refresh",
)
async def _refresh_background(self, revision: SummaryRevision) -> None:
from api.database import async_session_factory
if self._build_fn is None:
return
try:
async with async_session_factory() as session:
current = await fetch_summary_revision(session)
summary = await self._build_fn(session)
self.store(summary, current)
except Exception as exc:
self.last_error = str(exc)
logger.exception("summary cache background refresh failed")
_summary_cache = SummaryCache()
def get_summary_cache() -> SummaryCache:
return _summary_cache
def invalidate_summary_cache(scope: InvalidateScope = "all") -> None:
_summary_cache.invalidate(scope)
def reset_summary_cache_for_tests() -> None:
_summary_cache.reset()
_INVALIDATION_REGISTERED = False
def register_summary_cache_invalidation() -> None:
"""Clear summary cache when ORM rows that affect summary are written."""
global _INVALIDATION_REGISTERED
if _INVALIDATION_REGISTERED:
return
_INVALIDATION_REGISTERED = True
from sqlalchemy import event
def _invalidate_core(*_args: object, **_kwargs: object) -> None:
invalidate_summary_cache("core")
def _invalidate_progress(*_args: object, **_kwargs: object) -> None:
invalidate_summary_cache("progress")
for _name, model in _CORE_TABLES:
event.listen(model, "after_insert", _invalidate_core)
event.listen(model, "after_update", _invalidate_core)
event.listen(model, "after_delete", _invalidate_core)
event.listen(SBOMSnapshot, "after_insert", _invalidate_core)
event.listen(SBOMSnapshot, "after_delete", _invalidate_core)
event.listen(ProgressEvent, "after_insert", _invalidate_progress)
async def apply_progress_section(
session: AsyncSession,
summary: StateSummary,
revision: SummaryRevision,
) -> StateSummary:
recent = await fetch_recent_progress(session)
merged = merge_summary(summary, recent)
cache = get_summary_cache()
if cache._entry is not None and cache._entry.core_revision == revision.core_fingerprint():
cache._entry.summary = merged
cache._entry.progress_revision = revision.progress_fingerprint()
else:
cache.store(merged, revision)
return merged

View File

@@ -59,6 +59,11 @@ make api # db + migrate + uvicorn (restarts if already running)
All endpoints are read-only GET requests. The dashboard never writes to the API.
`/state/summary` is revision-cached server-side. Repeated polls with unchanged
hub data return `X-StateHub-Cache: hit-revision` without rebuilding the full
snapshot. Prefer `/state/overview` on the Overview page (lighter bounded
read model).
---
*Poll interval: 15 s for most pages, 60 s for Overview. Data is refreshed in the background — the page never reloads itself.*

View File

@@ -89,7 +89,7 @@ entity while recording the missing progress event.
| Tool | Key Args | When to use |
|------|----------|-------------|
| `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workstreams, blocking decisions, last 5 events, repo SBOM status, compact capabilities list — ~10% of get_state_summary() token cost. |
| `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, waiting tasks, all open workstreams, last 20 events. Large (~10k tokens). |
| `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, waiting tasks, all open workstreams, last 20 events. Large (~10k tokens). API revision-caches unchanged snapshots (`X-StateHub-Cache: hit-revision`); use REST `?refresh=true` only when you need a forced rebuild. |
| `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. |
| `list_tasks(workstream_id, status?)` | `workstream_id`: UUID (required); `status?`: wait/todo/progress/done/cancel | List all tasks in a workstream. Use this to look up task UUIDs before calling `update_task_status`, or to verify which workplan tasks are already synced to the DB. |
| `list_blocked_tasks(workstream_id?)` | optional filter | Legacy name: surfaces `wait` tasks, optionally scoped to one workstream. |

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Quick benchmark for /state/summary revision cache (STATE-WP-0066)."""
from __future__ import annotations
import argparse
import statistics
import sys
import time
import urllib.request
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--requests", type=int, default=10)
args = parser.parse_args()
url = f"{args.base_url.rstrip('/')}/state/summary"
timings: list[float] = []
last_cache = ""
# Prime cache
with urllib.request.urlopen(url, timeout=30) as resp:
resp.read()
last_cache = resp.headers.get("X-StateHub-Cache", "")
for _ in range(args.requests):
started = time.perf_counter()
with urllib.request.urlopen(url, timeout=30) as resp:
resp.read()
last_cache = resp.headers.get("X-StateHub-Cache", "")
timings.append(time.perf_counter() - started)
p95 = statistics.quantiles(timings, n=20)[18] if len(timings) >= 2 else timings[0]
print(f"requests={args.requests} p50={statistics.median(timings):.3f}s p95={p95:.3f}s last_cache={last_cache}")
return 0 if last_cache == "hit-revision" else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -52,10 +52,10 @@ def _truncate(_schema):
from api.models import Base
import api.routers.state as _state_router
import api.routers.workstreams as _ws_router
from api.services.summary_cache import reset_summary_cache_for_tests
# Reset in-process TTL caches so stale data from a previous test can't bleed through.
_state_router._SUMMARY_CACHE = None
_state_router._SUMMARY_CACHE_AT = 0.0
# Reset in-process caches so stale data from a previous test can't bleed through.
reset_summary_cache_for_tests()
_state_router._OVERVIEW_CACHE = None
_state_router._OVERVIEW_CACHE_AT = 0.0
_ws_router._INDEX_CACHE = None

195
tests/test_summary_cache.py Normal file
View File

@@ -0,0 +1,195 @@
"""Tests for revision-gated /state/summary caching."""
from __future__ import annotations
import pytest
from api.services.summary_cache import (
SummaryCache,
SummaryRevision,
fetch_summary_revision,
get_summary_cache,
invalidate_summary_cache,
reset_summary_cache_for_tests,
)
from tests.test_routers_core import _create_domain, _create_task, _create_topic, _create_workstream
@pytest.mark.asyncio
async def test_fetch_summary_revision_empty_db(test_engine):
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
revision = await fetch_summary_revision(session)
assert revision.core_fingerprint()
assert revision.progress_fingerprint() == ""
@pytest.mark.asyncio
async def test_summary_hit_revision_header(client):
r1 = await client.get("/state/summary")
assert r1.status_code == 200
assert r1.headers.get("X-StateHub-Cache") == "miss"
assert r1.headers.get("X-StateHub-Revision")
r2 = await client.get("/state/summary")
assert r2.status_code == 200
assert r2.headers.get("X-StateHub-Cache") == "hit-revision"
assert r2.headers.get("X-StateHub-Revision") == r1.headers.get("X-StateHub-Revision")
@pytest.mark.asyncio
async def test_summary_force_refresh_query(client):
await client.get("/state/summary")
r = await client.get("/state/summary", params={"refresh": "true"})
assert r.status_code == 200
assert r.headers.get("X-StateHub-Cache") == "miss"
@pytest.mark.asyncio
async def test_summary_no_cache_header(client):
await client.get("/state/summary")
r = await client.get("/state/summary", headers={"Cache-Control": "no-cache"})
assert r.status_code == 200
assert r.headers.get("X-StateHub-Cache") == "miss"
@pytest.mark.asyncio
async def test_summary_stale_while_revalidate(client, monkeypatch):
await client.get("/state/summary")
original = fetch_summary_revision
async def bumped_revision(session):
rev = await original(session)
from datetime import datetime, timedelta, timezone
return SummaryRevision(
core=rev.core + timedelta(seconds=1),
progress=rev.progress,
sbom=rev.sbom,
)
monkeypatch.setattr("api.routers.state.fetch_summary_revision", bumped_revision)
r = await client.get("/state/summary")
assert r.status_code == 200
assert r.headers.get("X-StateHub-Cache") == "stale"
@pytest.mark.asyncio
async def test_progress_section_refresh_without_full_rebuild(client):
await _create_domain(client)
topic = await _create_topic(client)
await client.get("/state/summary")
before = (await client.get("/state/summary")).headers.get("X-StateHub-Revision")
await client.post(
"/progress/",
json={
"topic_id": topic["id"],
"event_type": "note",
"summary": "cache section test",
"author": "pytest",
},
)
r = await client.get("/state/summary")
assert r.status_code == 200
assert r.headers.get("X-StateHub-Cache") == "hit-revision"
assert r.json()["recent_progress"][0]["summary"] == "cache section test"
assert r.headers.get("X-StateHub-Revision") != before
@pytest.mark.asyncio
async def test_task_mutation_invalidates_core_cache(client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic_id=topic["id"])
task = await _create_task(client, ws["id"])
await client.get("/state/summary")
assert (await client.get("/state/summary")).headers.get("X-StateHub-Cache") == "hit-revision"
await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
r = await client.get("/state/summary")
assert r.status_code == 200
assert r.headers.get("X-StateHub-Cache") == "miss"
def test_summary_cache_unit_progress_section():
cache = SummaryCache()
from datetime import datetime, timezone
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
rev = SummaryRevision(
core=datetime(2026, 1, 1, tzinfo=timezone.utc),
progress=datetime(2026, 1, 2, tzinfo=timezone.utc),
sbom=None,
)
empty_totals = Totals(
topics=TopicTotals(),
workstreams=WorkstreamTotals(),
tasks=TaskTotals(),
decisions=DecisionTotals(),
)
summary = StateSummary(
generated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
totals=empty_totals,
topics=[],
blocking_decisions=[],
waiting_tasks=[],
recent_progress=[],
open_workstreams=[],
)
cache.store(summary, rev)
new_rev = SummaryRevision(
core=rev.core,
progress=datetime(2026, 1, 3, tzinfo=timezone.utc),
sbom=None,
)
status, cached = cache.resolve(new_rev, force_refresh=False)
assert status == "progress-section"
assert cached is summary
def test_invalidate_summary_cache_scopes():
reset_summary_cache_for_tests()
cache = get_summary_cache()
from datetime import datetime, timezone
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
rev = SummaryRevision(
core=datetime(2026, 1, 1, tzinfo=timezone.utc),
progress=None,
sbom=None,
)
empty_totals = Totals(
topics=TopicTotals(),
workstreams=WorkstreamTotals(),
tasks=TaskTotals(),
decisions=DecisionTotals(),
)
summary = StateSummary(
generated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
totals=empty_totals,
topics=[],
blocking_decisions=[],
waiting_tasks=[],
recent_progress=[],
open_workstreams=[],
)
cache.store(summary, rev)
invalidate_summary_cache("progress")
status, _ = cache.resolve(rev, force_refresh=False)
assert status == "progress-section"
invalidate_summary_cache("core")
status, cached = cache.resolve(rev, force_refresh=False)
assert status == "miss"
assert cached is None

View File

@@ -4,12 +4,13 @@ type: workplan
title: "State summary revision cache and stale-while-revalidate"
domain: custodian
repo: state-hub
status: ready
status: finished
owner: codex
topic_slug: custodian
created: "2026-06-22"
updated: "2026-06-22"
state_hub_workstream_id: ""
finished: "2026-06-22"
state_hub_workstream_id: "f738cd77-6b8b-40e5-b348-dc304c7821f1"
---
# STATE-WP-0066 — State summary revision cache and stale-while-revalidate
@@ -147,9 +148,9 @@ Preserve truthful `generated_at` on cache hits (when the snapshot was built).
```task
id: STATE-WP-0066-T01
status: todo
status: done
priority: high
state_hub_task_id: ""
state_hub_task_id: "8ee836ec-048c-44f6-b16e-e7454d07371a"
```
Extract summary cache logic from `api/routers/state.py` into a dedicated
@@ -171,9 +172,9 @@ Done when revision fetch is tested in isolation and profiled under local DB.
```task
id: STATE-WP-0066-T02
status: todo
status: done
priority: high
state_hub_task_id: ""
state_hub_task_id: "c5fc8ab8-5fc8-463b-9ae7-c304a7e0383e"
```
Wire revision check into `GET /state/summary`:
@@ -192,9 +193,9 @@ skip the heavy query path (assert via mock or elapsed-ms header threshold).
```task
id: STATE-WP-0066-T03
status: todo
status: done
priority: high
state_hub_task_id: ""
state_hub_task_id: "aa079e17-6103-4539-878d-b451035e5f8a"
```
When revision differs but a cached snapshot exists:
@@ -214,9 +215,9 @@ runs; second request after rebuild shows `hit-revision`.
```task
id: STATE-WP-0066-T04
status: todo
status: done
priority: medium
state_hub_task_id: ""
state_hub_task_id: "99b14a10-ff1b-4609-a62b-5da19b79be68"
```
Split cache into `core` and `progress_tail` sections:
@@ -234,9 +235,9 @@ re-running domain/SBOM/flow-engine work (verify via query count or spy).
```task
id: STATE-WP-0066-T05
status: todo
status: done
priority: medium
state_hub_task_id: ""
state_hub_task_id: "0df9c1c2-6edf-4be3-bee8-b75e0d24fc02"
```
Call `invalidate_summary_cache()` (or equivalent revision bump) from write
@@ -254,9 +255,9 @@ Done when mutation routes trigger invalidation and tests pass.
```task
id: STATE-WP-0066-T06
status: todo
status: done
priority: high
state_hub_task_id: ""
state_hub_task_id: "5f68f999-d5ca-40df-a054-aaf762837342"
```
Prove cache effectiveness under realistic load:
@@ -276,9 +277,9 @@ and CI tests are green.
```task
id: STATE-WP-0066-T07
status: todo
status: done
priority: low
state_hub_task_id: ""
state_hub_task_id: "aee0f349-761f-42b7-82b2-6a319936a68e"
```
Update:
@@ -300,9 +301,9 @@ Done when docs match implemented headers and invalidation semantics.
```task
id: STATE-WP-0066-T08
status: todo
status: done
priority: medium
state_hub_task_id: ""
state_hub_task_id: "b4e967ab-57ba-4f0f-9161-373b383248b5"
```
End-to-end check from Railiance01 (or documented manual runbook):