From 685eea68808bc2321ed4d182086a92cb24deeb2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:50:06 +0000 Subject: [PATCH 1/4] feat: apply analysis-run knowledge cutoff to visible posts (v0.83.0) A later own-corp post no longer appears inside a historical run. Detail shows revision and configuration digest prefixes so an operator can confirm the run they approved. AnalysisRunsPanel is extracted for the Storybook inventory. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 7 +- .../0.83.0-analysis-run-cutoff-posts.md | 5 + CHANGELOG.md | 17 +++ CLAUDE.md | 8 + backend/app/analysis_run_ingestion.py | 21 ++- backend/tests/test_api.py | 25 +++- docs/adr/0014-authorized-analysis-run-read.md | 2 + ...016-analysis-run-knowledge-cutoff-posts.md | 58 +++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 8 + docs/storybook/INVENTORY.md | 30 ++++ .../plans/2026-08-15-analysis-run-registry.md | 10 +- frontend/package.json | 2 +- frontend/src/AnalysisRunsPanel.test.tsx | 35 +++++ frontend/src/AnalysisRunsPanel.tsx | 141 ++++++++++++++++++ frontend/src/App.css | 2 +- frontend/src/App.test.tsx | 5 + frontend/src/App.tsx | 126 +--------------- frontend/src/analysisRunDisplay.ts | 24 +++ frontend/src/api.ts | 3 + frontend/src/index.css | 6 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 23 files changed, 399 insertions(+), 142 deletions(-) create mode 100644 CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md create mode 100644 CLAUDE.md create mode 100644 docs/adr/0016-analysis-run-knowledge-cutoff-posts.md create mode 100644 docs/storybook/INVENTORY.md create mode 100644 frontend/src/AnalysisRunsPanel.test.tsx create mode 100644 frontend/src/AnalysisRunsPanel.tsx create mode 100644 frontend/src/analysisRunDisplay.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3f3a7cc9a..11cd837e8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -463,9 +463,12 @@ in SQL: the requester always sees their own run; a corporate-entity or process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. Detail also lists ABAC-visible post titles in the -run's scope so a buyer can open a post without seeing hidden rows. +run's scope whose `created_at` is at or before the run +`knowledge_cutoff`, so a later post cannot appear inside a historical +reconstruction (ADR 0016). Detail also returns revision and +configuration digest prefixes. The home list is clickable: `GET /api/analysis-runs/{id}` fills a -labeled detail (cutoff, requested date, counts, status history) +labeled detail (cutoff, requested date, counts, status history, digests) without exposing a DSN or raw record. 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. The diff --git a/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md new file mode 100644 index 000000000..e1d4ccab6 --- /dev/null +++ b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md @@ -0,0 +1,5 @@ +# 0.83.0 analysis-run cutoff-scoped posts + +Detail lists only posts known at the run knowledge cutoff and shows +revision/config digest prefixes so an operator can confirm the run +they approved. Later own-corp posts stay hidden. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac2df4ec..b7bd43c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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.83.0] - 2026-08-16 + +### Fixed + +- Analysis-run detail now lists only posts whose `created_at` is at or + before the run `knowledge_cutoff`. After `make seed`, a later own-corp + post does not appear inside the January 12 reconstruction. Open a later + run, or ask an administrator to capture a newer snapshot, when the list + is empty. + +### Added + +- Run detail shows revision and configuration digest prefixes so an + operator can confirm the run matches the code they approved (ADR 0016). +- `AnalysisRunsPanel` is a standalone home-page module with a Storybook + inventory for the next frontend toolchain slice. + ## [0.82.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..27b9b7a13 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +Read [AGENTS.md](AGENTS.md) first. Product architecture lives in +[ARCHITECTURE.md](ARCHITECTURE.md). Active analysis-run decisions are +ADR 0013–0016 under `docs/adr/`. + +This repository ships synthetic data only. Do not add fixtures, tests, +or examples derived from a real organization's records. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index c18ddf326..72c2c7de9 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -2,9 +2,10 @@ The registry itself is issue #89 / migration 0018. This module is the product projection: an account sees only runs they requested or whose -scope they already have ABAC authority to walk. Aggregate counts and -lookup labels come back; source SQL, DSNs, raw records, and provider -payloads never do. +scope they already have ABAC authority to walk. Detail post titles are +further limited to posts known at the run knowledge cutoff. Aggregate +counts and lookup labels come back; source SQL, DSNs, raw records, and +provider payloads never do. """ from __future__ import annotations @@ -226,6 +227,7 @@ async def fetch_visible_analysis_run( row["process_unit_id"], row["scope_key"], affiliated_entity_ids, + row["knowledge_cutoff"], ) return detail @@ -237,33 +239,42 @@ async def fetch_visible_scope_posts( process_unit_id: Any, scope_key: str | None, affiliated_entity_ids: list[str], + knowledge_cutoff: Any, ) -> list[dict[str, str]]: - """ABAC-visible post titles in the run's scope -- never a hidden body.""" + """ABAC-visible titles known at the run cutoff -- never a hidden body.""" 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 " "from source_post where corporate_entity_id = $1 " + "and created_at <= $2 " "order by created_at, post_title", corporate_entity_id, + knowledge_cutoff, ) 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 " "from source_post where process_unit_id = $1 " + "and created_at <= $2 " "order by created_at, post_title", process_unit_id, + knowledge_cutoff, ) 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 " "from source_post where thread_group_key = $1 " + "and created_at <= $2 " "order by created_at, post_title", scope_key, + knowledge_cutoff, ) elif scope_kind_code == "analysis_scope_all_visible": rows = await conn.fetch( "select post_id, post_title, visibility_code, corporate_entity_id " - "from source_post order by created_at, post_title" + "from source_post where created_at <= $1 " + "order by created_at, post_title", + knowledge_cutoff, ) else: return [] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index cfc2a5559..1ec47b88f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -296,11 +296,18 @@ def _seed_analysis_run( (account_id, role_id), ) - def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: str = "body") -> str: + def _insert_post( + title: str, + corporate_entity_id, + visibility_code: str, + body: str = "body", + created_at: str = "2026-01-10T09:00:00Z", + ) -> str: cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " - "values (%s, %s, %s, %s, 'voc', %s) returning post_id", - (account_id, corporate_entity_id, title, body, visibility_code), + "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), ) return str(cur.fetchone()[0]) @@ -313,6 +320,13 @@ def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: st "The weather in Gwangju was irrelevant.", ) other_private_post_id = _insert_post("Other-corp private post", other_corp_id, "private") + _insert_post( + "Late own-corp private post", + own_corp_id, + "private", + "Written after the analysis-run knowledge cutoff.", + created_at="2026-01-13T09:00:00Z", + ) cur.execute( "insert into cataloged_person (person_name, person_side_code) values " @@ -477,7 +491,10 @@ 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 "Late own-corp private post" not in titles assert "Other-corp private post" not in titles + assert body["code_revision_sha"] == "c" * 40 + assert body["configuration_sha256"] == "b" * 64 assert "postgresql://" not in str(body) assert "visible_posts" not in visible diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index c0f32beef..6349ae596 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -31,6 +31,8 @@ LineageWeave owns a fail-closed read projection of the #89 registry: - `GET /api/analysis-runs/{id}` also returns the append-only labeled `status_history`. The list does not. A failed event may include the stored machine `failure_code`; this slice does not invent a label. +- Detail post titles are further limited to + `source_post.created_at <= knowledge_cutoff` (ADR 0016). - TEPP remains a versioned `AnalysisRunRequest` consumer (`lineageweave.tepp_client`). This slice does not fork TEPP arithmetic. - contextual-orchestrator remains the only LLM path. This slice does not diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md new file mode 100644 index 000000000..b918c3450 --- /dev/null +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -0,0 +1,58 @@ +# ADR 0016 — Analysis-run post lists apply the run knowledge cutoff + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0013 normalized analysis-run registry; ADR 0014 authorized analysis-run read +**Refs:** Issue #79 (Milestone 2 parent); PR #89 registry + read projection + +## Context + +PR #89 stores `knowledge_cutoff` on each `analysis_run` and ADR 0013 +requires that a run may use only evidence available at that cutoff. +The v0.82 authorized detail listed every ABAC-visible post in the run +scope. A post written after the cutoff therefore appeared inside a +historical reconstruction. That is the buyer-visible temporal leak: +an operator cannot trust that "this run" is the evidence the run was +allowed to know. + +The registry already distinguishes snapshot availability from run +cutoff (Jensen & Snodgrass, 1999; W3C Time Ontology in OWL, 2022). +The read projection must apply the same as-of predicate when it +projects `source_post` titles. + +## Decision + +`GET /api/analysis-runs/{id}` includes a post title only when: + +1. the post is in the run's scope; +2. the caller already has ABAC authority to see that post; +3. `source_post.created_at <= analysis_run.knowledge_cutoff`. + +Hidden or later posts never appear. The list payload stays +aggregates-only. Detail also returns `code_revision_sha` and +`configuration_sha256` so an operator can confirm the run matches +the code and configuration they approved. Prefixes are shown in the +home panel; full digests remain on the API. + +The home Analysis runs panel lives in `AnalysisRunsPanel` so the +repeating list/detail object can be inventoried for Storybook without +growing `App.tsx`. + +## Consequences + +Fixture posts that belong in a January 2026 run must carry a +`created_at` at or before that cutoff. A later own-corp post is the +falsifiable counter-example. Write/rebuild APIs, TEPP submission, and +run-scoped post bodies remain later slices. + +## References + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. +*IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. +https://doi.org/10.1109/69.755613 + +Snodgrass, R. T. (Ed.). (1995). *The TSQL2 temporal query language*. +Springer. https://doi.org/10.1007/978-1-4615-2289-8 + +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 b439cb9ca..4dfeb8813 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -69,6 +69,7 @@ provenance, retention, and immutable evidence rather than blanket masking. |---|---| | One snapshot supports multiple analyses | Insert two runs over one snapshot with different valid cutoffs. | | Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. | +| Later posts are excluded from a historical run | A post with `created_at` after `knowledge_cutoff` is absent from `visible_posts`. | | Evidence cannot change after derivation | Reject snapshot/count updates and count insert/delete after the first run. | | Count/run race is serialized | Both paths acquire the snapshot row first; a later concurrency test must prove one legal winner and no lost freeze. | | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | @@ -82,6 +83,10 @@ 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). +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE +Transactions on Knowledge and Data Engineering, 11*(1), 36–44. +https://doi.org/10.1109/69.755613 + Kent, K., & Souppaya, M. (2006). *Guide to computer security log management* (NIST Special Publication 800-92). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-92 @@ -89,6 +94,9 @@ Technology. https://doi.org/10.6028/NIST.SP.800-92 Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ +Snodgrass, R. T. (Ed.). (1995). *The TSQL2 temporal query language*. +Springer. https://doi.org/10.1007/978-1-4615-2289-8 + OpenAPI Initiative. (2025). *OpenAPI specification, version 3.2.0*. https://spec.openapis.org/oas/v3.2.0.html diff --git a/docs/storybook/INVENTORY.md b/docs/storybook/INVENTORY.md new file mode 100644 index 000000000..98225be1b --- /dev/null +++ b/docs/storybook/INVENTORY.md @@ -0,0 +1,30 @@ +# Storybook inventory + +**Status:** Component inventory for the repeating home-page objects. +Storybook itself is the next frontend toolchain slice; do not add a +second Node package manager while adding it (`frontend/mise.toml` +pins Node 24, Corepack pnpm only). + +## Repeating objects + +| Object | Module | States a Storybook story must cover | +|---|---|---| +| Analysis run list | `frontend/src/AnalysisRunsPanel.tsx` | empty (`make seed` hint), loading, one succeeded Demo Corp run, hidden-run error | +| Analysis run detail | same | cutoff + requested date, status history, digest prefixes, in-cutoff posts, no posts at cutoff | +| Post list chip | `frontend/src/App.tsx` | public / private badges | +| Calendar commitment | `frontend/src/App.tsx` | dated open ticket | +| Period report row | `frontend/src/App.tsx` | mean θ, CAT item, member click-through | +| Post popup | `frontend/src/App.tsx` | summary, lineage, Keyman, tickets, chat | + +## Design tokens + +Repeating chips, badges, and list rows must use the CSS custom +properties in `frontend/src/index.css` (`--lw-space-*`, +`--lw-radius-*`, `--lw-color-danger`) rather than one-off hex values +when those objects are next extracted. + +## Next action + +Add Storybook via `pnpm` in `frontend/` with `@storybook/react-vite`, +then write CSF stories for `AnalysisRunsPanel` first. Keep stories on +synthetic Demo Corp fixtures only. diff --git a/docs/superpowers/plans/2026-08-15-analysis-run-registry.md b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md index a3ed77d27..4cddaec1c 100644 --- a/docs/superpowers/plans/2026-08-15-analysis-run-registry.md +++ b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md @@ -67,7 +67,15 @@ for Milestone 2 analysis requests and lifecycle evidence. 8. Obtain independent exact-head review and merge only after the parent PR is on protected `main` and base-sensitive evidence is regenerated. -## Task 5 — Next bounded vertical slice +## Task 5 — Knowledge-cutoff post projection (v0.83.0) + +1. Fail if a post written after `knowledge_cutoff` appears in + `visible_posts`. +2. Apply `created_at <= knowledge_cutoff` in the authorized read. +3. Show revision/config digest prefixes on the home detail. +4. Extract `AnalysisRunsPanel` and inventory Storybook states. + +## Task 6 — Next bounded vertical slice After this registry reaches protected main: diff --git a/frontend/package.json b/frontend/package.json index 5d3f2e2b8..d1e24268f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.82.0", + "version": "0.83.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/AnalysisRunsPanel.test.tsx b/frontend/src/AnalysisRunsPanel.test.tsx new file mode 100644 index 000000000..14d00e629 --- /dev/null +++ b/frontend/src/AnalysisRunsPanel.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; +import type { AnalysisRun } from "./api"; + +const sampleRun: AnalysisRun = { + analysis_run_id: "run-demo-lineage", + run_kind_code: "analysis_run_lineage", + run_kind_label: "Lineage reconstruction", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:30:00Z", + source_counts: [], +}; + +describe("analysisRunCaption", () => { + it("joins kind, status, and scope so the operator knows which run to open", () => { + expect(analysisRunCaption(sampleRun)).toBe( + "Lineage reconstruction · Succeeded · Demo Corp", + ); + }); +}); + +describe("shortDigest", () => { + it("returns a 12-character prefix for comparing an approved revision", () => { + expect(shortDigest("c".repeat(40))).toBe("c".repeat(12)); + }); + + it("returns null when a digest is missing so the UI can hide the row", () => { + expect(shortDigest(undefined)).toBeNull(); + }); +}); diff --git a/frontend/src/AnalysisRunsPanel.tsx b/frontend/src/AnalysisRunsPanel.tsx new file mode 100644 index 000000000..28341a744 --- /dev/null +++ b/frontend/src/AnalysisRunsPanel.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { + BackendError, + fetchAnalysisRun, + fetchAnalysisRuns, +} from "./api"; +import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; + +/** + * Home-page Analysis runs list and authorized detail. + * + * Click a run to load cutoff, lifecycle, reproducibility prefixes, and + * posts that existed at that cutoff. Hidden runs stay "not visible." + */ +export function AnalysisRunsPanel({ + accessToken, + onSelectPost, +}: { + accessToken: string; + onSelectPost: (postId: string) => void; +}) { + const [runs, setRuns] = useState(null); + const [selected, setSelected] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetchAnalysisRuns(accessToken) + .then((payload) => setRuns(payload.analysis_runs)) + .catch((err) => setError(String(err))); + }, [accessToken]); + + async function handleOpen(runId: string) { + setError(null); + try { + setSelected(await fetchAnalysisRun(accessToken, runId)); + } catch (err) { + setSelected(null); + if (err instanceof BackendError && err.status === 404) { + setError("This analysis run is not visible."); + return; + } + setError(String(err)); + } + } + + if (error && runs === null) return

