From ab9e041f9e178084533d64feb1d725db9e70df4b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 17 Aug 2026 01:21:11 +0900
Subject: [PATCH 1/4] feat: compare live post write clock with analysis-run
cutoff (v0.87.0)
In-cutoff titles now say whether the live row was rewritten after the
run. Demo public post is the edited counter-example; Demo private post
still matches the January cutoff. Bodies stay live. TEPP stays behind
tepp_client.
---
ARCHITECTURE.md | 5 +-
.../0.87.0-analysis-run-live-write-clock.md | 5 ++
CHANGELOG.md | 12 +++++
CLAUDE.md | 5 +-
backend/app/analysis_run_ingestion.py | 46 ++++++++++++++++---
backend/tests/test_api.py | 30 ++++++++++--
...016-analysis-run-knowledge-cutoff-posts.md | 15 +++---
.../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 2 +-
frontend/src/App.test.tsx | 31 +++++++++++--
frontend/src/App.tsx | 26 +++++++----
frontend/src/api.ts | 9 +++-
scripts/seed_demo_data.py | 24 ++++++----
tests/test_analysis_run_create.py | 12 +++++
13 files changed, 179 insertions(+), 43 deletions(-)
create mode 100644 CHANGELOG.d/0.87.0-analysis-run-live-write-clock.md
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.87.0-analysis-run-live-write-clock.md b/CHANGELOG.d/0.87.0-analysis-run-live-write-clock.md
new file mode 100644
index 000000000..701f2fbb4
--- /dev/null
+++ b/CHANGELOG.d/0.87.0-analysis-run-live-write-clock.md
@@ -0,0 +1,5 @@
+# 0.87.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..6d87d84e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ 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.87.1] - 2026-08-16
+
+### Added
+
+- 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 and names both clocks.
+ 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..4c92e9807 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,7 +29,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..274d34dbd 100644
--- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
+++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
@@ -25,9 +25,11 @@ 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. 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 +46,12 @@ 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.
- 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.
- 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/src/App.test.tsx b/frontend/src/App.test.tsx
index fd8a15146..f7468ba5b 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());
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 07088e9d4..0ae3a3e50 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1564,20 +1564,27 @@ function analysisRunDigestPrefix(digest: string): string {
/**
* Next action when a cutoff title opens the live post (ADR 0016).
*
- * Post-body versioning is a later slice. Until then the operator must
- * compare the opened body with this run's cutoff instead of treating
- * today's text as reconstructed evidence.
+ * Post-body versioning is a later slice. Titles marked
+ * `live_after_cutoff` were rewritten after this run; others still
+ * match the write clock the run knew.
*/
function analysisRunLivePostWarning(cutoffIso: string): string {
const cutoffDate = cutoffIso.slice(0, 10);
return (
- `Opening a title shows the live post. Compare it with cutoff ${cutoffDate} ` +
- "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 ${cutoffDate}. Compare those bodies with this run ` +
+ "before you treat them as reconstructed evidence."
);
}
-function analysisRunLivePostButtonLabel(postTitle: string): string {
- return `Open live post (may have changed after cutoff): ${postTitle}`;
+function analysisRunLivePostButtonLabel(post: {
+ post_title: string;
+ live_after_cutoff?: boolean;
+}): string {
+ if (post.live_after_cutoff) {
+ return `Open live post (updated after cutoff): ${post.post_title}`;
+ }
+ return `Open live post: ${post.post_title}`;
}
function AnalysisRunReproducibilityDigests({
@@ -1752,11 +1759,14 @@ function AnalysisRunsPanel({
+ {post.live_after_cutoff && (
+ Updated after cutoff
+ )}
))}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 3385d5179..9eaaba8b8 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -536,6 +536,13 @@ export interface AnalysisRunStatusEvent {
failure_code?: string;
}
+export interface AnalysisRunVisiblePost {
+ post_id: string;
+ post_title: string;
+ updated_at?: string;
+ live_after_cutoff?: boolean;
+}
+
export interface AnalysisRun {
analysis_run_id: string;
run_kind_code: AnalysisRunKindCode;
@@ -549,7 +556,7 @@ export interface AnalysisRun {
requested_at: string;
source_counts: AnalysisRunCount[];
status_history?: AnalysisRunStatusEvent[];
- visible_posts?: { post_id: string; post_title: string }[];
+ visible_posts?: AnalysisRunVisiblePost[];
code_revision_sha?: string;
configuration_sha256?: string;
}
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 2f3c66c45..7bef3245d 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -233,22 +233,27 @@ def seed(
cur.execute("select post_id from source_post where post_title = 'Demo public post'")
if cur.fetchone() is None:
cur.execute(
- "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at) "
+ "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at, updated_at) "
"values (%s, %s, %s, 'Demo public post', "
"'Ada West at Demo Corp followed up with Priya Nair at Northridge Grid about the delayed shipment.', "
- "'voc', 'public', '2026-01-10T12:00:00Z')",
+ "'voc', 'public', '2026-01-10T12:00:00Z', '2026-01-13T09:00:00Z')",
(account_ids["demo.analyst"], corporate_entity_id, process_units["DEMO-PU-A"]),
)
cur.execute(
- "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at) "
- "values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private', '2026-01-10T12:00:00Z')",
+ "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at, updated_at) "
+ "values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private', '2026-01-10T12:00:00Z', '2026-01-10T12:00:00Z')",
(account_ids["demo.admin"], corporate_entity_id, process_units["DEMO-PU-HQ"]),
)
cur.execute(
- "update source_post set created_at = '2026-01-10T12:00:00Z' "
- "where post_title in ('Demo public post', 'Demo private post') "
- "and created_at > '2026-01-12T12:00:00Z'"
+ "update source_post set created_at = '2026-01-10T12:00:00Z', "
+ "updated_at = '2026-01-13T09:00:00Z' "
+ "where post_title = 'Demo public post'"
+ )
+ cur.execute(
+ "update source_post set created_at = '2026-01-10T12:00:00Z', "
+ "updated_at = '2026-01-10T12:00:00Z' "
+ "where post_title = 'Demo private post'"
)
cur.execute("select post_id from source_post where post_title = 'Demo public post'")
demo_public_post_id = cur.fetchone()[0]
@@ -377,8 +382,8 @@ def insert_fixture_source_posts(cur, author_account_id, corporate_entity_id, pro
"insert into source_post "
"(author_account_id, corporate_entity_id, process_unit_id, "
" post_title, post_body, voc_type_code, visibility_code, "
- " thread_group_key, secondary_grouping_key, created_at) "
- "values (%s, %s, %s, %s, %s, %s, 'public', %s, %s, %s) returning post_id",
+ " thread_group_key, secondary_grouping_key, created_at, updated_at) "
+ "values (%s, %s, %s, %s, %s, %s, 'public', %s, %s, %s, %s) returning post_id",
(
author_account_id,
corporate_entity_id,
@@ -389,6 +394,7 @@ def insert_fixture_source_posts(cur, author_account_id, corporate_entity_id, pro
rec.group_key,
rec.secondary_key,
occurred,
+ occurred,
),
)
post_id = str(cur.fetchone()[0])
diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py
index 4e24a4228..4b7ffcf53 100644
--- a/tests/test_analysis_run_create.py
+++ b/tests/test_analysis_run_create.py
@@ -5,6 +5,7 @@
from backend.app.analysis_run_ingestion import (
AnalysisRunCreateError,
_resolve_corporate_entity_id,
+ live_write_after_cutoff,
plan_analysis_run_capture,
)
import pytest
@@ -124,6 +125,17 @@ def test_empty_corpus_uses_the_cutoff_as_latest_available_time() -> None:
assert capture.maximum_available_time == _CUTOFF
+def test_live_write_clock_is_distinct_from_the_cutoff_admission_clock() -> None:
+ """An in-cutoff title can still have been rewritten after the run."""
+ cutoff = _CUTOFF
+ assert live_write_after_cutoff(_EARLIER, cutoff) is False
+ assert live_write_after_cutoff(cutoff, cutoff) is False
+ assert live_write_after_cutoff(
+ datetime(2026, 1, 13, 9, 0, tzinfo=timezone.utc), cutoff
+ ) is True
+ assert live_write_after_cutoff(datetime(2026, 1, 13, 9, 0), cutoff) is True
+
+
def test_create_rejects_an_unaffiliated_or_ambiguous_corporate_entity() -> None:
with pytest.raises(AnalysisRunCreateError) as hidden:
_resolve_corporate_entity_id("corp-other", ["corp-1"])
From 5ca456c96efa5a070d57a8d03fb3cbf9dbc6d576 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 16 Aug 2026 16:43:39 +0000
Subject: [PATCH 2/4] fix: rebase write-clock slice to 0.87.1 with honest
updated_at
Keep #150's cutoff comparison on current #74, take 0.87.1 so it does
not collide with retention purge, bump source_post.updated_at on a
title or body rewrite unless the statement already assigned the clock,
and name both clocks on the opened live post. The seeded post-list
exact set now includes the edited own-corp title.
Co-authored-by: Seongho Bae
---
ARCHITECTURE.md | 4 +-
...> 0.87.1-analysis-run-live-write-clock.md} | 5 +-
CLAUDE.md | 5 +-
backend/tests/test_api.py | 43 +++++++++++-
docker/postgres-init/Dockerfile | 1 +
...016-analysis-run-knowledge-cutoff-posts.md | 6 +-
docs/adr/0021-source-post-write-clock.md | 49 +++++++++++++
.../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 2 +-
docs/doctoring/DESIGN_TOKEN_REFERENCES.md | 2 +-
docs/storybook-inventory.md | 1 +
frontend/package.json | 2 +-
frontend/src/App.css | 14 +++-
frontend/src/App.test.tsx | 5 ++
frontend/src/App.tsx | 70 +++++++++++++++----
.../CutoffWriteClockBadge.stories.tsx | 19 +++++
.../components/CutoffWriteClockBadge.test.tsx | 10 +++
.../src/components/CutoffWriteClockBadge.tsx | 16 +++++
frontend/src/styles/tokens.css | 1 +
lineageweave/__init__.py | 2 +-
migrations/0021_source_post_write_clock.sql | 37 ++++++++++
.../rollback/0021_source_post_write_clock.sql | 9 +++
pyproject.toml | 2 +-
scripts/seed_demo_data.py | 1 +
tests/test_analysis_run_create.py | 16 +++++
tests/test_analysis_run_registry_schema.py | 15 ++++
uv.lock | 2 +-
26 files changed, 311 insertions(+), 28 deletions(-)
rename CHANGELOG.d/{0.87.0-analysis-run-live-write-clock.md => 0.87.1-analysis-run-live-write-clock.md} (51%)
create mode 100644 docs/adr/0021-source-post-write-clock.md
create mode 100644 frontend/src/components/CutoffWriteClockBadge.stories.tsx
create mode 100644 frontend/src/components/CutoffWriteClockBadge.test.tsx
create mode 100644 frontend/src/components/CutoffWriteClockBadge.tsx
create mode 100644 migrations/0021_source_post_write_clock.sql
create mode 100644 migrations/rollback/0021_source_post_write_clock.sql
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e2fe01e72..2de42827e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -483,8 +483,8 @@ 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 still
-shows the live body; titles rewritten after the run are marked
-updated after cutoff. Status history is detail-only
+shows the live body and names both clocks when the title was
+rewritten after the run. 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.87.0-analysis-run-live-write-clock.md b/CHANGELOG.d/0.87.1-analysis-run-live-write-clock.md
similarity index 51%
rename from CHANGELOG.d/0.87.0-analysis-run-live-write-clock.md
rename to CHANGELOG.d/0.87.1-analysis-run-live-write-clock.md
index 701f2fbb4..bb7c0293c 100644
--- a/CHANGELOG.d/0.87.0-analysis-run-live-write-clock.md
+++ b/CHANGELOG.d/0.87.1-analysis-run-live-write-clock.md
@@ -1,5 +1,6 @@
-# 0.87.0 Analysis-run live write clock
+# 0.87.1 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.
+post still matches the January cutoff. The opened live body names both
+clocks. Bodies stay live.
diff --git a/CLAUDE.md b/CLAUDE.md
index 4c92e9807..3cdbeb9f2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,7 +30,8 @@ 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. Titles marked updated
-after cutoff were rewritten after the run; compare those bodies
-before treating them as reconstructed evidence (ADR 0016).
+after cutoff were rewritten after the run; the opened body names
+both clocks. Compare those bodies before treating them as
+reconstructed evidence (ADR 0016 / 0021).
`POST /api/analysis-runs` records Pending on an authorized
cutoff capture (ADR 0017) and does not reconstruct lineage.
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 685e7ad05..860a4ea54 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -33,6 +33,7 @@
_MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql"
_REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql"
_RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql"
+_WRITE_CLOCK_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0021_source_post_write_clock.sql"
def _postgres_available() -> bool:
@@ -117,6 +118,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_MIGRATION_PATH.read_text())
cur.execute(_REGISTRY_MIGRATION.read_text())
cur.execute(_RETENTION_MIGRATION.read_text())
+ cur.execute(_WRITE_CLOCK_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
@@ -523,6 +525,40 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes(
assert "postgresql://" not in str(body)
assert "visible_posts" not in visible
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ "select updated_at from source_post where post_title = %s",
+ ("Own-corp private post",),
+ )
+ pinned = cur.fetchone()[0]
+ cur.execute(
+ "update source_post set post_body = post_body || ' rewritten' "
+ "where post_title = %s",
+ ("Own-corp private post",),
+ )
+ cur.execute(
+ "select updated_at from source_post where post_title = %s",
+ ("Own-corp private post",),
+ )
+ rewritten = cur.fetchone()[0]
+ assert rewritten > pinned
+ cur.execute(
+ "update source_post set post_body = %s, updated_at = %s "
+ "where post_title = %s",
+ ("body", pinned, "Own-corp private post"),
+ )
+ cur.execute(
+ "select updated_at from source_post where post_title = %s",
+ ("Own-corp private post",),
+ )
+ honoured = cur.fetchone()[0]
+ assert honoured == pinned
+ finally:
+ admin_conn.close()
+
hidden = client.get(
f"/api/analysis-runs/{seeded_db['hidden_run_id']}",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
@@ -611,7 +647,12 @@ def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client,
response = client.get("/api/posts", headers={"Authorization": f"Bearer {demo_analyst_token}"})
assert response.status_code == 200
titles = {post["post_title"] for post in response.json()}
- assert titles == {"Public post", "Own-corp private post", "Late own-corp private post"}
+ assert titles == {
+ "Public post",
+ "Own-corp private post",
+ "Late own-corp private post",
+ "Edited own-corp private post",
+ }
public = next(post for post in response.json() if post["post_title"] == "Public post")
assert public["voc_type_label"] == "Voice of Customer"
assert public["visibility_label"] == "Public"
diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile
index ce2f0e6b5..333741958 100644
--- a/docker/postgres-init/Dockerfile
+++ b/docker/postgres-init/Dockerfile
@@ -26,6 +26,7 @@ COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/1
COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql
COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql
COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.sql
+COPY migrations/0021_source_post_write_clock.sql /docker-entrypoint-initdb.d/22-source-post-write-clock.sql
# Official image already drops to this account at runtime; declare it so
# the Dockerfile itself satisfies DS-0002 (explicit non-root USER).
USER postgres
diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
index 274d34dbd..c63e9034f 100644
--- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
+++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
@@ -48,10 +48,14 @@ run.
post (2026-02-10) does not appear.
- Open the run: Demo public post is marked updated after cutoff
(`updated_at` 2026-01-13). Demo private post is not.
+- Open a marked title: the live popup names the write clock and the
+ cutoff. Compare that body with the run before treating it as
+ reconstructed evidence.
- 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. The write
- clock is a projection, not a stored cutoff body.
+ clock is a projection, not a stored cutoff body. Migration 0021
+ (ADR 0021) keeps `updated_at` honest after a title or body write.
- 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/adr/0021-source-post-write-clock.md b/docs/adr/0021-source-post-write-clock.md
new file mode 100644
index 000000000..a8a0e1a45
--- /dev/null
+++ b/docs/adr/0021-source-post-write-clock.md
@@ -0,0 +1,49 @@
+# ADR 0021 — Source-post write clock honors explicit pins
+
+**Decision status:** Accepted
+**Date:** 2026-08-16
+
+## Context
+
+ADR 0016 compares live `source_post.updated_at` with
+`analysis_run.knowledge_cutoff` so the operator can see which in-cutoff
+titles were rewritten after the run. The column defaulted to `now()` on
+insert and never moved on a later title or body write, so a real rewrite
+stayed unmarked and `make seed` could not pin Demo public post to
+2026-01-13 unless every statement assigned `updated_at`.
+
+W3C Time Ontology in OWL (Hobbs & Pan, 2017) and ISO 8601-1:2019 keep
+the live write clock distinct from the analysis cutoff. A missing write
+clock and a confidently-in-cutoff clock are different things.
+
+## Decision
+
+Migration `0021_source_post_write_clock.sql` adds
+`touch_source_post_write_clock` on `BEFORE UPDATE` of `source_post`.
+The trigger bumps `updated_at` to `clock_timestamp()` only when
+`post_title` or `post_body` changes and the statement did not already
+assign `updated_at`. Thread-group, visibility, or grouping-key updates
+do not pretend to be a rewrite.
+
+`make seed` may still pin Demo public post to 2026-01-13 and Demo
+private post to its create clock. A later product body edit after the
+January cutoff marks the title.
+
+## Consequences
+
+- After `make seed`, open the Demo Corp lineage run: Demo public post
+ is marked updated after cutoff; Demo private post is not.
+- Open a marked title: the live popup names both clocks. Compare that
+ body with the run before treating it as reconstructed evidence.
+- Cutoff body versioning remains a later slice.
+- Roll back `0021` before `0020` / `0018` when emptying a database
+ that also uses the retention purge.
+
+## References
+
+International Organization for Standardization. (2019). *ISO 8601-1:2019:
+Date and time—Representations for information interchange—Part 1: Basic
+rules* (confirmed 2024; Amendment 1:2022).
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
index 8f465a482..101f9d72b 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). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. |
+| 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. Migration 0021 bumps `updated_at` on a title or body rewrite unless the statement already assigned the clock (ADR 0021). |
| 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/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
index 2f0647dca..ba4624c5a 100644
--- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
+++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
@@ -8,7 +8,7 @@ the Storybook inventory.
| Source | Product implication | Implemented evidence |
|---|---|---|
-| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--radius-chip`, and `--font-*`. `CitationChip` and `PopupCloseButton` read those names through `App.css`. |
+| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--radius-chip`, and `--font-*`. `CitationChip`, `PopupCloseButton`, and `CutoffWriteClockBadge` read those names through `App.css`. |
| Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. |
## APA 7th references
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 282e3515e..296d300d4 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -6,6 +6,7 @@ buyer-facing control you can click before changing product CSS.
| Story | Buyer next action | Token / module |
|---|---|---|
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
+| `AnalysisRun/CutoffWriteClockBadge` | Open the marked title and compare the live body with the run cutoff. | `--color-accent-border`, `--radius-chip`, `--font-size-badge`, `CutoffWriteClockBadge` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
Repeated web objects must use `frontend/src/styles/tokens.css` and a module
diff --git a/frontend/package.json b/frontend/package.json
index 0d43d9fa2..25956961a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.87.0",
+ "version": "0.87.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 5251e69f8..35577150b 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -49,11 +49,23 @@
}
.post-badge {
- font-size: 0.75rem;
+ font-size: var(--font-size-badge);
opacity: 0.7;
text-transform: uppercase;
}
+.cutoff-write-clock-badge {
+ display: inline-block;
+ margin-left: var(--space-chip-gap);
+ padding: var(--space-chip-block) var(--space-chip-inline);
+ border: 1px solid var(--color-accent-border);
+ border-radius: var(--radius-chip);
+ background: var(--color-accent-background);
+ color: var(--color-text-heading);
+ font-size: var(--font-size-badge);
+ text-transform: uppercase;
+}
+
.popup-backdrop {
position: fixed;
inset: 0;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index f7468ba5b..233aee1f4 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1727,6 +1727,11 @@ describe("App, authenticated", () => {
}),
);
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
+ expect(
+ screen.getByText(
+ "This live body was rewritten on 2026-01-13, after cutoff 2026-01-12. Compare it with this run before you treat it as reconstructed evidence.",
+ ),
+ ).toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0ae3a3e50..2e3ccab68 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -59,6 +59,7 @@ import {
type VocEvidence,
} from "./api";
import { CitationChip } from "./components/CitationChip";
+import { CutoffWriteClockBadge } from "./components/CutoffWriteClockBadge";
import { PopupCloseButton } from "./components/PopupCloseButton";
import { LineageDag } from "./LineageDag";
import { PostBody } from "./PostBody";
@@ -1163,13 +1164,15 @@ function PostDetailPopup({
graph,
onClose,
onSelectPost,
+ liveWriteClock,
}: {
postId: string;
accessToken: string;
canExtract: boolean;
graph: LineageGraph | null;
onClose: () => void;
- onSelectPost?: (postId: string) => void;
+ onSelectPost?: SelectPostFn;
+ liveWriteClock?: AnalysisRunLiveWriteClock | null;
}) {
const [post, setPost] = useState(null);
const [error, setError] = useState(null);
@@ -1242,6 +1245,9 @@ function PostDetailPopup({
{post.visibility_label ?? post.visibility_code} ·{" "}
{new Date(post.created_at).toLocaleString()}
+ {liveWriteClock?.liveAfterCutoff && (
+
{analysisRunOpenedPostWarning(liveWriteClock)}
+ )}
@@ -1587,6 +1593,30 @@ function analysisRunLivePostButtonLabel(post: {
return `Open live post: ${post.post_title}`;
}
+type AnalysisRunLiveWriteClock = {
+ knowledgeCutoff: string;
+ updatedAt?: string;
+ liveAfterCutoff?: boolean;
+};
+
+type SelectPostFn = (postId: string, liveWriteClock?: AnalysisRunLiveWriteClock) => void;
+
+/**
+ * Next action after a marked cutoff title opens the live body.
+ */
+function analysisRunOpenedPostWarning(clock: AnalysisRunLiveWriteClock): string {
+ const cutoffDate = clock.knowledgeCutoff.slice(0, 10);
+ const writtenDate = clock.updatedAt?.slice(0, 10);
+ if (!clock.liveAfterCutoff) {
+ return `This live body still matches the write clock this run knew (cutoff ${cutoffDate}).`;
+ }
+ const written = writtenDate ? ` on ${writtenDate}` : "";
+ return (
+ `This live body was rewritten${written}, after cutoff ${cutoffDate}. ` +
+ "Compare it with this run before you treat it as reconstructed evidence."
+ );
+}
+
function AnalysisRunReproducibilityDigests({
codeRevisionSha,
configurationSha256,
@@ -1622,7 +1652,7 @@ function AnalysisRunsPanel({
onSelectPost,
}: {
accessToken: string;
- onSelectPost: (postId: string) => void;
+ onSelectPost: SelectPostFn;
}) {
const [runs, setRuns] = useState(null);
const [selected, setSelected] = useState(null);
@@ -1760,13 +1790,17 @@ function AnalysisRunsPanel({
- {post.live_after_cutoff && (
- Updated after cutoff
- )}
+ {post.live_after_cutoff && }
))}
@@ -2036,6 +2070,12 @@ function PostList({ accessToken }: { accessToken: string }) {
const [graph, setGraph] = useState(null);
const [error, setError] = useState(null);
const [selectedPostId, setSelectedPostId] = useState(null);
+ const [liveWriteClock, setLiveWriteClock] = useState(null);
+
+ function selectPost(postId: string, comparison?: AnalysisRunLiveWriteClock) {
+ setSelectedPostId(postId);
+ setLiveWriteClock(comparison ?? null);
+ }
const [canRebuild, setCanRebuild] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
const [rebuildError, setRebuildError] = useState(null);
@@ -2067,9 +2107,9 @@ function PostList({ accessToken }: { accessToken: string }) {
return (
<>
-
-
-
+
+
+