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
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ A run-bearing analysis-run registry empties only after an unrevoked
(ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not
expose purge on a public HTTP route.

`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
transport or an unused accepted envelope is Failed
(`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a
theta or a local psychometric substitute. The home list caption stays
`kind · status · entity`; the machine failure code is detail-only
(ADR 0014). Open a Failed TEPP row, then connect a live TEPP
transport. A failed lineage row retries reconstruction -- it does not
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).
`POST /api/analysis-runs` records Pending on an authorized
cutoff capture (ADR 0017) and does not reconstruct lineage.
A thread-group run lists only when an ABAC-visible post exists at or
before `knowledge_cutoff`, even when the signed-in account requested
the run (ADR 0018). Requesting a January thread that has no in-cutoff
visible post does not put that row on the home list. A 404 on that
hidden row must stay generic: do not name the thread or the cutoff.

## CI gates

`.github/workflows/tests.yml` runs the full suite on every PR to `main`.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.d/0.87.1-requester-thread-cutoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
A thread-group run you requested stays off the home list unless that
thread already has an in-cutoff visible post. Request still records a
corporate-scope lineage run. Same-named R&R people bind the earliest
catalog row.
4 changes: 4 additions & 0 deletions CHANGELOG.d/0.87.2-hidden-run-next-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Opening a listed analysis-run that then 404s tells you to open a
visible run from the home list, or request a lineage reconstruction
for a corporation you already walk. The message does not name the
thread or the cutoff.
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ 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.2] - 2026-08-16

### Fixed

- Opening a listed analysis-run that then 404s tells you to open a
visible run from the home list, or request a lineage reconstruction
for a corporation you already walk. The message does not name the
thread or the cutoff, so a hidden row stays unconfirmed (ADR 0014 /
ADR 0018).

## [0.87.1] - 2026-08-16

### Fixed

- A thread-group analysis-run you requested no longer appears on the
home list when that thread has no ABAC-visible post at or before
`knowledge_cutoff` (ADR 0018). The row appears when that thread
already has an in-cutoff visible post. Request still records a
corporate-scope lineage run (ADR 0017). Detail of the hidden row
is 404.
- An R&R person name that matches two catalog rows now binds the
earliest `created_at`, then `person_id`. A later same-named Keyman
row no longer steals the mention.

## [0.87.0] - 2026-08-16

### Added
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ Opening a cutoff title shows the live post -- compare it with the
cutoff before treating the body as reconstructed evidence (ADR 0016).
`POST /api/analysis-runs` records Pending on an authorized
cutoff capture (ADR 0017) and does not reconstruct lineage.
A thread-group run lists only when an ABAC-visible post exists at or
before `knowledge_cutoff`, even when the signed-in account requested
the run (ADR 0018). Requesting a January thread that has no in-cutoff
visible post does not put that row on the home list. A 404 on that
hidden row must stay generic: do not name the thread or the cutoff.
55 changes: 33 additions & 22 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Authorized, source-redacting reads of the Milestone 2 analysis-run registry.

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.
product projection: an account sees runs they requested or whose scope
they already have ABAC authority to walk, except a thread-group run
also needs an in-cutoff visible post (ADR 0018). Requester ownership
does not bypass that clock. Aggregate counts and lookup labels come
back; source SQL, DSNs, raw records, and provider payloads never do.

``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, run,
scope, and the first Pending event atomically. It does not reconstruct
Expand Down Expand Up @@ -33,23 +34,8 @@
"analysis_run_tepp": "tepp-run-v1",
}