{error}

; + if (runs === null) return

Loading analysis runs...

; + + return ( +
+
+

Analysis runs

+
+ {error &&

{error}

} + {runs.length === 0 ? ( +

+ No analysis runs visible to this account yet -- try `make seed`. +

+ ) : ( +
    + {runs.map((run) => { + const documentCount = run.source_counts.find( + (count) => count.count_type_code === "analysis_count_document", + ); + const caption = analysisRunCaption(run); + return ( +
  • + +
  • + ); + })} +
+ )} + {selected && ( +
+

{analysisRunCaption(selected)}

+

+ Cutoff {selected.knowledge_cutoff.slice(0, 10)} + {" · "} + Requested {selected.requested_at.slice(0, 10)} +

+ {(shortDigest(selected.code_revision_sha) || shortDigest(selected.configuration_sha256)) && ( +

+ Use these digests to confirm this run matches the code and configuration you approved. + {shortDigest(selected.code_revision_sha) && ` Revision ${shortDigest(selected.code_revision_sha)}`} + {shortDigest(selected.configuration_sha256) && ` · Config ${shortDigest(selected.configuration_sha256)}`} +

+ )} +
    + {selected.source_counts.map((count) => ( +
  • + {count.count_value} {count.count_type_label.toLowerCase()} +
  • + ))} +
+ {selected.status_history && selected.status_history.length > 0 && ( +
    + {selected.status_history.map((event) => ( +
  1. + {event.status_label} {event.occurred_at.slice(0, 16).replace("T", " ")} + {event.failure_code ? ` · ${event.failure_code}` : ""} +
  2. + ))} +
+ )} + {selected.visible_posts && selected.visible_posts.length > 0 ? ( +
    + {selected.visible_posts.map((post) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ No posts were available at this cutoff. Open a later run, or ask an + administrator to capture a newer snapshot. +

+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/App.css b/frontend/src/App.css index dfd0f2e81..86f900a2b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -24,7 +24,7 @@ } .error { - color: #b91c1c; + color: var(--lw-color-danger); } .post-list { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4fb1b5649..368ac70a4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -190,6 +190,9 @@ describe("App, authenticated", () => { }, ], visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + configuration_schema_version: "lineage-run-v1", + configuration_sha256: "b".repeat(64), + code_revision_sha: "c".repeat(40), status_history: [ { status_ordinal: 1, @@ -1391,6 +1394,8 @@ describe("App, authenticated", () => { expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); expect(screen.getByRole("button", { name: "Open run post: Public post" })).toBeInTheDocument(); + expect(screen.getByText(/Revision cccccccccccc/)).toBeInTheDocument(); + expect(screen.getByText(/Config bbbbbbbbbbbb/)).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f350928d..095cc7346 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,8 +7,6 @@ import { deriveCommitment, evaluatePost, extractPostKeymen, - fetchAnalysisRun, - fetchAnalysisRuns, fetchCalendar, fetchLineageGraph, fetchMe, @@ -35,7 +33,6 @@ import { verifyPostRelations, type ActivityEvent, type AffiliateNode, - type AnalysisRun, type CalendarEntry, type ChatAnswer, type ChatExchange, @@ -55,6 +52,7 @@ import { type RelatedNode, type VocEvidence, } from "./api"; +import { AnalysisRunsPanel } from "./AnalysisRunsPanel"; import { LineageDag } from "./LineageDag"; import { subgraphForPost } from "./lineageLayout"; import "./App.css"; @@ -1347,128 +1345,6 @@ function PostDetailPopup({ ); } -function analysisRunCaption(run: AnalysisRun): string { - return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label] - .filter(Boolean) - .join(" · "); -} - -function AnalysisRunsPanel({ - accessToken, - onSelectPost, -}: { - accessToken: string; - onSelectPost: (postId: string) => void; -}) { - const [runs, setRuns] = useState(null); - const [selected, setSelected] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - fetchAnalysisRuns(accessToken) - .then((payload) => setRuns(payload.analysis_runs)) - .catch((err) => setError(String(err))); - }, [accessToken]); - - async function handleOpen(runId: string) { - setError(null); - try { - setSelected(await fetchAnalysisRun(accessToken, runId)); - } catch (err) { - setSelected(null); - if (err instanceof BackendError && err.status === 404) { - setError("This analysis run is not visible."); - return; - } - setError(String(err)); - } - } - - if (error && runs === null) return

{error}

; - if (runs === null) return

Loading analysis runs...

; - - return ( -
-
-

Analysis runs

-
- {error &&

{error}

} - {runs.length === 0 ? ( -

- No analysis runs visible to this account yet -- try `make seed`. -

- ) : ( -
    - {runs.map((run) => { - const documentCount = run.source_counts.find( - (count) => count.count_type_code === "analysis_count_document", - ); - const caption = analysisRunCaption(run); - return ( -
  • - -
  • - ); - })} -
- )} - {selected && ( -
-

{analysisRunCaption(selected)}

-

- Cutoff {selected.knowledge_cutoff.slice(0, 10)} - {" · "} - Requested {selected.requested_at.slice(0, 10)} -

-
    - {selected.source_counts.map((count) => ( -
  • - {count.count_value} {count.count_type_label.toLowerCase()} -
  • - ))} -
- {selected.status_history && selected.status_history.length > 0 && ( -
    - {selected.status_history.map((event) => ( -
  1. - {event.status_label} {event.occurred_at.slice(0, 16).replace("T", " ")} - {event.failure_code ? ` · ${event.failure_code}` : ""} -
  2. - ))} -
- )} - {selected.visible_posts && selected.visible_posts.length > 0 && ( -
    - {selected.visible_posts.map((post) => ( -
  • - -
  • - ))} -
- )} -
- )} -
- ); -} - function CalendarPanel({ accessToken, onSelectPost, diff --git a/frontend/src/analysisRunDisplay.ts b/frontend/src/analysisRunDisplay.ts new file mode 100644 index 000000000..6d5714f03 --- /dev/null +++ b/frontend/src/analysisRunDisplay.ts @@ -0,0 +1,24 @@ +import type { AnalysisRun } from "./api"; + +/** + * Buyer-facing caption for one authorized analysis run. + * + * Combines kind, latest status, and scope so the home list tells the + * operator which reconstruction they are about to open. + */ +export function analysisRunCaption(run: AnalysisRun): string { + return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label] + .filter(Boolean) + .join(" · "); +} + +/** + * Shorten a reproducibility digest for the home detail. + * + * Full SHA values stay on the API payload; the UI shows enough prefix + * to compare against an approved revision without dumping a raw hash. + */ +export function shortDigest(value: string | undefined, length = 12): string | null { + if (!value) return null; + return value.slice(0, length); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3dacb054c..ea2f52c04 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -519,6 +519,9 @@ export interface AnalysisRun { knowledge_cutoff: string; requested_at: string; source_counts: AnalysisRunCount[]; + configuration_schema_version?: string; + configuration_sha256?: string; + code_revision_sha?: string; status_history?: AnalysisRunStatusEvent[]; visible_posts?: { post_id: string; post_title: string }[]; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb331302..df72732d8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,6 +10,12 @@ --social-bg: rgba(244, 243, 236, 0.5); --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --lw-space-xs: 0.5rem; + --lw-space-sm: 0.75rem; + --lw-space-md: 1rem; + --lw-radius-sm: 8px; + --lw-radius-md: 12px; + --lw-color-danger: #b91c1c; --sans: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif; diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index b1f0c97b1..5bd7638d6 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.82.0" +__version__ = "0.83.0" diff --git a/pyproject.toml b/pyproject.toml index 9238d87a2..f7b33f6ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.82.0" +version = "0.83.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index f3307cb32..06408c2a9 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.82.0" +version = "0.83.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From c2c8407912ca97bb0e789e55e982ac3ed32d15a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:03:42 +0900 Subject: [PATCH 2/4] fix: keep seeded Demo posts visible at the analysis-run cutoff Default created_at=now() hid every demo post from the January 12 run. Stamp Demo public/private at 2026-01-10, add Late Demo public post as the after-cutoff counter-example, and import AnalysisRun so tsc -b type-checks the extracted panel. --- .../0.83.0-analysis-run-cutoff-posts.md | 3 +- CHANGELOG.md | 8 +-- ...016-analysis-run-knowledge-cutoff-posts.md | 7 +-- frontend/src/AnalysisRunsPanel.test.tsx | 50 ++++++++++++++++++- frontend/src/AnalysisRunsPanel.tsx | 1 + scripts/seed_demo_data.py | 45 ++++++++++++++--- tests/test_analysis_run_authorization.py | 10 ++++ 7 files changed, 109 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md index e1d4ccab6..e470a5dfa 100644 --- a/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md +++ b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md @@ -2,4 +2,5 @@ Detail lists only posts known at the run knowledge cutoff and shows revision/config digest prefixes so an operator can confirm the run -they approved. Later own-corp posts stay hidden. +they approved. After `make seed`, Demo public post still opens; Late +Demo public post stays hidden. diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bd43c75..42ac97268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,10 @@ All notable changes to this project are documented here. Format follows ### Fixed - Analysis-run detail now lists only posts whose `created_at` is at or - before the run `knowledge_cutoff`. After `make seed`, a later own-corp - post does not appear inside the January 12 reconstruction. Open a later - run, or ask an administrator to capture a newer snapshot, when the list - is empty. + before the run `knowledge_cutoff`. After `make seed`, Demo public post + (2026-01-10) still opens from the January 12 reconstruction; Late Demo + public post (2026-01-13) does not. Open a later run, or ask an + administrator to capture a newer snapshot, when the list is empty. ### Added diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index b918c3450..ce4f3634d 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -41,9 +41,10 @@ growing `App.tsx`. ## Consequences Fixture posts that belong in a January 2026 run must carry a -`created_at` at or before that cutoff. A later own-corp post is the -falsifiable counter-example. Write/rebuild APIs, TEPP submission, and -run-scoped post bodies remain later slices. +`created_at` at or before that cutoff. `make seed` stamps Demo public +and Demo private posts at 2026-01-10 and inserts Late Demo public post +at 2026-01-13 as the falsifiable own-corp counter-example. Write/rebuild +APIs, TEPP submission, and run-scoped post bodies remain later slices. ## References diff --git a/frontend/src/AnalysisRunsPanel.test.tsx b/frontend/src/AnalysisRunsPanel.test.tsx index 14d00e629..f96b4a42e 100644 --- a/frontend/src/AnalysisRunsPanel.test.tsx +++ b/frontend/src/AnalysisRunsPanel.test.tsx @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AnalysisRunsPanel } from "./AnalysisRunsPanel"; import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; import type { AnalysisRun } from "./api"; @@ -33,3 +36,48 @@ describe("shortDigest", () => { expect(shortDigest(undefined)).toBeNull(); }); }); + +describe("AnalysisRunsPanel", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("tells the operator to open a later run when no posts existed at cutoff", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { + return Promise.resolve( + new Response( + JSON.stringify({ + ...sampleRun, + visible_posts: [], + code_revision_sha: "c".repeat(40), + configuration_sha256: "b".repeat(64), + }), + { status: 200 }, + ), + ); + } + if (url.endsWith("/api/analysis-runs")) { + return Promise.resolve( + new Response(JSON.stringify({ analysis_runs: [sampleRun] }), { status: 200 }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }), + ); + + render( undefined} />); + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ); + expect( + await screen.findByText(/No posts were available at this cutoff/), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Open run post:/ })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/AnalysisRunsPanel.tsx b/frontend/src/AnalysisRunsPanel.tsx index 28341a744..2d68a3ce1 100644 --- a/frontend/src/AnalysisRunsPanel.tsx +++ b/frontend/src/AnalysisRunsPanel.tsx @@ -3,6 +3,7 @@ import { BackendError, fetchAnalysisRun, fetchAnalysisRuns, + type AnalysisRun, } from "./api"; import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index dec4de9ad..70f118050 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -220,19 +220,52 @@ def seed( (account_id, roles[role_code]), ) + # Analysis-run knowledge cutoff is 2026-01-12T12:00:00Z. + # Default created_at=now() would hide every demo post from that run. + demo_post_created_at = "2026-01-10T09:00:00Z" + late_demo_post_created_at = "2026-01-13T09:00:00Z" 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) " + "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 public post', " "'Ada West at Demo Corp followed up with Priya Nair at Northridge Grid about the delayed shipment.', " - "'voc', 'public')", - (account_ids["demo.analyst"], corporate_entity_id, process_units["DEMO-PU-A"]), + "'voc', 'public', %s)", + ( + account_ids["demo.analyst"], + corporate_entity_id, + process_units["DEMO-PU-A"], + demo_post_created_at, + ), ) cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code) " - "values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private')", - (account_ids["demo.admin"], corporate_entity_id, process_units["DEMO-PU-HQ"]), + "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', %s)", + ( + account_ids["demo.admin"], + corporate_entity_id, + process_units["DEMO-PU-HQ"], + demo_post_created_at, + ), + ) + cur.execute( + "update source_post set created_at = %s " + "where post_title in ('Demo public post', 'Demo private post')", + (demo_post_created_at,), + ) + cur.execute("select post_id from source_post where post_title = 'Late 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) " + "values (%s, %s, %s, 'Late Demo public post', " + "'Written after the Demo Corp lineage-run knowledge cutoff.', " + "'voc', 'public', %s)", + ( + account_ids["demo.analyst"], + corporate_entity_id, + process_units["DEMO-PU-A"], + late_demo_post_created_at, + ), ) cur.execute("select post_id from source_post where post_title = 'Demo public post'") diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py index 730825c14..86b94fe56 100644 --- a/tests/test_analysis_run_authorization.py +++ b/tests/test_analysis_run_authorization.py @@ -14,6 +14,16 @@ _ROOT = Path(__file__).resolve().parents[1] _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" +_SEED_SCRIPT = _ROOT / "scripts" / "seed_demo_data.py" + + +def test_seed_stamps_demo_posts_relative_to_run_cutoff() -> None: + """make seed must not hide Demo public post behind default created_at=now().""" + seed = _SEED_SCRIPT.read_text(encoding="utf-8") + assert "2026-01-12T12:00:00Z" in seed + assert "demo_post_created_at = \"2026-01-10T09:00:00Z\"" in seed + assert "late_demo_post_created_at = \"2026-01-13T09:00:00Z\"" in seed + assert "Late Demo public post" in seed _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" ) From 09038837de81383fe040bc7dc77b066cadaac733 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:16:57 +0000 Subject: [PATCH 3/4] fix: prove cutoff on live SQL and skip frozen re-seed counts The always-on seed test only grepped strings. Add an authz_db case that inserts before/equal/after cutoff posts on every scope, restore the thread-group visibility predicate, and keep GET /api/posts listing the late own-corp fixture. Skip analysis_source_count inserts when the Demo snapshot already has rows so a second make seed does not hit the freeze trigger. Co-authored-by: Seongho Bae --- backend/tests/test_api.py | 18 +- scripts/seed_demo_data.py | 33 ++- tests/test_analysis_run_authorization.py | 315 ++++++++++++++++++++++- 3 files changed, 338 insertions(+), 28 deletions(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1ec47b88f..541e174a6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -95,10 +95,11 @@ def demo_analyst_token() -> str: @pytest.fixture def seeded_db(demo_analyst_token): """A throwaway, freshly migrated database seeded with a user_account - keyed to the real Keycloak demo.analyst subject, plus three source_post - rows covering the three visibility outcomes the API must distinguish: - public (visible to anyone), same-corp private (visible), and - other-corp private (must NOT be visible). + keyed to the real Keycloak demo.analyst subject, plus four source_post + rows covering the visibility and cutoff outcomes the API must + distinguish: public (visible to anyone), same-corp private (visible), + other-corp private (must NOT be visible), and a later own-corp private + post that GET /api/posts still lists but analysis-run detail hides. """ subject = jwt.decode(demo_analyst_token, options={"verify_signature": False})["sub"] @@ -491,6 +492,7 @@ 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 "Public post" not in titles assert "Late own-corp private post" not in titles assert "Other-corp private post" not in titles assert body["code_revision_sha"] == "c" * 40 @@ -504,6 +506,12 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( ) assert hidden.status_code == 404 + hidden_all_visible = client.get( + f"/api/analysis-runs/{seeded_db['hidden_all_visible_id']}", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert hidden_all_visible.status_code == 404 + unauthenticated = client.get("/api/analysis-runs") assert unauthenticated.status_code == 401 @@ -520,7 +528,7 @@ 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"} + assert titles == {"Public post", "Own-corp private post", "Late 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/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 70f118050..98c1c00ec 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -1234,8 +1234,10 @@ def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, proces def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: """Insert one Demo-Corp lineage run so Analysis runs is not empty. - Aggregates only: three synthetic documents, one thread. The digest is - a hash of a fixed demo contract string -- never a source row or DSN. + Aggregates only: three synthetic snapshot documents, one thread. The + digest is a hash of a fixed demo contract string -- never a source + row or DSN. Counts stay snapshot inventory (ADR 0013); they are not + a live count of cutoff-filtered Demo posts. """ import hashlib @@ -1262,18 +1264,23 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - else: snapshot_id = snapshot_row[0] cur.execute( - """ - insert into analysis_source_count - (analysis_source_snapshot_id, count_type_code, count_value) - values - (%s, 'analysis_count_document', 3), - (%s, 'analysis_count_thread', 1), - (%s, 'analysis_count_lineage_node', 5), - (%s, 'analysis_count_lineage_edge', 4) - on conflict do nothing - """, - (snapshot_id, snapshot_id, snapshot_id, snapshot_id), + "select 1 from analysis_source_count " + "where analysis_source_snapshot_id = %s limit 1", + (snapshot_id,), ) + if cur.fetchone() is None: + cur.execute( + """ + insert into analysis_source_count + (analysis_source_snapshot_id, count_type_code, count_value) + values + (%s, 'analysis_count_document', 3), + (%s, 'analysis_count_thread', 1), + (%s, 'analysis_count_lineage_node', 5), + (%s, 'analysis_count_lineage_edge', 4) + """, + (snapshot_id, snapshot_id, snapshot_id, snapshot_id), + ) cur.execute( """ select analysis_run_id from analysis_run diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py index 86b94fe56..6b9901698 100644 --- a/tests/test_analysis_run_authorization.py +++ b/tests/test_analysis_run_authorization.py @@ -15,15 +15,6 @@ _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" _SEED_SCRIPT = _ROOT / "scripts" / "seed_demo_data.py" - - -def test_seed_stamps_demo_posts_relative_to_run_cutoff() -> None: - """make seed must not hide Demo public post behind default created_at=now().""" - seed = _SEED_SCRIPT.read_text(encoding="utf-8") - assert "2026-01-12T12:00:00Z" in seed - assert "demo_post_created_at = \"2026-01-10T09:00:00Z\"" in seed - assert "late_demo_post_created_at = \"2026-01-13T09:00:00Z\"" in seed - assert "Late Demo public post" in seed _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" ) @@ -117,6 +108,8 @@ def _complete_run( idempotency_key: str, scope_kind: str, corporate_entity_id: str | None = None, + process_unit_id: str | None = None, + scope_key: str | None = None, ) -> str: """Insert one succeeded run with one document-count aggregate.""" cursor.execute( @@ -163,6 +156,24 @@ def _complete_run( """, (run_id, scope_kind, corporate_entity_id), ) + elif scope_kind == "analysis_scope_process_unit": + cursor.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, process_unit_id) + values (%s, %s, %s) + """, + (run_id, scope_kind, process_unit_id), + ) + elif scope_kind == "analysis_scope_thread_group": + cursor.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, scope_key) + values (%s, %s, %s) + """, + (run_id, scope_kind, scope_key), + ) else: cursor.execute( """ @@ -209,12 +220,129 @@ def _visible_ids(cursor, account_id: str, entity_ids: list[str]) -> set[str]: and aff.process_unit_id = scope.process_unit_id ) ) + or ( + scope.scope_kind_code = 'analysis_scope_thread_group' + and exists ( + select 1 from source_post p + where p.thread_group_key = scope.scope_key + and ( + p.visibility_code = 'public' + or p.corporate_entity_id = any(%s::uuid[]) + ) + ) + ) """, - (account_id, entity_ids, account_id), + (account_id, entity_ids, account_id, entity_ids), ) return {str(row[0]) for row in cursor.fetchall()} +def _ensure_post_lookups(cursor) -> None: + """Insert the lookup codes source_post requires.""" + cursor.execute( + """ + insert into common_lookup_value (lookup_category, lookup_code, lookup_label) + values + ('voc_type', 'voc', 'Voice of Customer'), + ('post_visibility', 'public', 'Public'), + ('post_visibility', 'private', 'Private') + on conflict (lookup_code) do nothing + """ + ) + + +def _insert_post( + cursor, + *, + account_id: str, + corporate_entity_id: str, + title: str, + visibility_code: str, + created_at: str, + process_unit_id: str | None = None, + thread_group_key: str = "", +) -> str: + """Insert one synthetic post used to falsify the cutoff predicate.""" + cursor.execute( + """ + 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, created_at + ) + values (%s, %s, %s, %s, 'synthetic cutoff body', 'voc', %s, %s, %s) + returning post_id + """, + ( + account_id, + corporate_entity_id, + process_unit_id, + title, + visibility_code, + thread_group_key, + created_at, + ), + ) + return str(cursor.fetchone()[0]) + + +def _cutoff_visible_titles( + cursor, + *, + scope_kind_code: str, + corporate_entity_id: str | None, + process_unit_id: str | None, + scope_key: str | None, + affiliated_entity_ids: list[str], + knowledge_cutoff: str, +) -> set[str]: + """Execute the same cutoff + ABAC filter fetch_visible_scope_posts uses.""" + if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id: + cursor.execute( + """ + select post_title, visibility_code, corporate_entity_id + from source_post + where corporate_entity_id = %s and created_at <= %s + """, + (corporate_entity_id, knowledge_cutoff), + ) + elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id: + cursor.execute( + """ + select post_title, visibility_code, corporate_entity_id + from source_post + where process_unit_id = %s and created_at <= %s + """, + (process_unit_id, knowledge_cutoff), + ) + elif scope_kind_code == "analysis_scope_thread_group" and scope_key: + cursor.execute( + """ + select post_title, visibility_code, corporate_entity_id + from source_post + where thread_group_key = %s and created_at <= %s + """, + (scope_key, knowledge_cutoff), + ) + elif scope_kind_code == "analysis_scope_all_visible": + cursor.execute( + """ + select post_title, visibility_code, corporate_entity_id + from source_post + where created_at <= %s + """, + (knowledge_cutoff,), + ) + else: + return set() + affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} + titles: set[str] = set() + for title, visibility_code, entity_id in cursor.fetchall(): + if visibility_code == "public" or str(entity_id) in affiliated: + titles.add(title) + return titles + + def test_hidden_scope_does_not_leak_through_all_visible_or_other_corp(authz_db) -> None: """A Demo-Corp viewer never sees another tenant's run or its aggregates.""" with authz_db.cursor() as cursor: @@ -262,3 +390,170 @@ def test_hidden_scope_does_not_leak_through_all_visible_or_other_corp(authz_db) assert hidden_all_visible in outsider_visible assert hidden_other_corp in outsider_visible assert own_run not in outsider_visible + + +def test_seed_stamps_demo_posts_relative_to_run_cutoff() -> None: + """make seed must not hide Demo public post behind default created_at=now().""" + seed = _SEED_SCRIPT.read_text(encoding="utf-8") + assert "2026-01-12T12:00:00Z" in seed + assert "demo_post_created_at = \"2026-01-10T09:00:00Z\"" in seed + assert "late_demo_post_created_at = \"2026-01-13T09:00:00Z\"" in seed + assert "Late Demo public post" in seed + assert "select 1 from analysis_source_count" in seed + assert "if cur.fetchone() is None:" in seed + + +def test_cutoff_hides_later_posts_on_every_scope(authz_db) -> None: + """A post after knowledge_cutoff is absent; equal-to-cutoff remains.""" + cutoff = "2026-01-12T12:00:00Z" + with authz_db.cursor() as cursor: + _ensure_post_lookups(cursor) + author = _insert_account(cursor, "author") + own_corp = _insert_corp(cursor, "DEMO-CORP-CUTOFF", "Demo Corp") + other_corp = _insert_corp(cursor, "OTHER-CORP-CUTOFF", "Other Corp") + cursor.execute( + """ + insert into process_unit + (corporate_entity_id, process_unit_code, process_unit_name) + values (%s, 'DEMO-PU-CUTOFF', 'Demo cutoff unit') + returning process_unit_id + """, + (own_corp,), + ) + process_unit_id = str(cursor.fetchone()[0]) + _insert_post( + cursor, + account_id=author, + corporate_entity_id=own_corp, + title="In-cutoff own-corp", + visibility_code="private", + created_at="2026-01-10T09:00:00Z", + process_unit_id=process_unit_id, + thread_group_key="cutoff-thread", + ) + _insert_post( + cursor, + account_id=author, + corporate_entity_id=own_corp, + title="Equal-to-cutoff own-corp", + visibility_code="private", + created_at=cutoff, + process_unit_id=process_unit_id, + thread_group_key="cutoff-thread", + ) + _insert_post( + cursor, + account_id=author, + corporate_entity_id=own_corp, + title="Late own-corp", + visibility_code="private", + created_at="2026-01-12T12:00:01Z", + process_unit_id=process_unit_id, + thread_group_key="cutoff-thread", + ) + _insert_post( + cursor, + account_id=author, + corporate_entity_id=other_corp, + title="Other-corp private", + visibility_code="private", + created_at="2026-01-10T09:00:00Z", + thread_group_key="cutoff-thread", + ) + _insert_post( + cursor, + account_id=author, + corporate_entity_id=other_corp, + title="Other-corp public", + visibility_code="public", + created_at="2026-01-10T09:00:00Z", + thread_group_key="other-thread", + ) + + expected_own = {"In-cutoff own-corp", "Equal-to-cutoff own-corp"} + for scope_kind, corp_id, unit_id, scope_key in ( + ("analysis_scope_corporate_entity", own_corp, None, None), + ("analysis_scope_process_unit", None, process_unit_id, None), + ("analysis_scope_thread_group", None, None, "cutoff-thread"), + ): + titles = _cutoff_visible_titles( + cursor, + scope_kind_code=scope_kind, + corporate_entity_id=corp_id, + process_unit_id=unit_id, + scope_key=scope_key, + affiliated_entity_ids=[own_corp], + knowledge_cutoff=cutoff, + ) + assert titles == expected_own, scope_kind + assert "Late own-corp" not in titles + assert "Other-corp private" not in titles + + all_visible = _cutoff_visible_titles( + cursor, + scope_kind_code="analysis_scope_all_visible", + corporate_entity_id=None, + process_unit_id=None, + scope_key=None, + affiliated_entity_ids=[own_corp], + knowledge_cutoff=cutoff, + ) + assert all_visible == expected_own | {"Other-corp public"} + assert "Late own-corp" not in all_visible + assert "Other-corp private" not in all_visible + + +def test_thread_group_run_is_visible_only_when_a_post_is_already_visible( + authz_db, +) -> None: + """A thread-group run follows the same ABAC gate as a post in that group.""" + with authz_db.cursor() as cursor: + _ensure_post_lookups(cursor) + viewer = _insert_account(cursor, "thread-viewer") + outsider = _insert_account(cursor, "thread-outsider") + own_corp = _insert_corp(cursor, "DEMO-CORP-THREAD", "Demo Corp") + other_corp = _insert_corp(cursor, "OTHER-CORP-THREAD", "Other Corp") + cursor.execute( + """ + insert into account_affiliation (user_account_id, corporate_entity_id) + values (%s, %s) + """, + (viewer, own_corp), + ) + _insert_post( + cursor, + account_id=outsider, + corporate_entity_id=other_corp, + title="Public thread post", + visibility_code="public", + created_at="2026-01-10T09:00:00Z", + thread_group_key="shared-thread", + ) + _insert_post( + cursor, + account_id=outsider, + corporate_entity_id=other_corp, + title="Private other-corp thread post", + visibility_code="private", + created_at="2026-01-10T09:00:00Z", + thread_group_key="hidden-thread", + ) + shared_run = _complete_run( + cursor, + account_id=outsider, + digest="1" * 64, + idempotency_key="shared-thread-run", + scope_kind="analysis_scope_thread_group", + scope_key="shared-thread", + ) + hidden_run = _complete_run( + cursor, + account_id=outsider, + digest="2" * 64, + idempotency_key="hidden-thread-run", + scope_kind="analysis_scope_thread_group", + scope_key="hidden-thread", + ) + visible = _visible_ids(cursor, viewer, [own_corp]) + assert shared_run in visible + assert hidden_run not in visible From 8fb81d09062bf399c7df746c4ad7005de46adf01 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:17:00 +0000 Subject: [PATCH 4/4] fix: label snapshot counts as capture inventory ADR 0013 stores document counts on the snapshot, not on the run. The home badge now says "in the snapshot" so three frozen documents are not read as the two in-cutoff Demo posts plus the late counter-example. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 4 +- .../0.83.0-analysis-run-cutoff-posts.md | 3 +- CHANGELOG.md | 5 ++ ...016-analysis-run-knowledge-cutoff-posts.md | 10 +-- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 3 +- docs/storybook/INVENTORY.md | 4 +- .../plans/2026-08-15-analysis-run-registry.md | 2 + frontend/src/AnalysisRunsPanel.test.tsx | 63 ++++++++++++++++++- frontend/src/AnalysisRunsPanel.tsx | 14 ++--- frontend/src/App.test.tsx | 2 +- frontend/src/analysisRunDisplay.ts | 13 +++- 11 files changed, 101 insertions(+), 22 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 11cd837e8..999844fd0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -475,7 +475,9 @@ its machine `failure_code` rather than an invented caption. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · -Demo Corp" with "3 documents" and Pending / Running / Succeeded times. +Demo Corp" with "3 documents in the snapshot" and Pending / Running / +Succeeded times. Snapshot counts stay capture inventory; the post list +applies the run knowledge cutoff (ADR 0016). ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md index e470a5dfa..62d4004ec 100644 --- a/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md +++ b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md @@ -3,4 +3,5 @@ Detail lists only posts known at the run knowledge cutoff and shows revision/config digest prefixes so an operator can confirm the run they approved. After `make seed`, Demo public post still opens; Late -Demo public post stays hidden. +Demo public post stays hidden. Snapshot counts are labeled as capture +inventory. A second `make seed` skips frozen count inserts. diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ac97268..8d1a6777a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ All notable changes to this project are documented here. Format follows (2026-01-10) still opens from the January 12 reconstruction; Late Demo public post (2026-01-13) does not. Open a later run, or ask an administrator to capture a newer snapshot, when the list is empty. +- Snapshot document counts are labeled "in the snapshot" so a frozen + capture total is not read as the cutoff-filtered post list. +- `make seed` skips frozen `analysis_source_count` inserts when the + Demo snapshot already has rows, so a second seed after the Demo run + exists no longer raises `analysis_source_count_frozen_after_run`. ### Added diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index ce4f3634d..01e4ff31f 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -29,10 +29,12 @@ projects `source_post` titles. 3. `source_post.created_at <= analysis_run.knowledge_cutoff`. Hidden or later posts never appear. The list payload stays -aggregates-only. Detail also returns `code_revision_sha` and -`configuration_sha256` so an operator can confirm the run matches -the code and configuration they approved. Prefixes are shown in the -home panel; full digests remain on the API. +aggregates-only. `source_counts` remain snapshot inventory +(ADR 0013); the home panel labels them "in the snapshot" so they +are not read as the cutoff-filtered title list. Detail also returns +`code_revision_sha` and `configuration_sha256` so an operator can +confirm the run matches the code and configuration they approved. +Prefixes are shown in the home panel; full digests remain on the API. The home Analysis runs panel lives in `AnalysisRunsPanel` so the repeating list/detail object can be inventoried for Storybook without diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index 4dfeb8813..b5e79c8a3 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -69,7 +69,8 @@ provenance, retention, and immutable evidence rather than blanket masking. |---|---| | One snapshot supports multiple analyses | Insert two runs over one snapshot with different valid cutoffs. | | Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. | -| Later posts are excluded from a historical run | A post with `created_at` after `knowledge_cutoff` is absent from `visible_posts`. | +| Later posts are excluded from a historical run | A post with `created_at` after `knowledge_cutoff` is absent from `visible_posts`. Equal-to-cutoff remains. | +| Snapshot counts are not the cutoff list | Home copy says "in the snapshot"; a late own-corp post is listed by `GET /api/posts` and absent from run detail. | | Evidence cannot change after derivation | Reject snapshot/count updates and count insert/delete after the first run. | | Count/run race is serialized | Both paths acquire the snapshot row first; a later concurrency test must prove one legal winner and no lost freeze. | | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | diff --git a/docs/storybook/INVENTORY.md b/docs/storybook/INVENTORY.md index 98225be1b..1252bb71a 100644 --- a/docs/storybook/INVENTORY.md +++ b/docs/storybook/INVENTORY.md @@ -9,8 +9,8 @@ pins Node 24, Corepack pnpm only). | Object | Module | States a Storybook story must cover | |---|---|---| -| Analysis run list | `frontend/src/AnalysisRunsPanel.tsx` | empty (`make seed` hint), loading, one succeeded Demo Corp run, hidden-run error | -| Analysis run detail | same | cutoff + requested date, status history, digest prefixes, in-cutoff posts, no posts at cutoff | +| Analysis run list | `frontend/src/AnalysisRunsPanel.tsx` | empty (`make seed` hint), loading, one succeeded Demo Corp run with snapshot-count badge, hidden-run error | +| Analysis run detail | same | cutoff + requested date, status history, digest prefixes, snapshot-count labels, in-cutoff posts, no posts at cutoff | | Post list chip | `frontend/src/App.tsx` | public / private badges | | Calendar commitment | `frontend/src/App.tsx` | dated open ticket | | Period report row | `frontend/src/App.tsx` | mean θ, CAT item, member click-through | diff --git a/docs/superpowers/plans/2026-08-15-analysis-run-registry.md b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md index 4cddaec1c..e79b2a341 100644 --- a/docs/superpowers/plans/2026-08-15-analysis-run-registry.md +++ b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md @@ -74,6 +74,8 @@ for Milestone 2 analysis requests and lifecycle evidence. 2. Apply `created_at <= knowledge_cutoff` in the authorized read. 3. Show revision/config digest prefixes on the home detail. 4. Extract `AnalysisRunsPanel` and inventory Storybook states. +5. Label snapshot counts as capture inventory; skip frozen re-seed + count inserts; keep `GET /api/posts` listing later own-corp posts. ## Task 6 — Next bounded vertical slice diff --git a/frontend/src/AnalysisRunsPanel.test.tsx b/frontend/src/AnalysisRunsPanel.test.tsx index f96b4a42e..002e7896a 100644 --- a/frontend/src/AnalysisRunsPanel.test.tsx +++ b/frontend/src/AnalysisRunsPanel.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AnalysisRunsPanel } from "./AnalysisRunsPanel"; -import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; +import { analysisRunCaption, shortDigest, snapshotCountCaption } from "./analysisRunDisplay"; import type { AnalysisRun } from "./api"; const sampleRun: AnalysisRun = { @@ -16,7 +16,13 @@ const sampleRun: AnalysisRun = { status_label: "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", - source_counts: [], + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], }; describe("analysisRunCaption", () => { @@ -37,6 +43,18 @@ describe("shortDigest", () => { }); }); +describe("snapshotCountCaption", () => { + it("names the capture so the badge is not read as the cutoff post list", () => { + expect( + snapshotCountCaption({ + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }), + ).toBe("3 documents in the snapshot"); + }); +}); + describe("AnalysisRunsPanel", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -79,5 +97,46 @@ describe("AnalysisRunsPanel", () => { await screen.findByText(/No posts were available at this cutoff/), ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Open run post:/ })).not.toBeInTheDocument(); + expect(screen.getAllByText("3 documents in the snapshot").length).toBeGreaterThan(0); + }); + + it("opens only in-cutoff titles and never a later own-corp post", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { + return Promise.resolve( + new Response( + JSON.stringify({ + ...sampleRun, + visible_posts: [{ post_id: "post-demo-public", post_title: "Demo public post" }], + code_revision_sha: "c".repeat(40), + configuration_sha256: "b".repeat(64), + }), + { status: 200 }, + ), + ); + } + if (url.endsWith("/api/analysis-runs")) { + return Promise.resolve( + new Response(JSON.stringify({ analysis_runs: [sampleRun] }), { status: 200 }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }), + ); + + render( undefined} />); + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ); + expect( + await screen.findByRole("button", { name: "Open run post: Demo public post" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Late Demo public post/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/No posts were available at this cutoff/)).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/AnalysisRunsPanel.tsx b/frontend/src/AnalysisRunsPanel.tsx index 2d68a3ce1..312a0bf2d 100644 --- a/frontend/src/AnalysisRunsPanel.tsx +++ b/frontend/src/AnalysisRunsPanel.tsx @@ -5,7 +5,7 @@ import { fetchAnalysisRuns, type AnalysisRun, } from "./api"; -import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; +import { analysisRunCaption, shortDigest, snapshotCountCaption } from "./analysisRunDisplay"; /** * Home-page Analysis runs list and authorized detail. @@ -73,9 +73,7 @@ export function AnalysisRunsPanel({ > {caption} {documentCount && ( - - {documentCount.count_value} {documentCount.count_type_label.toLowerCase()} - + {snapshotCountCaption(documentCount)} )} @@ -100,9 +98,7 @@ export function AnalysisRunsPanel({ )}
    {selected.source_counts.map((count) => ( -
  • - {count.count_value} {count.count_type_label.toLowerCase()} -
  • +
  • {snapshotCountCaption(count)}
  • ))}
{selected.status_history && selected.status_history.length > 0 && ( @@ -129,12 +125,12 @@ export function AnalysisRunsPanel({ ))} - ) : ( + ) : selected.visible_posts ? (

No posts were available at this cutoff. Open a later run, or ask an administrator to capture a newer snapshot.

- )} + ) : null} )} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 368ac70a4..b83bec6ee 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1377,7 +1377,7 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Analysis runs" })).toBeInTheDocument(); const list = screen.getByRole("list", { name: "Analysis runs" }); expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp"); - expect(list).toHaveTextContent("3 documents"); + expect(list).toHaveTextContent("3 documents in the snapshot"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); diff --git a/frontend/src/analysisRunDisplay.ts b/frontend/src/analysisRunDisplay.ts index 6d5714f03..b551de3c9 100644 --- a/frontend/src/analysisRunDisplay.ts +++ b/frontend/src/analysisRunDisplay.ts @@ -1,4 +1,4 @@ -import type { AnalysisRun } from "./api"; +import type { AnalysisRun, AnalysisRunCount } from "./api"; /** * Buyer-facing caption for one authorized analysis run. @@ -22,3 +22,14 @@ export function shortDigest(value: string | undefined, length = 12): string | nu if (!value) return null; return value.slice(0, length); } + +/** + * Label a frozen snapshot aggregate so it is not read as the cutoff list. + * + * ADR 0013 stores counts on the capture, not on the run. The home panel + * must say they describe the snapshot, or "3 documents" looks like the + * two in-cutoff Demo posts plus the late counter-example. + */ +export function snapshotCountCaption(count: AnalysisRunCount): string { + return `${count.count_value} ${count.count_type_label.toLowerCase()} in the snapshot`; +}