Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,13 @@ revision and configuration digest prefixes.
`tepp_client` on that same snapshot; the default transport is
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, counts, status history)
without exposing a DSN or raw record. Status history is detail-only
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, then compares the
live ``updated_at`` write clock with that cutoff so the operator can
decide whether to treat the opened text as reconstructed evidence.
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
list rows add a next-action line (open the run, then connect the
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 0.84.1 Analysis-run digest a11y and live-body warning

Detail prefixes stay audible and hoverable. Open a cutoff title only
after reading that the live body may have changed since the run.
The list stays aggregates-only.
4 changes: 4 additions & 0 deletions CHANGELOG.d/0.84.2-analysis-run-live-write-clock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# 0.84.2 Analysis-run live write clock

Open a cutoff title, then read whether the live body was written after
that run. Do not treat a later rewrite as reconstructed evidence.
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ 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.84.2] - 2026-08-16

### Added

- Opening a cutoff title now compares the live `source_post.updated_at`
write clock with that run's knowledge cutoff. Open the Demo Corp
lineage run, then a listed title: if the body was rewritten after
2026-01-12, do not treat it as reconstructed evidence. A write clock
at or before the cutoff tells you the opened text is still the
cutoff corpus. Post-body versioning remains later work (ADR 0016).
- `GET /api/posts` and `GET /api/posts/{id}` include `updated_at`.
Seed historical Demo posts keep `updated_at = created_at` so the
January run does not look rewritten at seed time.

## [0.84.1] - 2026-08-16

### Fixed

- Analysis-run detail keeps 12-character digest prefixes as visible
text (so assistive technology hears `Code` / `Config` values) and
puts the full digest on hover. Open the Demo Corp lineage run, hover
a prefix, and match it to the API payload. The home list still hides
digests even when the list JSON includes them.
- Opening a cutoff title now says the live body may have changed after
that run. Compare the opened post with the cutoff date before you
treat it as reconstructed evidence (ADR 0016).

## [0.84.0] - 2026-08-16

### Added
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the
ADRs under `docs/adr/`. Do not fork those rules here.

## Analysis-run seed (v0.84.0)
## Analysis-run seed (v0.84.2)