_VISIBLE_RUN_SQL = """
run.requested_by_account_id = $1
or (
scope.scope_kind_code = 'analysis_scope_corporate_entity'
and scope.corporate_entity_id = any($2::uuid[])
)
or (
scope.scope_kind_code = 'analysis_scope_process_unit'
and exists (
select 1 from account_affiliation aff
where aff.user_account_id = $1
and aff.process_unit_id = scope.process_unit_id
)
)
or (
scope.scope_kind_code = 'analysis_scope_thread_group'
and exists (
_THREAD_GROUP_IN_CUTOFF_SQL = """
exists (
select 1 from source_post p
where p.thread_group_key = scope.scope_key
and p.created_at <= run.knowledge_cutoff
Expand All @@ -58,6 +44,31 @@
or p.corporate_entity_id = any($2::uuid[])
)
)
"""

_VISIBLE_RUN_SQL = f"""
(
run.requested_by_account_id = $1
or (
scope.scope_kind_code = 'analysis_scope_corporate_entity'
and scope.corporate_entity_id = any($2::uuid[])
)
or (
scope.scope_kind_code = 'analysis_scope_process_unit'
and exists (
select 1 from account_affiliation aff
where aff.user_account_id = $1
and aff.process_unit_id = scope.process_unit_id
)
)
or (
scope.scope_kind_code = 'analysis_scope_thread_group'
and {_THREAD_GROUP_IN_CUTOFF_SQL}
)
)
and (
scope.scope_kind_code <> 'analysis_scope_thread_group'
or {_THREAD_GROUP_IN_CUTOFF_SQL}
)
"""

Expand Down Expand Up @@ -203,7 +214,7 @@ async def fetch_visible_analysis_runs(
account_id: str,
affiliated_entity_ids: list[str],
) -> list[dict[str, Any]]:
"""Runs the account requested or whose scope they may already walk."""
"""Runs the account may walk, with thread-group cutoff still applied."""
rows = await conn.fetch(
_RUN_SELECT.format(where=_VISIBLE_RUN_SQL),
account_id,
Expand Down
10 changes: 7 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1194,8 +1194,10 @@ async def list_analysis_runs(
) -> dict[str, Any]:
"""Authorized analysis-run list: aggregates and labels only.

Hidden scopes 404 at the item path and never appear here. The
payload has no source SQL, DSN, raw record, or provider body.
Hidden scopes 404 at the item path and never appear here. A
thread-group run you requested still needs an in-cutoff visible
post (ADR 0018). The payload has no source SQL, DSN, raw record,
or provider body.
"""
_require_post_read(account)
async with pool.acquire() as conn:
Expand Down Expand Up @@ -1261,7 +1263,9 @@ async def read_analysis_run(
) -> dict[str, Any]:
"""One authorized analysis-run projection, or 404 when hidden.

