diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index bf19c5f77..e2fe01e72 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -482,8 +482,9 @@ unavailable, so that run is Failed rather than a fabricated score.
The home list is clickable: `GET /api/analysis-runs/{id}` fills a
labeled detail (cutoff, requested date, 12-character digest prefixes
with full digests on hover, counts, status history)
-without exposing a DSN or raw record. Opening a cutoff title warns
-that the live body may have changed after the run. Status history is detail-only
+without exposing a DSN or raw record. Opening a cutoff title still
+shows the live body; titles rewritten after the run are marked
+updated after cutoff. Status history is detail-only
and uses lookup labels plus occurrence times; a failure event keeps
its machine `failure_code` rather than an invented caption. Failed
TEPP list rows add a next-action line (open the run, then connect the
diff --git a/CHANGELOG.d/0.88.0-analysis-run-cutoff-body-warning.md b/CHANGELOG.d/0.88.0-analysis-run-cutoff-body-warning.md
new file mode 100644
index 000000000..0562ff696
--- /dev/null
+++ b/CHANGELOG.d/0.88.0-analysis-run-cutoff-body-warning.md
@@ -0,0 +1,4 @@
+# 0.88.0 Analysis-run cutoff body warning
+
+Opening a title marked updated after cutoff now says the popup body is
+live. The earlier text is not stored, so the popup does not invent it.
diff --git a/CHANGELOG.d/0.88.0-analysis-run-live-write-clock.md b/CHANGELOG.d/0.88.0-analysis-run-live-write-clock.md
new file mode 100644
index 000000000..7a967633d
--- /dev/null
+++ b/CHANGELOG.d/0.88.0-analysis-run-live-write-clock.md
@@ -0,0 +1,5 @@
+# 0.88.0 Analysis-run live write clock
+
+In-cutoff titles now say whether the live row was rewritten after the
+run. Open Demo public post as the edited counter-example; Demo private
+post still matches the January cutoff. Bodies stay live.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00a19fe92..044a399ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,25 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.88.0] - 2026-08-16
+
+### Added
+
+- Opening an analysis-run title marked **Updated after cutoff** now
+ shows a popup status that the body is live, not a cutoff snapshot
+ (ADR 0016). After `make seed`, open the Demo Corp lineage run and
+ click Demo public post: the warning appears above the live body.
+ Demo private post and the home post list do not. The earlier text
+ is not stored, so the popup does not invent it.
+
+- Analysis-run detail now compares each in-cutoff title's live
+ `updated_at` with that run's knowledge cutoff. After `make seed`,
+ open the Demo Corp lineage run: Demo public post is marked
+ **Updated after cutoff**; Demo private post is not. Opening a
+ marked title still shows the live body -- cutoff body versioning
+ stays a later slice (ADR 0016). The list stays aggregates-only.
+ No TEPP theta is invented.
+
## [0.87.0] - 2026-08-16
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 870c77f87..122e348c3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3,6 +3,15 @@
Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the
ADRs under `docs/adr/`. Do not fork those rules here.
+## Analysis-run write clock (v0.88.0)
+
+Open the Demo Corp lineage run after `make seed`. Demo public post is
+marked **Updated after cutoff**; Demo private post is not. Opening the
+marked title shows a live-body status above the text — the earlier
+version is not stored, so the popup does not invent it. Compare that
+body with the cutoff before treating it as reconstructed evidence
+(ADR 0016). The home post list and unmarked titles stay quiet.
+
## Analysis-run retention (v0.87.0)
To empty a run-bearing registry, insert an unrevoked
@@ -29,7 +38,8 @@ mention TEPP. A failed period-report row rebuilds the report. A
pending TEPP row does not claim a calibrated measurement. A pending
lineage row says reconstruction has not started yet.
Digest prefixes stay audible; hover a prefix to read the full digest.
-Opening a cutoff title shows the live post -- compare it with the
-cutoff before treating the body as reconstructed evidence (ADR 0016).
+Opening a cutoff title shows the live post. Titles marked updated
+after cutoff were rewritten after the run; compare those bodies
+before treating them as reconstructed evidence (ADR 0016).
`POST /api/analysis-runs` records Pending on an authorized
cutoff capture (ADR 0017) and does not reconstruct lineage.
diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py
index d26eb6f6e..1da59ad1d 100644
--- a/backend/app/analysis_run_ingestion.py
+++ b/backend/app/analysis_run_ingestion.py
@@ -93,6 +93,22 @@ def _iso(value: Any) -> str:
return value.isoformat() if hasattr(value, "isoformat") else str(value)
+def _as_utc(value: datetime) -> datetime:
+ """Treat a naive clock as UTC so cutoff comparison stays timezone-aware."""
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+ return value.astimezone(timezone.utc)
+
+
+def live_write_after_cutoff(updated_at: datetime, knowledge_cutoff: datetime) -> bool:
+ """True when the live row was rewritten after the run's analysis clock.
+
+ ``created_at <= knowledge_cutoff`` admits the title. ``updated_at`` is
+ the live write clock (ADR 0016). Equal times stay in-cutoff evidence.
+ """
+ return _as_utc(updated_at) > _as_utc(knowledge_cutoff)
+
+
async def _counts_by_run(
conn: asyncpg.Connection,
run_ids: list[str],
@@ -258,15 +274,21 @@ async def fetch_visible_scope_posts(
scope_key: str | None,
affiliated_entity_ids: list[str],
knowledge_cutoff: Any,
-) -> list[dict[str, str]]:
+) -> list[dict[str, Any]]:
"""ABAC-visible post titles known at the run cutoff -- never a hidden body.
``knowledge_cutoff`` is the analysis clock (W3C Time / ISO 8601-1:2019;
ADR 0013/0016). A later live post must not appear inside an earlier run.
+ ``updated_at`` is compared separately so the operator can see which
+ in-cutoff titles were rewritten after that clock. The live body is
+ still not returned.
"""
+ columns = (
+ "post_id, post_title, visibility_code, corporate_entity_id, updated_at"
+ )
if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id:
rows = await conn.fetch(
- "select post_id, post_title, visibility_code, corporate_entity_id "
+ f"select {columns} "
"from source_post where corporate_entity_id = $1 "
"and created_at <= $2 "
"order by created_at, post_title",
@@ -275,7 +297,7 @@ async def fetch_visible_scope_posts(
)
elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id:
rows = await conn.fetch(
- "select post_id, post_title, visibility_code, corporate_entity_id "
+ f"select {columns} "
"from source_post where process_unit_id = $1 "
"and created_at <= $2 "
"order by created_at, post_title",
@@ -284,7 +306,7 @@ async def fetch_visible_scope_posts(
)
elif scope_kind_code == "analysis_scope_thread_group" and scope_key:
rows = await conn.fetch(
- "select post_id, post_title, visibility_code, corporate_entity_id "
+ f"select {columns} "
"from source_post where thread_group_key = $1 "
"and created_at <= $2 "
"order by created_at, post_title",
@@ -293,7 +315,7 @@ async def fetch_visible_scope_posts(
)
elif scope_kind_code == "analysis_scope_all_visible":
rows = await conn.fetch(
- "select post_id, post_title, visibility_code, corporate_entity_id "
+ f"select {columns} "
"from source_post where created_at <= $1 "
"order by created_at, post_title",
knowledge_cutoff,
@@ -301,12 +323,22 @@ async def fetch_visible_scope_posts(
else:
return []
affiliated = {str(entity_id) for entity_id in affiliated_entity_ids}
- posts: list[dict[str, str]] = []
+ posts: list[dict[str, Any]] = []
for row in rows:
visible = row["visibility_code"] == "public" or str(row["corporate_entity_id"]) in affiliated
if not visible:
continue
- posts.append({"post_id": str(row["post_id"]), "post_title": row["post_title"]})
+ updated_at = row["updated_at"]
+ posts.append(
+ {
+ "post_id": str(row["post_id"]),
+ "post_title": row["post_title"],
+ "updated_at": _iso(updated_at),
+ "live_after_cutoff": live_write_after_cutoff(
+ updated_at, knowledge_cutoff
+ ),
+ }
+ )
return posts
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 3b74c22a3..685e7ad05 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -305,11 +305,21 @@ def _insert_post(
visibility_code: str,
body: str = "body",
created_at: str = "2026-01-10T12:00:00Z",
+ updated_at: str | None = None,
) -> str:
+ written_at = updated_at if updated_at is not None else created_at
cur.execute(
- "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, created_at) "
- "values (%s, %s, %s, %s, 'voc', %s, %s) returning post_id",
- (account_id, corporate_entity_id, title, body, visibility_code, created_at),
+ "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, created_at, updated_at) "
+ "values (%s, %s, %s, %s, 'voc', %s, %s, %s) returning post_id",
+ (
+ account_id,
+ corporate_entity_id,
+ title,
+ body,
+ visibility_code,
+ created_at,
+ written_at,
+ ),
)
return str(cur.fetchone()[0])
@@ -329,6 +339,14 @@ def _insert_post(
"A follow-up written after the January 2026 run cutoff.",
created_at="2026-01-20T12:00:00Z",
)
+ _insert_post(
+ "Edited own-corp private post",
+ own_corp_id,
+ "private",
+ "A January post rewritten after the run cutoff.",
+ created_at="2026-01-10T12:00:00Z",
+ updated_at="2026-01-13T09:00:00Z",
+ )
cur.execute(
"insert into cataloged_person (person_name, person_side_code) values "
@@ -494,8 +512,14 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes(
assert all("failure_code" not in event for event in history)
titles = {post["post_title"] for post in body["visible_posts"]}
assert "Own-corp private post" in titles
+ assert "Edited own-corp private post" in titles
assert "Late own-corp private post" not in titles
assert "Other-corp private post" not in titles
+ posts_by_title = {post["post_title"]: post for post in body["visible_posts"]}
+ assert posts_by_title["Own-corp private post"]["live_after_cutoff"] is False
+ assert posts_by_title["Edited own-corp private post"]["live_after_cutoff"] is True
+ assert posts_by_title["Edited own-corp private post"]["updated_at"].startswith("2026-01-13")
+ assert "post_body" not in posts_by_title["Edited own-corp private post"]
assert "postgresql://" not in str(body)
assert "visible_posts" not in visible
diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
index 089443374..dc855a964 100644
--- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
+++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
@@ -25,9 +25,12 @@ every scope branch (corporate entity, process unit, thread group, and
all-visible). ABAC visibility is applied after that temporal gate.
Click-through still opens the live post body -- post versioning is a
later slice -- but the run list itself must not advertise a post the
-run was not allowed to know. The detail must say that next action
-plainly: compare the opened body with this cutoff before treating it
-as reconstructed evidence.
+run was not allowed to know. Detail compares the live `updated_at`
+write clock with `knowledge_cutoff` and marks titles rewritten after
+the run. Opening a marked title shows a popup status that the body is
+live; the earlier text is not stored, so the popup does not invent it.
+The next action is specific: only those marked titles need a cutoff
+comparison before treating the live body as reconstructed evidence.
Reproducibility digests on the same detail use a labeled group whose
accessible name does not replace the visible prefixes (W3C Accessible
@@ -44,11 +47,15 @@ run.
- After `make seed`, the Demo Corp lineage run lists Demo public post
and other in-cutoff Demo Corp titles. The later fixture account-review
post (2026-02-10) does not appear.
-- Open the run, read the live-body warning, then open a listed post
- and compare it with the cutoff date.
+- Open the run: Demo public post is marked updated after cutoff
+ (`updated_at` 2026-01-13). Demo private post is not. Opening the
+ marked title shows a live-body status; the private title and the
+ home post list do not.
- Hover a digest prefix to read the full code or configuration digest
when you need to match the API payload.
-- Post-body versioning at the cutoff remains future work.
+- Post-body versioning at the cutoff remains future work. The write
+ clock is a projection, not a stored cutoff body. The popup states
+ that honesty instead of inventing the earlier text.
- Thread-group *run list* visibility now uses the same cutoff
(ADR 0018). A later public post cannot surface a previously hidden
thread-group run.
diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
index c776053b1..8f465a482 100644
--- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
+++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
@@ -8,7 +8,7 @@
| Source | Product implication | Implemented evidence |
|---|---|---|
| W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. |
-| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Opening a listed title warns that the live body may have changed after that cutoff. |
+| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. |
| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. |
| ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. |
| PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. |
diff --git a/frontend/package.json b/frontend/package.json
index 0d43d9fa2..4c66c7205 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.87.0",
+ "version": "0.88.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 5251e69f8..71d1018cc 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -147,6 +147,14 @@
opacity: 0.7;
}
+.popup-live-body-warning {
+ margin: 0.75rem 0 1rem;
+ padding: 0.65rem 0.75rem;
+ border-left: 3px solid #b45309;
+ background: color-mix(in srgb, canvas 88%, #b45309 12%);
+ font-size: 0.85rem;
+}
+
.popup-section {
margin-top: 1.5rem;
padding-top: 1rem;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index fd8a15146..1d735319c 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -302,7 +302,20 @@ describe("App, authenticated", () => {
count_value: 3,
},
],
- visible_posts: [{ post_id: "post-1", post_title: "Public post" }],
+ visible_posts: [
+ {
+ post_id: "post-1",
+ post_title: "Public post",
+ updated_at: "2026-01-13T09:00:00Z",
+ live_after_cutoff: true,
+ },
+ {
+ post_id: "post-2",
+ post_title: "Private post",
+ updated_at: "2026-01-10T12:00:00Z",
+ live_after_cutoff: false,
+ },
+ ],
code_revision_sha: "abcdef0123456789deadbeefcafebabe",
configuration_sha256:
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
@@ -1688,19 +1701,29 @@ describe("App, authenticated", () => {
expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument();
expect(
screen.getByText(
- "Opening a title shows the live post. Compare it with cutoff 2026-01-12 before you treat the body as reconstructed evidence — it may have changed after this run.",
+ "Opening a title shows the live post. Titles marked updated after cutoff were rewritten after 2026-01-12. Compare those bodies with this run before you treat them as reconstructed evidence.",
),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
- name: "Open live post (may have changed after cutoff): Public post",
+ name: "Open live post (updated after cutoff): Public post",
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", {
+ name: "Open live post: Private post",
}),
).toBeInTheDocument();
+ const cutoffPosts = screen.getByRole("list", { name: "Posts known at this run cutoff" });
+ expect(cutoffPosts).toHaveTextContent("Updated after cutoff");
+ expect(screen.getByRole("button", { name: "Open live post: Private post" }).closest("li")).not.toHaveTextContent(
+ "Updated after cutoff",
+ );
expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", {
- name: "Open live post (may have changed after cutoff): Public post",
+ name: "Open live post (updated after cutoff): Public post",
}),
);
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
@@ -1719,6 +1742,42 @@ describe("App, authenticated", () => {
expect(teppHistory).not.toHaveTextContent("Succeeded");
});
+ it("warns that a cutoff-rewritten title opens the live body, not a snapshot", async () => {
+ stubBackend();
+ render(
+ {liveBodyWarning} +
+ ) : null}{rebuildError}
} {!graph &&Loading lineage graph...
} - {graph &&