`make seed` writes a Demo Corp lineage run and a TEPP run on the same
snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing
Expand All @@ -12,3 +12,7 @@ transport or an unused accepted envelope is Failed
theta or a local psychometric substitute. The home list caption stays
`kind · status · entity`; the machine failure code is detail-only
(ADR 0014). Open the Failed row, then connect a live TEPP transport.
Digest prefixes stay audible; hover a prefix to read the full digest.
Opening a cutoff title shows the live post and compares its
``updated_at`` write clock with the run cutoff before you treat the
body as reconstructed evidence (ADR 0016).
18 changes: 13 additions & 5 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,12 @@ async def fetch_visible_scope_posts(

``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 the live write clock so the operator can compare
today's body with that cutoff before treating it as evidence.
"""
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 "
"select post_id, post_title, visibility_code, corporate_entity_id, updated_at "
"from source_post where corporate_entity_id = $1 "
"and created_at <= $2 "
"order by created_at, post_title",
Expand All @@ -256,7 +258,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 "
"select post_id, post_title, visibility_code, corporate_entity_id, updated_at "
"from source_post where process_unit_id = $1 "
"and created_at <= $2 "
"order by created_at, post_title",
Expand All @@ -265,7 +267,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 "
"select post_id, post_title, visibility_code, corporate_entity_id, updated_at "
"from source_post where thread_group_key = $1 "
"and created_at <= $2 "
"order by created_at, post_title",
Expand All @@ -274,7 +276,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 "
"select post_id, post_title, visibility_code, corporate_entity_id, updated_at "
"from source_post where created_at <= $1 "
"order by created_at, post_title",
knowledge_cutoff,
Expand All @@ -287,5 +289,11 @@ async def fetch_visible_scope_posts(
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"]})
posts.append(
{
"post_id": str(row["post_id"]),
"post_title": row["post_title"],
"updated_at": _iso(row["updated_at"]),
}
)
return posts
11 changes: 8 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,11 @@ def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool:


def _serialize_post(post: asyncpg.Record, labels: dict[str, str] | None = None) -> dict[str, Any]:
"""Turn a ``source_post`` row into the public JSON shape."""
"""Turn a ``source_post`` row into the public JSON shape.

``updated_at`` is the live write clock the analysis-run popup
compares with ``knowledge_cutoff`` (ADR 0016).
"""
resolved = labels or {}
voc = post["voc_type_code"]
visibility = post["visibility_code"]
Expand All @@ -290,6 +294,7 @@ def _serialize_post(post: asyncpg.Record, labels: dict[str, str] | None = None)
"visibility_code": visibility,
"visibility_label": resolved.get(visibility, visibility),
"created_at": post["created_at"].isoformat(),
"updated_at": post["updated_at"].isoformat(),
}


Expand Down Expand Up @@ -351,7 +356,7 @@ async def list_posts(
_require_post_read(account)
async with pool.acquire() as conn:
rows = await conn.fetch(
"select post_id, post_title, voc_type_code, visibility_code, corporate_entity_id, created_at "
"select post_id, post_title, voc_type_code, visibility_code, corporate_entity_id, created_at, updated_at "
"from source_post order by created_at desc"
)
visible = [row for row in rows if _can_see_post(account, row)]
Expand All @@ -369,7 +374,7 @@ async def read_post(
_require_post_read(account)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"select post_id, post_title, post_body, voc_type_code, visibility_code, corporate_entity_id, created_at "
"select post_id, post_title, post_body, voc_type_code, visibility_code, corporate_entity_id, created_at, updated_at "
"from source_post where post_id = $1",
post_id,
)
Expand Down
25 changes: 21 additions & 4 deletions docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@ 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.
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.

Reproducibility digests on the same detail use a labeled group whose
accessible name does not replace the visible prefixes (W3C Accessible
Name and Description Computation 1.1). Full digests stay on `title`
for hover verification and on the API payload; the home list stays
aggregates-only.

Seed and API fixtures backdate in-cutoff posts. A late own-corp private
post remains on the live post list and stays out of the January 2026
Expand All @@ -36,9 +44,14 @@ 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, then open a listed post, to inspect what that cutoff
actually reconstructed.
- Post-body versioning at the cutoff remains future work.
- Open the run, read the live-body warning, then open a listed post.
The opened popup compares ``source_post.updated_at`` with this
cutoff. If the live row was written after the run, do not treat the
body 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 comparison is the operator action until that snapshot exists.

## References

Expand All @@ -48,3 +61,7 @@ rules* (confirmed 2024; Amendment 1:2022).

World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
Recommendation). https://www.w3.org/TR/owl-time/

World Wide Web Consortium. (2018). *Accessible name and description
computation 1.1* (W3C Recommendation).
https://www.w3.org/TR/accname-1.1/
6 changes: 5 additions & 1 deletion docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
| 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). |
| 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 compares the live `updated_at` write clock with that cutoff. |
| 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. |
| NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, and exclusion of raw source/provider payloads. |
Expand Down Expand Up @@ -98,5 +99,8 @@ PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation:
World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C
Recommendation). https://www.w3.org/TR/prov-o/

World Wide Web Consortium. (2018). *Accessible name and description
computation 1.1* (W3C Recommendation). https://www.w3.org/TR/accname-1.1/

World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation).
https://www.w3.org/TR/owl-time/
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.84.0",
"version": "0.84.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,26 @@
cursor: pointer;
}

:root {
--lw-opacity-meta: 0.7;
--lw-font-size-meta: 0.85rem;
}

.post-meta {
opacity: 0.7;
font-size: 0.85rem;
opacity: var(--lw-opacity-meta);
font-size: var(--lw-font-size-meta);
}

.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

.post-body {
Expand Down
62 changes: 60 additions & 2 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe("App, authenticated", () => {
chatUnavailable?: boolean;
searchUnavailable?: boolean;
verificationEvidenceUrl?: string | null;
postUpdatedAt?: string;
}) {
const statusLabel: Record<string, string> = {
open: "Open",
Expand Down Expand Up @@ -283,6 +284,9 @@ describe("App, authenticated", () => {
count_value: 3,
},
],
code_revision_sha: "abcdef0123456789deadbeefcafebabe",
configuration_sha256:
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
{
analysis_run_id: "run-demo-tepp",
Expand Down Expand Up @@ -547,6 +551,7 @@ describe("App, authenticated", () => {
visibility_code: "public",
visibility_label: "Public",
created_at: "2026-01-01T00:00:00Z",
updated_at: options?.postUpdatedAt ?? "2026-01-01T00:00:00Z",
}),
);
}
Expand Down Expand Up @@ -1448,6 +1453,12 @@ describe("App, authenticated", () => {
expect(list).toHaveTextContent("3 documents");
expect(list).not.toHaveTextContent("postgresql://");
expect(list).not.toHaveTextContent("select ");
expect(list).not.toHaveTextContent("Code abcdef012345");
expect(list).not.toHaveTextContent("Config 0123456789ab");
expect(list).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe");
expect(list).not.toHaveTextContent(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
);

await userEvent.click(
screen.getByRole("button", {
Expand All @@ -1458,21 +1469,39 @@ describe("App, authenticated", () => {
expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument();
expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument();
const digests = screen.getByLabelText("Analysis run reproducibility digests");
expect(digests).toHaveTextContent("Hover a prefix to read the full digest for verification.");
expect(digests).toHaveTextContent("Code abcdef012345");
expect(digests).toHaveTextContent("Config 0123456789ab");
expect(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe");
expect(digests).not.toHaveTextContent(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
);
expect(screen.getByTitle("abcdef0123456789deadbeefcafebabe")).toHaveTextContent("Code abcdef012345");
expect(
screen.getByTitle("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
).toHaveTextContent("Config 0123456789ab");
const history = screen.getByRole("list", { name: "Analysis run status history" });
expect(history).toHaveTextContent("Pending 2026-01-12 12:31");
expect(history).toHaveTextContent("Running 2026-01-12 12:32");
expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33");
expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open run post: Public post" })).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.",
),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Open live post (may have changed after cutoff): Public post",
}),
).toBeInTheDocument();
expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument();

await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" }));
await userEvent.click(
screen.getByRole("button", {
name: "Open live post (may have changed after cutoff): Public post",
}),
);
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());

await userEvent.click(
Expand All @@ -1489,6 +1518,35 @@ describe("App, authenticated", () => {
expect(teppHistory).not.toHaveTextContent("Succeeded");
});

it("compares the live write clock with the run cutoff when a cutoff title is opened", async () => {
stubBackend({ postUpdatedAt: "2026-02-01T00:00:00Z" });
render(<App />);

await userEvent.click(
await screen.findByRole("button", {
name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp",
}),
);
await userEvent.click(
screen.getByRole("button", {
name: "Open live post (may have changed after cutoff): Public post",
}),
);
expect(
await screen.findByText(
"This live body was last written after cutoff 2026-01-12. Do not treat it as reconstructed evidence.",
),
).toBeInTheDocument();

await userEvent.click(screen.getByRole("button", { name: "Close" }));
await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
expect(
screen.queryByText(/This live body was last written after cutoff/),
).not.toBeInTheDocument();
expect(screen.queryByText(/has not been written since cutoff/)).not.toBeInTheDocument();
});

it("shows the calibrated period-report mean theta on the home page", async () => {
stubBackend();
render(<App />);
Expand Down
Loading