Detail adds the labeled status history. Hidden runs never leak events.
Detail adds the labeled status history. Hidden runs never leak
events. A thread-group run you requested still needs an in-cutoff
visible post (ADR 0018).
"""
_require_post_read(account)
try:
Expand Down
5 changes: 4 additions & 1 deletion backend/app/post_summary_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,10 @@ async def _replace_summary_projection(
)
elif role.actor_type_code == ACTOR_TYPE_PERSON:
person_row = await conn.fetchrow(
"select person_id from cataloged_person where person_name = $1 limit 1",
"select person_id from cataloged_person "
"where person_name = $1 "
"order by created_at, person_id "
"limit 1",
role.actor_name,
)
if person_row is not None:
Expand Down
80 changes: 80 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,86 @@ def test_thread_group_run_list_honors_knowledge_cutoff(
assert seeded_db["visible_run_id"] in ids


def test_requester_owned_thread_group_run_list_honors_knowledge_cutoff(
client, demo_analyst_token, seeded_db
) -> None:
"""Requesting a January thread-group run does not list it without an in-cutoff post."""

admin_conn = psycopg2.connect(seeded_db["dsn"])
admin_conn.autocommit = True
try:
with admin_conn.cursor() as cur:
cur.execute(
"insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, thread_group_key, created_at) "
"select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public', %s, %s "
"from source_post where post_id = %s",
(
"Late requester thread-group post",
"Written after the January cutoff.",
"late-requester-thread-group",
"2026-01-20T12:00:00Z",
seeded_db["own_private_post_id"],
),
)
cur.execute(
"""
insert into analysis_source_snapshot
(snapshot_sha256, source_contract_version,
maximum_available_time, captured_at)
values (%s, 'source-contract-v1',
'2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z')
returning analysis_source_snapshot_id
""",
("e" * 64,),
)
snapshot_id = cur.fetchone()[0]
cur.execute(
"""
insert into analysis_run
(analysis_source_snapshot_id, run_kind_code, idempotency_key,
requested_by_account_id, knowledge_cutoff,
configuration_schema_version, configuration_sha256,
code_revision_sha, requested_at)
values (%s, 'analysis_run_lineage', %s,
(select user_account_id from user_account
where email_address = 'test.analyst@example.test'),
'2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s,
'2026-01-12T12:30:00Z')
returning analysis_run_id
""",
(snapshot_id, "hidden-own-late-thread", "b" * 64, "c" * 40),
)
run_id = str(cur.fetchone()[0])
cur.execute(
"""
insert into analysis_run_scope
(analysis_run_id, scope_kind_code, scope_key)
values (%s, 'analysis_scope_thread_group', 'late-requester-thread-group')
""",
(run_id,),
)
cur.execute(
"""
insert into analysis_run_status_event
(analysis_run_id, status_ordinal, status_code, occurred_at)
values (%s, 1, 'analysis_status_succeeded', '2026-01-12T12:33:00Z')
""",
(run_id,),
)
finally:
admin_conn.close()

headers = {"Authorization": f"Bearer {demo_analyst_token}"}
listed = client.get("/api/analysis-runs", headers=headers)
assert listed.status_code == 200
ids = {run["analysis_run_id"] for run in listed.json()["analysis_runs"]}
assert run_id not in ids
assert seeded_db["visible_run_id"] in ids

hidden = client.get(f"/api/analysis-runs/{run_id}", headers=headers)
assert hidden.status_code == 404


def test_first_mention_of_a_new_counterparty_creates_a_real_corporate_entity(
client, demo_analyst_token, seeded_db, monkeypatch
) -> None:
Expand Down
3 changes: 3 additions & 0 deletions docs/adr/0009-cross-post-actor-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ counterparty and Keyman affiliation already resolves against).
**Person** (an R&R actor, not a Keyman): opportunistically joined to an
*existing* `cataloged_person` row by exact name match, when Keyman
extraction has already cataloged that name on this or another post.
When two rows share `person_name`, the join takes the earliest
`created_at`, then `person_id` — it does not invent a person or pick
an arbitrary `LIMIT 1` row.
R&R does not create a new person identity itself -- `cataloged_person`
requires `person_side_code` (our-side vs. counterparty), which R&R's
prompt does not currently ask for and Keyman's does; inventing one here
Expand Down
7 changes: 6 additions & 1 deletion docs/adr/0014-authorized-analysis-run-read.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ started yet. The detail now shows the legal
lifecycle the registry already stored. `POST /api/analysis-runs` now
records a Pending run on an authorized cutoff capture (ADR 0017).
Reconstruction, a live TEPP transport, and a fuller Analysis Run
Console remain later slices.
Console remain later slices. A 404 on a hidden run (including a
thread-group row the caller requested that still lacks an in-cutoff
visible post, ADR 0018) must stay generic: do not name the thread or
the cutoff. Tell the operator to open a visible run from the home
list, or request a lineage reconstruction for a corporation they
already walk.

## References

Expand Down
7 changes: 7 additions & 0 deletions docs/adr/0018-related-nodes-team-org-walk.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ the id by `corporate_entity.entity_name`.

Thread-group run list visibility requires at least one ABAC-visible
`source_post` whose `created_at` is at or before `knowledge_cutoff`.
Requester ownership (`requested_by_account_id`) does not bypass that
clock: a run you requested on a thread that has no in-cutoff visible
post stays off the home list and returns 404 on detail.

## Consequences

Expand All @@ -49,6 +52,10 @@ Thread-group run list visibility requires at least one ABAC-visible
organization chip.
- A later public post in a thread group no longer lists a January run
that could not have known that post.
- Requesting that January run yourself does not put it on your home
list. The row appears when the thread already has an in-cutoff
visible post. Request still records a corporate-scope lineage run
(ADR 0017).

## References

Expand Down
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.87.0",
"version": "0.87.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe("App, authenticated", () => {
failedReportRun?: boolean;
succeededTeppRun?: boolean;
pendingTeppRun?: boolean;
hiddenAnalysisRun?: boolean;
postBody?: string;
}) {
const statusLabel: Record<string, string> = {
Expand Down Expand Up @@ -283,6 +284,14 @@ describe("App, authenticated", () => {
);
}
if (url.endsWith("/api/analysis-runs/run-demo-lineage")) {
if (options?.hiddenAnalysisRun) {
return Promise.resolve(
new Response(JSON.stringify({ detail: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(
jsonResponse({
analysis_run_id: "run-demo-lineage",
Expand Down Expand Up @@ -1740,6 +1749,27 @@ describe("App, authenticated", () => {
expect(teppButton).not.toHaveTextContent("reconstruction");
});

it("tells the operator what to do when a listed run is no longer visible", async () => {
stubBackend({ hiddenAnalysisRun: true });
render(<App />);

await screen.findByRole("list", { name: "Analysis runs" });
await userEvent.click(
screen.getByRole("button", {
name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp",
}),
);

expect(
await screen.findByText(
"This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk.",
),
).toBeInTheDocument();
expect(screen.queryByText(/not visible/i)).not.toBeInTheDocument();
expect(screen.queryByText(/thread-group/i)).not.toBeInTheDocument();
expect(screen.queryByText(/knowledge_cutoff/i)).not.toBeInTheDocument();
});

it("does not tell a failed period report to connect the measurement service", async () => {
stubBackend({ failedReportRun: true });
render(<App />);
Expand Down
Loading
Loading