diff --git a/.github/workflows/prov-o-contract.yml b/.github/workflows/prov-o-contract.yml
new file mode 100644
index 000000000..eae453153
--- /dev/null
+++ b/.github/workflows/prov-o-contract.yml
@@ -0,0 +1,94 @@
+name: PROV-O contract
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - "lineageweave/prov_o.py"
+ - "tests/test_prov_o.py"
+ - "tests/test_prov_o_schema.py"
+ - "migrations/0017_prov_o_standard_relations.sql"
+ - "docs/ontology/prov-o-support-profile.ttl"
+ - "pyproject.toml"
+ - "uv.lock"
+ - ".github/workflows/prov-o-contract.yml"
+ push:
+ branches: [main]
+ paths:
+ - "lineageweave/prov_o.py"
+ - "tests/test_prov_o.py"
+ - "tests/test_prov_o_schema.py"
+ - "migrations/0017_prov_o_standard_relations.sql"
+ - "docs/ontology/prov-o-support-profile.ttl"
+ - "pyproject.toml"
+ - "uv.lock"
+ - ".github/workflows/prov-o-contract.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: prov-o-contract-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ standards-contract:
+ name: Registry, inference, coverage, PostgreSQL
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
+ env:
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ env:
+ LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Set up locked Python dependency manager
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ version: "0.11.28"
+ enable-cache: false
+
+ - name: Install the committed universal lock
+ run: uv sync --frozen --extra dev
+
+ - name: Verify complete relation behavior and 100 percent coverage
+ run: |
+ uv run --frozen python -m coverage run --branch --source=lineageweave.prov_o \
+ -m pytest -q tests/test_prov_o.py
+ uv run --frozen python -m coverage report --fail-under=100 lineageweave/prov_o.py
+
+ - name: Require a reachable PostgreSQL service
+ run: |
+ set -euo pipefail
+ for _ in $(seq 1 30); do
+ if pg_isready -h localhost -p 5432 -U postgres; then
+ exit 0
+ fi
+ sleep 2
+ done
+ echo "PostgreSQL service is unreachable; the schema contract would silently skip." >&2
+ exit 1
+
+ - name: Verify normalized PostgreSQL contracts
+ run: uv run --frozen python -m pytest -q tests/test_prov_o_schema.py
+
+ - name: Compile owned Python surface
+ run: uv run --frozen python -m compileall -q lineageweave/prov_o.py tests/test_prov_o.py tests/test_prov_o_schema.py
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index cb4c7f951..e78d36254 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
- branches: [main]
permissions:
contents: read
@@ -17,6 +16,20 @@ jobs:
pytest:
name: Full test suite
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
+ env:
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ env:
+ LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7
@@ -28,19 +41,22 @@ jobs:
with:
python-version: "3.12"
- - name: Install pinned Rust toolchain
+ - name: Set up locked dependency manager
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ version: "0.11.28"
+ enable-cache: false
+
+ - name: Select pinned Rust toolchain
run: |
- # Same pin as backend/Dockerfile: fast-mlsirm's PyO3/maturin
- # core has no wheel, so pip install -e ".[backend]" compiles it.
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
- sh -s -- -y --profile minimal --default-toolchain 1.97.1
- echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+ rustup toolchain install 1.97.1 --profile minimal
+ rustup default 1.97.1
- - name: Install package and test dependencies
- run: python -m pip install -e ".[dev,backend]"
+ - name: Install the committed universal lock
+ run: uv sync --frozen --extra dev --extra backend
- - name: Run full test suite
- run: python -m pytest -q
+ - name: Run full test suite against PostgreSQL
+ run: uv run --frozen python -m pytest -q
frontend:
name: Frontend lint, test, build
@@ -54,7 +70,6 @@ jobs:
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5
with:
- # Matches frontend/mise.toml's pin (setup-node doesn't parse mise.toml).
node-version: "24"
- name: Enable Corepack
diff --git a/AGENTS.md b/AGENTS.md
index 735988f09..dba1c4b42 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,7 +55,10 @@ summary/chat, or invented commitment. A missing signal and a
confidently-negative signal are different things. Keyman extraction,
entity-relationship classification, post summary, in-popup chat, and
commitment derivation go through contextual-orchestrator the same way
-adjudication does -- never a raw LLM API.
+adjudication does -- never a raw LLM API. Demo TEPP seed goes through
+`tepp_client` the same way: a missing transport or an unused accepted
+envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`),
+never a fabricated theta or a local psychometric substitute.
## Tests
@@ -88,3 +91,13 @@ pnpm run lint && pnpm run test && pnpm run build
Do not weaken, skip, or `continue-on-error` a failing check -- fix the
underlying cause or, for a genuine false positive in a third-party scanner,
add a narrow, documented suppression referencing the specific finding.
+## W3C PROV-O boundary
+
+- Add standard provenance through `lineageweave.prov_o` and the
+ normalized `provenance_*` schema, never by inventing another
+ `edge_type` alias for a W3C property.
+- Qualified relations retain their Influence resource and imply the
+ corresponding unqualified relation.
+- Appendix B inverse names normalize to the preferred W3C direction;
+ do not proliferate private inverse vocabulary.
+- Keep `knowledge_graph_edge` an explicit navigation projection.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 617b8b95d..305361bd6 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -64,7 +64,7 @@ flowchart LR
| `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order |
| `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` |
| `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) |
-| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) |
+| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string. Tag-only, charset, unquoted, or undecodable bodies never fall back to the raw source. GET does not call the vision client. |
| `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport |
| `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread |
| `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) |
@@ -136,6 +136,8 @@ identities and content) and `migrations/0001_initial_schema.sql` for the
`role_permission` / `account_role_assignment`, `abac_policy`, `post` /
`post_counterparty_entity`, `person` / `person_affiliation` /
`post_person_mention`, `knowledge_graph_edge`, `issue_ticket`,
+
+Keyman and R&R person mentions are separate replaceable projections (`post_person_mention` and `post_summary_person_mention`). The read-only `combined_post_person_mention` view feeds lineage discovery. Materialized KG edges are unique and carry normalized `knowledge_graph_edge_evidence`; only evidence from an ABAC-visible post participates in RWR.
`post_lineage_edge`). Real-database tests: `tests/test_schema.py`
(skipped without a reachable PostgreSQL server, same pattern as the
real-provider LLM tests).
@@ -254,11 +256,14 @@ pattern and then hide the action button so it cannot 503 again.
`find_linked_post_ids` first expands to every post
sharing a mentioned person before calling
`backend/app/knowledge_graph.py::load_visible_subgraph` -- that function
-only loads edges among an *already-known* post set (its other caller,
-`related_for_person`, pre-resolves the full set itself), it does not
-discover new posts on its own; a real bug from calling it with only the
-single starting post was caught while building this and is now
-regression-tested (`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`).
+only loads edges among an *already-known* post set (its other callers,
+`related_for_person` / `related_for_entity` / `related_for_team`,
+pre-resolve the full set themselves), it does not discover new posts on
+its own; a real bug from calling it with only the single starting post
+was caught while building this and is now regression-tested
+(`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`).
+Person, team, and organization mention channels load independently
+(ADR 0018): a team-only or organization-only post still walks.
### Frontend (`frontend/`)
@@ -274,8 +279,9 @@ summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel
affiliate tree (resolved ancestors plus unresolved org roots), Keyman +
counterparty panels (a Keyman click loads RWR related nodes;
a related corporate-entity node, a resolved Keyman affiliation,
-or a classified name that resolves to a cataloged org continues
-the same walk via `GET /api/corporate-entities/{id}/related`;
+a classified name that resolves to a cataloged org, or an R&R team
+continues the same walk via `GET /api/corporate-entities/{id}/related`
+or `GET /api/teams/{id}/related`;
`post_admin` can extract),
and an in-popup chat whose cited sources
open a sliding evidence panel (`EvidencePanel`, CSS
@@ -320,7 +326,9 @@ is the same never-guess-a-parent rule
`corporate_hierarchy_resolution` already applies. Entity levels and
Keyman sides are labeled from `common_lookup_value` (`Our side`,
`Plant`, `Company`) so the popup never shows raw `our_side` / `plant`
-codes when a label exists.
+codes when a label exists. Related-node person chips use the same
+side lookup label (for example, `Our side` or `Counterparty`) rather
+than exposing the generic PROV-O `Person` class as business context.
`GET /api/posts` and `GET /api/posts/{post_id}` include
`voc_type_label` / `visibility_label` from `common_lookup_value` so
@@ -450,6 +458,48 @@ lists the same dated tickets the period-report members already show.
Re-seed is idempotent. The empty-state copy is only for accounts that
truly have no dated open tickets.
+## Phase 6-M2: authorized analysis-run evidence (read projection)
+
+Issue #79's first buyer-visible Milestone 2 slice is a source-redacting
+read of the #89 registry. `GET /api/analysis-runs` and
+`GET /api/analysis-runs/{id}` require `post_read` and apply the scope
+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 whose `created_at` is at or before `knowledge_cutoff`
+(ADR 0016) so a buyer can open a post the run was allowed to know
+without seeing later live rows or hidden bodies. Detail also returns
+revision and configuration digest prefixes.
+`POST /api/analysis-runs` records a Pending run on a new authorized
+cutoff capture (ADR 0017): snapshot, counts, run, scope, and the first
+status in one transaction. It does not reconstruct lineage and does not
+invent a TEPP score. Request a lineage reconstruction from the home
+list, then open the Pending row to confirm the cutoff corpus.
+`make seed` also records a TEPP measurement run through
+`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, 12-character digest prefixes
+with full digests on hover, counts, status history)
+without exposing a DSN or raw record. Opening a cutoff title warns
+that the live body may have changed after the run. Status history is detail-only
+and uses lookup labels plus occurrence times; a failure event keeps
+its machine `failure_code` rather than an invented caption. Failed
+TEPP list rows add a next-action line (open the run, then connect the
+measurement service) so `tepp_not_available` is not mistaken for a
+calibrated negative result. A failed lineage row tells the operator
+to retry reconstruction, not to connect TEPP. A failed period-report
+row tells the operator to rebuild the report. A pending TEPP row
+does not claim a calibrated measurement. A pending lineage row
+says reconstruction has not started yet. 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,
+and "TEPP measurement · Failed · Demo Corp" whose detail history ends
+in Failed / `tepp_not_available`.
+
## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only)
First of three staged slices toward the brief's weekly/monthly
@@ -682,3 +732,183 @@ checks a well-known public foundation name ("Mozilla Foundation")
against a deliberately fabricated one in the same request, asserting
the former comes back `verify_corroborated` with a real evidence URL
and the latter `verify_uncorroborated` with none.
+
+## Phase 7: R&R's named actor is a PROV-O Agent, not always a person
+
+`post_summary.py`'s R&R extraction forced every named actor into a
+person slot, but business correspondence routinely names an
+organization acting in its own name ("당사" [our company],
+"Demo Corp"), not an individual. See
+[ADR 0006](docs/adr/0006-role-responsibility-agent-ontology.md).
+
+Grounded in W3C PROV-O (Lebo, Sahoo, & McGuinness, 2013):
+`RoleResponsibility` (renamed field `actor_name`, was `person_name` --
+the field can hold an organization's name now, so "person" in the name
+would be wrong) gains `actor_type_code` (`prov_person` /
+`prov_organization`, defaulting to person when the model omits it) and
+`affiliated_organization_name` (an LLM-inferred affiliation for a
+person actor, since a bare name without an employer is hard to place).
+The ontology gains `:RoleActorPerson rdfs:subClassOf prov:Person` and
+`:RoleActorOrganization rdfs:subClassOf prov:Organization` -- genuine
+subclasses of the real external PROV-O classes (imported via the
+`prov:` namespace), kept distinct from the ontology's existing `:Person`
+(node_type's cataloged Keyman with a stable `person_id`) since an R&R
+actor is a free-text name with no cataloged identity of its own.
+`migrations/0012_role_responsibility_agent_type.sql` renames the
+`post_summary_role` column via `RENAME COLUMN` (preserves existing
+rows) rather than a drop/recreate. The popup's R&R list shows a
+Person/Organization badge and the inferred affiliation; only a person
+actor still links to the Keyman panel.
+
+## Phase 8: same-name Keymen are not silently merged; titles are captured
+
+Two different real people can share a name -- `keyman_extraction.py`
+never captured a stated job title/position, so nothing distinguished
+"Kim Cheolsu, sales manager" from an unrelated "Kim Cheolsu, purchasing
+lead" beyond the bare name. `PersonMention` gains `job_title: str |
+None`, and the extraction prompt now explicitly asks for one when the
+text states it (never left out as a same-name disambiguation signal).
+
+Persistence, in two places for a reason: `person_affiliation.role_title`
+(a schema column that already existed, previously never populated) for
+a title tied to a specific organization, and a new
+`cataloged_person.last_known_job_title` (`migrations/0013_person_job_title.sql`)
+for a title stated without a named organization to attach it to (e.g.
+"our legal counsel, Sam Okonkwo" -- `fixtures.ambiguous_keyman_post()`'s
+own real example, which has zero affiliated organizations for Sam).
+Both feed `_upsert_person`'s disambiguation check
+(`backend/app/keyman_ingestion.py`): a same person_name+person_side_code
+match is only reused when the new mention's stated title, if any, does
+not conflict with a title already on file -- a genuine stated conflict
+creates a fresh `cataloged_person` row instead of merging two different
+people. A missing title on either side is not treated as a conflict
+(titles legitimately change -- a promotion -- and most mentions state no
+title at all), so this only splits on an actual stated disagreement,
+verified by a real test that two posts naming the same name with
+genuinely different stated titles produce two distinct person rows.
+
+## Phase 9: an R&R actor can be a team, meso-level between person and organization
+
+Real post text named "설계팀" (design team) -- neither a person nor the
+company itself, but a sub-unit of one. See
+[ADR 0007](docs/adr/0007-team-actor-type.md). `actor_type_code` gains a
+third value, `prov_team`, grounded in the W3C Organization Ontology's
+`org:OrganizationalUnit` (Reynolds, 2014) -- a different, complementary
+W3C vocabulary from PROV-O (which models "who acted," not "how a
+company is internally structured"). The prompt now offers three actor
+types and requires `affiliated_organization_name` for a team actor too
+(not just a person): a team's own name never answers "which company,"
+unlike an organization actor's. `migrations/0014_role_responsibility_team_actor_type.sql`
+adds the lookup row -- purely additive, no schema change, since
+`actor_type_code` already stores an arbitrary FK'd code.
+
+## Phase 10: an abbreviated organization name is resolved and search-verified, not left opaque
+
+Real post text names organizations by abbreviation ("AGP" for
+"Aurora Grid Power") that character-similarity matching
+(`corporate_hierarchy_resolution`) structurally cannot bridge -- an
+initialism shares almost no substring with its expansion. See
+[ADR 0008](docs/adr/0008-organization-abbreviation-resolution.md).
+
+New module `lineageweave/organization_name_resolution.py`: an LLM
+proposes the full name from context (or declines with `UNKNOWN`), then
+the *existing* `relation_verification` Searxng client cross-verifies
+the specific raw/resolved pairing (no second web-search integration
+built). Only a search-corroborated resolution is ever substituted in
+for `resolve_corporate_entity` -- an unresolved or unverified name
+still flows through unchanged. Cached in a new
+`organization_name_resolution` table
+(`migrations/0015_organization_name_resolution.sql`) keyed by the raw
+name, so the same abbreviation across many posts is resolved once.
+Grounded in SKOS `skos:altLabel`/`skos:prefLabel` (Miles & Bechhofer,
+2009). Wired into `backend/app/keyman_ingestion.py`'s affiliation loop
+and the offline synthetic-batch script's paced re-implementation of it
+(the batch script's own copy was also missing `role_title` persistence
+entirely -- fixed alongside this).
+
+Also fixed while running this against synthetic embedded-image fixtures:
+`image_content.py`'s `_parse_description` required an exact single-pass
+`TEXT:`/`CAPTION:`/`TAGS:` match, which was rejecting real vision
+responses whose formatting was close but not exact (markdown-bolded
+labels, reordered labels, a missing TAGS line) -- silently producing
+the same "content unavailable" placeholder as a genuinely unconfigured
+vision channel. Fields are now recovered independently per label line;
+only a response with neither TEXT nor CAPTION is treated as unusable.
+
+## Phase 11: R&R team/organization actors get a shared cross-post identity
+
+Extraction runs per-post; a team's or organization's identity did not
+survive across posts the way a Keyman's already did via
+`cataloged_person`. See
+[ADR 0009](docs/adr/0009-cross-post-actor-identity.md). New
+`cataloged_team` catalog (`migrations/0016_cross_post_actor_identity.sql`,
+identity key `(team_name, affiliated_organization_name)` -- a bare team
+name like "설계팀" is not by itself identifying) plus two mention join
+tables (`post_team_mention`, `post_organization_mention`); an
+organization actor reuses the existing `corporate_entity` catalog, no
+new table needed. `lineageweave/knowledge_graph.py`'s
+`knowledge_graph_edges_for_post` extended with three new edge kinds
+(`edge_mention_team`, `edge_team_affiliation`, `edge_mention_organization`);
+`backend/app/post_summary_ingestion.py`'s `persist_post_summary` now
+resolves each R&R actor's identity, stores that id on
+`post_summary_role` (ADR 0019 — `entity_name` is not unique), and calls
+the same `persist_edges_for_post` Keyman ingestion already uses. A person R&R
+actor is opportunistically joined to an existing `cataloged_person` row
+by name (never originated by R&R itself -- documented gap in the ADR:
+`cataloged_person` needs `person_side_code`, which R&R's prompt does
+not currently capture).
+
+## Phase 12: a real counterparty organization is auto-created, not left permanently unresolved
+
+`corporate_hierarchy_resolution`'s similarity matching only ever finds
+an ALREADY-cataloged entity. Real Milestone 2 data confirmed the actual
+gap: 0 of 4,154 person affiliations and 0 of 9,852 R&R organization
+mentions ever resolved -- the standing "통합 고객사 계열 tree AI"
+requirement was never actually populated. See
+[ADR 0010](docs/adr/0010-corporate-hierarchy-auto-creation.md).
+
+New `lineageweave/corporate_hierarchy_inference.py` proposes a
+Group/Company/Plant placement from context; new
+`backend/app/corporate_entity_ingestion.py`'s
+`get_or_create_corporate_entity` tries similarity matching first, then
+creates a real new `corporate_entity` row once the proposal is
+Searxng-corroborated (reusing `relation_verification`, no new search
+integration), recursing up a bounded parent chain so the whole
+hierarchy gets real links. Auto-created rows get a deterministic
+`AUTO-`-prefixed code so they can never collide with a real login corp
+code. Wired into both `keyman_ingestion.py`'s affiliation loop and
+`post_summary_ingestion.py`'s R&R organization-actor loop.
+## Standards-complete W3C PROV-O provenance layer
+
+ADR 0011 separates standards-complete provenance from the compact
+buyer-facing navigation graph. `lineageweave/prov_o.py` validates
+and materializes all 50 normative PROV-O properties, including
+literal-valued times/values and qualified Influence resources.
+`migrations/0017_prov_o_standard_relations.sql` stores definitions,
+class/property hierarchies, domains, ranges, qualification maps,
+inverse names, typed resources, literals, assertions, and inference
+premises in third normal form. Existing product nodes cross the
+boundary only through `provenance_resource_binding`; projection to
+`knowledge_graph_edge` is explicit and reversible.
+
+See `docs/PROV_O_IMPLEMENTATION.md`, the complete implementation
+matrix, and `docs/adr/0011-prov-o-standard-relations.md`.
+
+## Phase 13: corporate-entity creation is serialized against a real observed deadlock
+
+Phase 12's creation path made real concurrent writes for the first
+time. A synthetic regression corpus batch run under real concurrency surfaced a
+genuine `DeadlockDetectedError`: two concurrent transactions each
+creating a different new entity, mentioned in opposite order across
+two different posts, took row-level locks in opposite order and
+deadlocked. See [ADR 0012](docs/adr/0012-corporate-entity-creation-lock.md).
+
+`get_or_create_corporate_entity` now takes a single named Postgres
+advisory transaction lock (`pg_advisory_xact_lock`) immediately before
+the write -- never held across the slow LLM inference/Searxng
+verification calls that precede it -- and re-checks candidates fresh
+under the lock before inserting. The lock key is fixed, not per-name,
+so it also covers the multi-entity opposite-order case a per-name lock
+would still deadlock on. Every already-cataloged entity still resolves
+through the unchanged, lock-free similarity-matching fast path; only
+the rare creation branch serializes.
diff --git a/CHANGELOG.d/0.76.0.md b/CHANGELOG.d/0.76.0.md
new file mode 100644
index 000000000..a16208167
--- /dev/null
+++ b/CHANGELOG.d/0.76.0.md
@@ -0,0 +1,3 @@
+# 0.76.0 — W3C PROV-O standard relations
+
+This release fragment is the machine-local source for the root changelog entry added by the one-shot documentation workflow. It records complete support for the Recommendation's 30 classes, 50 normative properties, 14 qualification patterns, Appendix B inverse names, normalized PostgreSQL persistence, exact RDF serialization, and 100% owned-module statement/branch coverage.
diff --git a/CHANGELOG.d/0.77.0-review-hardening.md b/CHANGELOG.d/0.77.0-review-hardening.md
new file mode 100644
index 000000000..873e93450
--- /dev/null
+++ b/CHANGELOG.d/0.77.0-review-hardening.md
@@ -0,0 +1,21 @@
+# 0.77.0 — Review hardening follow-up
+
+- Vision response parsing now treats caption and tag values as single-line
+ fields, strips balanced outer Markdown emphasis, and prevents trailing model
+ commentary from entering searchable tags or captions.
+- The dedicated PROV-O workflow now fails when its PostgreSQL service is
+ unreachable instead of allowing the database contract module to skip.
+- PROV-O database fixtures preserve DSN query options and quote generated
+ database identifiers through `psycopg2.sql.Identifier`.
+- PostgreSQL lexical `xsd:dateTime` validation now rejects offsets outside
+ the XSD range of `Z` / `±hh:mm` with a maximum of `±14:00`, so
+ `+14:01` fails closed instead of being accepted as `timestamptz`.
+- Corporate-entity inference, external verification, and the short advisory-
+ lock creation transaction now finish before the atomic post-summary
+ replacement transaction begins, so network latency never extends summary
+ write locks while post-owned rows still commit or roll back together.
+- Keyman and R&R person mentions now replace separate source-owned projections,
+ while one canonical graph edge retains normalized per-post evidence. This
+ prevents a summary refresh from deleting Keyman facts or leaving removed R&R
+ actors visible in buyer navigation.
+- The implementation matrix follows portable Markdown table spacing.
diff --git a/CHANGELOG.d/0.79.0-analysis-run-authorized-read.md b/CHANGELOG.d/0.79.0-analysis-run-authorized-read.md
new file mode 100644
index 000000000..7233020b2
--- /dev/null
+++ b/CHANGELOG.d/0.79.0-analysis-run-authorized-read.md
@@ -0,0 +1,9 @@
+# 0.79.0 — Authorized analysis-run read projection
+
+## Added
+
+- `GET /api/analysis-runs` and `GET /api/analysis-runs/{id}` expose
+ source-redacting registry evidence to `post_read` accounts.
+- Home-page Analysis runs panel shows the seeded Demo Corp lineage run
+ after `make seed`. Hidden scopes 404. No raw source, DSN, or provider
+ payload is returned.
diff --git a/CHANGELOG.d/0.80.0-analysis-run-detail-click.md b/CHANGELOG.d/0.80.0-analysis-run-detail-click.md
new file mode 100644
index 000000000..090ebb824
--- /dev/null
+++ b/CHANGELOG.d/0.80.0-analysis-run-detail-click.md
@@ -0,0 +1,5 @@
+# 0.80.0 analysis-run detail click
+
+Home Analysis runs rows open `GET /api/analysis-runs/{id}`. The
+detail shows labeled aggregates and dates only. Hidden runs stay
+404 / "not visible". Synthetic Demo Corp seed only.
diff --git a/CHANGELOG.d/0.81.0-analysis-run-status-history.md b/CHANGELOG.d/0.81.0-analysis-run-status-history.md
new file mode 100644
index 000000000..9fa4b8706
--- /dev/null
+++ b/CHANGELOG.d/0.81.0-analysis-run-status-history.md
@@ -0,0 +1,5 @@
+# 0.81.0 analysis-run status history
+
+Detail of `GET /api/analysis-runs/{id}` shows the labeled append-only
+lifecycle. The list stays latest-status only. Hidden runs 404.
+Synthetic Demo Corp seed only.
diff --git a/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md b/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md
new file mode 100644
index 000000000..1802a5644
--- /dev/null
+++ b/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md
@@ -0,0 +1,4 @@
+# 0.82.0 analysis-run post click-through
+
+Detail lists ABAC-visible post titles in the run scope. Hidden
+other-corp private posts never appear. Synthetic titles only.
diff --git a/CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md b/CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md
new file mode 100644
index 000000000..f2de7cfad
--- /dev/null
+++ b/CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md
@@ -0,0 +1,2 @@
+Analysis-run detail applies knowledge_cutoff to visible posts. Migration
+0016 no longer deletes overlapping Keyman mention_context.
diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md
new file mode 100644
index 000000000..df127a9d4
--- /dev/null
+++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md
@@ -0,0 +1,8 @@
+# 0.84.0 TEPP analysis-run seed
+
+Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo
+Corp snapshot. The home list shows Failed and a kind-specific next
+action; detail history keeps `tepp_not_available`. Missing transport
+is not a fake measurement. A failed lineage row does not mention TEPP.
+A failed period-report row rebuilds the report. A pending TEPP row
+does not claim a calibrated measurement.
diff --git a/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md
new file mode 100644
index 000000000..213eb5451
--- /dev/null
+++ b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md
@@ -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.
diff --git a/CHANGELOG.d/0.85.0-analysis-run-create.md b/CHANGELOG.d/0.85.0-analysis-run-create.md
new file mode 100644
index 000000000..505320a19
--- /dev/null
+++ b/CHANGELOG.d/0.85.0-analysis-run-create.md
@@ -0,0 +1,5 @@
+# 0.85.0 authorized analysis-run create
+
+`POST /api/analysis-runs` records Pending on an authorized cutoff
+capture. Request a lineage reconstruction from the home list. This
+write does not invent a measurement.
diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md
new file mode 100644
index 000000000..6e4605d5a
--- /dev/null
+++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md
@@ -0,0 +1,5 @@
+Related-node walks include team and organization mention edges. Click an
+R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff.
+Failed period-report rows rebuild the report; a pending TEPP corpus
+does not claim a calibrated measurement. A pending lineage row says
+reconstruction has not started yet.
diff --git a/CHANGELOG.d/0.86.2-role-catalog-identity.md b/CHANGELOG.d/0.86.2-role-catalog-identity.md
new file mode 100644
index 000000000..a56b3d47e
--- /dev/null
+++ b/CHANGELOG.d/0.86.2-role-catalog-identity.md
@@ -0,0 +1,3 @@
+R&R organization buttons walk the catalog id stored on the role row. A
+shared display name no longer attaches a homonym. Team related matches
+person/entity 403/404.
diff --git a/CHANGELOG.d/0.86.3-embedded-image-fallback.md b/CHANGELOG.d/0.86.3-embedded-image-fallback.md
new file mode 100644
index 000000000..93a9b2224
--- /dev/null
+++ b/CHANGELOG.d/0.86.3-embedded-image-fallback.md
@@ -0,0 +1,2 @@
+Tag-only or charset/unquoted data-URI bodies no longer dump raw source.
+Re-export with the picture embedded. This screen does not OCR the picture.
diff --git a/CHANGELOG.d/milestone2-analysis-run-registry.md b/CHANGELOG.d/milestone2-analysis-run-registry.md
new file mode 100644
index 000000000..5d7e0288f
--- /dev/null
+++ b/CHANGELOG.d/milestone2-analysis-run-registry.md
@@ -0,0 +1,21 @@
+## Added
+
+- Added a normalized PostgreSQL registry for immutable source snapshots,
+ aggregate reconciliation counts, authenticated analysis requests, product
+ scopes, and append-only lifecycle evidence.
+- Added a run-owned knowledge cutoff and snapshot-owned evidence-availability
+ clock so one capture can support multiple historically valid analyses without
+ future-information leakage.
+- Added canonical account-scoped idempotency, immutable request and scope evidence,
+ deletion resistance, serialized count/run locking, scope-required request-time-
+ ordered lifecycle transitions, database-owned record time, and a derived
+ current-status view.
+- Added fail-closed rollback, real-PostgreSQL contract tests, ADR 0013, and APA
+ 7th standards traceability.
+
+## Security
+
+- The registry deliberately excludes source SQL, DSNs, raw records, inline
+ images, provider payloads, credentials, private source identifiers, and raw
+ exceptions. Necessary PII remains in purpose-bound authorized product/source
+ contexts rather than being copied into audit metadata or blanket-masked.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0096828a2..e2b30b214 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,301 @@ 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.86.3] - 2026-08-16
+
+### Fixed
+
+- Opening a post whose body is only a remote `http(s)` image, a
+ `charset=` data-URI, an unquoted `src`, or undecodable base64 no
+ longer dumps the raw source string. Re-export the source with the
+ picture embedded, then open the post again. A valid 1x1 PNG still
+ renders as a picture. This screen does not read text inside the
+ picture; Extract Keyman and Ask stay on their own channels and stay
+ silent when the vision client is Null.
+
+## [0.86.2] - 2026-08-16
+
+### Fixed
+
+- An R&R organization button now walks the catalog id stored on that
+ role row (ADR 0019). Two catalog orgs can share a display name; open
+ the post, click the name, and you stay on the resolved org — not a
+ homonym. `GET /api/teams/{id}/related` matches person/entity authz:
+ another corp's private-only team is 403; an unknown UUID is 404.
+
+## [0.86.1] - 2026-08-16
+
+### Changed
+
+- Opening a post or its evidence panel now shows each embedded
+ `data:image` picture in document order, with the surrounding sentences
+ as text. The raw base64 string is no longer dumped into the popup.
+ Remote `http(s)` image URLs stay unloaded. After `make seed`, a post
+ whose body includes a data-URI image shows the picture.
+
+### Fixed
+
+- Opening a Pending lineage run repeats that reconstruction has not
+ started. Pending next-action copy is pinned to the registered run
+ kinds, so a Pending TEPP row does not say reconstruction.
+
+## [0.86.0] - 2026-08-16
+
+### Added
+
+- Related-node walks now include team and organization mention edges.
+ After `make seed` and a summary that names 설계팀 on two posts, open
+ either post, click the R&R team, and open the sibling post (ADR 0018).
+ A team-only follow-up is no longer an island.
+- `GET /api/teams/{team_id}/related` starts the same RWR walk Keyman
+ and corporate-entity related already use. Related team chips are
+ buttons.
+
+### Fixed
+
+- Thread-group analysis-run *lists* now require an in-cutoff visible
+ post. A later public post in that thread group no longer surfaces a
+ January run the account was not allowed to know.
+- Failed period-report rows tell the operator to rebuild the report.
+ Next-action copy is pinned to the registered run kinds. A pending
+ TEPP corpus does not claim a calibrated measurement.
+
+## [0.85.0] - 2026-08-16
+
+### Added
+
+- `POST /api/analysis-runs` records a Pending lineage or TEPP run on an
+ authorized cutoff capture (ADR 0017). The home panel's **Request a
+ lineage reconstruction** button writes that row so an operator can
+ confirm the cutoff corpus immediately. Reconstruction and live TEPP
+ execution stay later slices — this write never invents a theta.
+- Failed lineage rows tell the operator to retry reconstruction; only
+ Failed TEPP rows mention the measurement service. Pending rows say
+ reconstruction has not started yet.
+
+## [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
+
+- `make seed` records a Demo Corp TEPP measurement run through
+ `tepp_client` on the same snapshot as the lineage run (ADR 0013).
+ The default transport is unavailable, so the home list shows
+ "TEPP measurement · Failed · Demo Corp" and tells the operator to
+ open the run, then connect the measurement service. Detail history
+ keeps `tepp_not_available` -- never a fabricated theta. TEPP stays
+ a wire client, not a local psychometric engine. `make seed` skips
+ snapshot-count inserts once counts exist so a re-run does not hit
+ the freeze trigger. A failed lineage row tells the operator to retry
+ reconstruction; only a failed TEPP row mentions the measurement
+ service. A failed period-report row tells the operator to rebuild
+ the report from a current snapshot. A pending TEPP row does not
+ claim a calibrated measurement. Stacked PRs now run the same
+ GitHub Checks as PRs to main.
+
+## [0.83.0] - 2026-08-16
+
+### Fixed
+
+- Analysis-run detail now lists only ABAC-visible posts whose
+ `created_at` is at or before that run's `knowledge_cutoff`. After
+ `make seed`, open the Demo Corp lineage run: Demo public post is
+ there; a later own-corp follow-up is not. The live post list is
+ unchanged. Click a listed title to inspect what that cutoff
+ reconstructed (ADR 0016).
+- Upgrading through `0016_cross_post_actor_identity.sql` copies R&R
+ person names into `post_summary_person_mention` and leaves Keyman
+ `post_person_mention.mention_context` in place. Re-run Keyman only
+ when you want a new Keyman set -- a later summary no longer erases
+ the stolen row.
+
+## [0.82.0] - 2026-08-16
+
+### Added
+
+- Analysis-run detail lists ABAC-visible posts in the run's scope.
+ After `make seed`, the Demo Corp lineage run opens the Demo public
+ post. Hidden other-corp private posts never appear. List payloads
+ stay aggregates-only.
+
+## [0.81.0] - 2026-08-16
+
+### Added
+
+- Analysis-run detail shows the labeled lifecycle: Pending, Running,
+ then Succeeded, with occurrence times from `analysis_run_status_event`.
+ The list stays latest-status only. Hidden runs still 404 and never
+ leak events. Failure codes stay machine tokens -- no invented label.
+ Synthetic Demo Corp seed only.
+
+## [0.80.0] - 2026-08-16
+
+### Added
+
+- Home Analysis runs rows are buttons. Clicking the seeded Demo Corp
+ lineage run opens `GET /api/analysis-runs/{id}` and shows cutoff,
+ requested date, and document count. A hidden run is "This analysis
+ run is not visible." -- never a raw 404 or a DSN. Still synthetic
+ aggregates only.
+
+## [0.79.0] - 2026-08-16
+
+### Added
+
+- Authorized analysis-run evidence on the product home page. After
+ `make seed`, Demo Analyst sees "Lineage reconstruction · Succeeded ·
+ Demo Corp" with the synthetic document count. `GET /api/analysis-runs`
+ is scoped in SQL: another tenant's run 404s and never appears in the
+ list. The payload is labels and aggregates -- never source SQL, a DSN,
+ or a raw record. TEPP stays behind `tepp_client`; Null channels are
+ unchanged.
+
+## [0.78.0] - 2026-08-15
+
+### Changed
+
+- Related-node person chips now use the localized `person_side` lookup label
+ supplied by the authorized API payload. Users see business context such as
+ `Our side` or `Counterparty`, while ontology class metadata remains available
+ separately for semantic processing and provenance.
+- Related-person buttons now expose that same caption in the accessible name, so
+ assistive technology hears `Related nodes for Priya Nair (Counterparty)`
+ instead of the name alone.
+- Structured extraction, summarization, commitment, relationship-classification,
+ and LLM-as-a-Judge consumers now request contextual-orchestrator `auto` mode
+ so the orchestration plane can meet the quality requirement and then minimize
+ known execution cost. Explicit checked `verify` paths remain unchanged
+ (ADR 0015).
+
+## [0.77.0] - 2026-08-14
+
+### Fixed
+
+- Keyman and R&R person mentions now replace independent source projections. Knowledge Graph edges have one canonical identity plus post-level evidence, so removed actors and concurrent writes cannot leave stale or duplicate buyer-visible relationships.
+- Vision-response parsing now strips balanced outer Markdown emphasis from field values
+ while still accepting emphasized field labels, so OCR such as
+ ``TEXT: **LT7**`` is not truncated.
+- A real live synthetic regression batch run surfaced a genuine
+ `DeadlockDetectedError` from concurrent corporate-entity creation:
+ two concurrent transactions each creating a different new entity,
+ mentioned in opposite order across two different posts, took
+ row-level locks in opposite order and deadlocked. Entity *creation*
+ (the rare, first-mention-only branch) now serializes through a
+ single named Postgres advisory transaction lock, taken only right
+ before the write and auto-released at commit/rollback -- the
+ lock-free similarity-matching fast path every already-cataloged
+ entity resolves through is unaffected. See ADR 0012.
+
+## [0.76.0] - 2026-08-14
+
+### Added
+
+- Standards-complete W3C PROV-O support: all 30 classes, all 50
+ normative properties, both qualification tables, qualified-to-
+ unqualified implication, property hierarchy, defined inverses,
+ Appendix B inverse-name normalization, RDF serialization, and a
+ normalized PostgreSQL assertion store with fail-closed domain,
+ range, object-kind, and datatype enforcement (ADR 0011).
+- A dedicated exact-head PROV-O contract workflow runs the complete
+ registry/inference suite, real PostgreSQL migration tests, public
+ docstring checks, and 100% statement/branch coverage for the owned
+ runtime module.
+
+### Changed
+
+- The product navigation graph remains an explicit projection;
+ literal-valued and qualified provenance is no longer forced into
+ `knowledge_graph_edge`.
+
+### Fixed
+
+- Review hardening verifies complete hierarchy placement, rejects parent
+ failures and cycles, propagates canonical affiliations, replaces stale
+ actor projections, enforces atomic team identity, validates timezone-aware
+ `xsd:dateTime` literals (including the XSD `±14:00` offset bound), and
+ protects referenced provenance rows.
+
+## [0.75.0] - 2026-08-14
+
+### Added
+
+- A real counterparty organization mentioned for the first time now
+ gets auto-created into the corporate hierarchy, not left permanently
+ unresolved. Synthetic regression fixtures prove the first-mention gap.
+ An LLM proposes a
+ Group/Company/Plant placement from context; a real new
+ `corporate_entity` row is only created once the proposal is
+ search-corroborated (reusing the existing Searxng verification
+ client, no new search integration). Auto-created rows get a
+ deterministic `AUTO-`-prefixed code, kept structurally separate from
+ the real login corp-code namespace. Wired into both Keyman
+ affiliation resolution and R&R organization-actor resolution.
+
+## [0.74.0] - 2026-08-14
+
+### Added
+
+- R&R team and organization actors now get a shared identity across
+ posts, not just per-post free text -- the same "설계팀" (design
+ team) named in ten posts resolves to one `cataloged_team` row
+ (identity key: team name + parent org, since a bare team name is not
+ by itself identifying), and an organization actor resolves against
+ the existing `corporate_entity` catalog. Each resolved actor gets a
+ real Knowledge Graph mention edge (`edge_mention_team`,
+ `edge_team_affiliation`, `edge_mention_organization`), so extraction
+ results now genuinely become cross-post lineage clues instead of
+ per-post islands. A person R&R actor is opportunistically joined to
+ an existing Keyman-cataloged person by name (documented gap: R&R does
+ not yet originate new person identities itself -- see ADR 0009).
+
+## [0.73.0] - 2026-08-14
+
+### Fixed
+
+- Image OCR/caption parsing no longer discards a real vision response
+ just because its formatting was close but not exact (bolded labels
+ like `**TEXT:**`, reordered labels, or a missing TAGS line) --
+ observed live against synthetic embedded-image fixtures in the synthetic regression batch
+ in format-variation fixtures. Fields are now recovered independently; only a
+ response with neither TEXT nor CAPTION content is treated as
+ unusable. A strict format mismatch was silently producing the same
+ "[image: content unavailable]" placeholder as a genuinely unavailable
+ vision channel, discarding real, already-paid-for content.
+
+## [0.72.0] - 2026-08-14
+
+### Added
+
+- Abbreviated/slang organization names (e.g. "AGP") are now resolved
+ to their canonical name ("Aurora Grid Power") via LLM context, then
+ cross-verified against external search before being trusted -- new
+ `lineageweave/organization_name_resolution.py`, reusing the existing
+ Searxng verification client rather than a second web-search
+ integration. Cached in a new `organization_name_resolution` table
+ keyed by the raw name.
+- Wired into Keyman affiliation ingestion (both the API path and the
+ offline synthetic-batch script): a search-corroborated resolution feeds
+ `resolve_corporate_entity`, an unverified one leaves the raw name
+ unchanged.
+
+### Fixed
+
+- The offline synthetic-batch script's own re-implementation of Keyman
+ affiliation persistence was missing `role_title` entirely (a stale
+ copy that predated that feature) -- fixed alongside this change.
+
## [0.71.0] - 2026-08-14
### Added
@@ -14,6 +309,61 @@ All notable changes to this project are documented here. Format follows
above the names. Unassigned excerpts stay in the list; a post with
no named organization still says so.
+## [0.70.0] - 2026-08-14
+
+### Added
+
+- R&R's `actor_type_code` gains `prov_team`, a meso-level actor type for
+ a named sub-unit of a company (e.g. "설계팀"/design team) -- distinct
+ from both a person and the company itself. Grounded in the W3C
+ Organization Ontology's `org:OrganizationalUnit` (Reynolds, 2014).
+- A team actor now requires `affiliated_organization_name` in the same
+ way a person actor does -- a team's own name never answers "which
+ company."
+- R&R badge shows a distinct "Team" label/color, not the prior binary
+ Person/Organization ternary (which would have mislabeled a team as
+ "Organization").
+
+## [0.69.0] - 2026-08-14
+
+### Added
+
+- Keyman extraction now captures a stated job title/position
+ (`PersonMention.job_title`), since two different real people can
+ share a name and a title is real evidence for telling them apart.
+ Persisted to `person_affiliation.role_title` (an existing schema
+ column, previously never populated) and a new
+ `cataloged_person.last_known_job_title` for a title stated without a
+ named organization to attach it to.
+- `_upsert_person` no longer blindly merges a same-name+side match: a
+ genuinely conflicting stated title creates a fresh person row instead
+ of reusing one, verified by a real test with two posts naming the
+ same name and different titles.
+- Keyman panel shows the person's title next to their name. After
+ `make seed`, Ada West is "Account manager" and Priya Nair is
+ "Procurement lead" so the title is visible without a live LLM.
+
+### Fixed
+
+- `scripts/seed_demo_data.py` no longer embeds the local Keycloak admin
+ password. `make seed` still supplies the compose default via
+ `KEYCLOAK_ADMIN_PASSWORD`; a direct script run requires that env var
+ or `--keycloak-admin-password`.
+
+## [0.68.0] - 2026-08-14
+
+### Changed
+
+- R&R's named actor is no longer forced into a person slot. A
+ business post can name an organization acting in its own name
+ ("당사," "Demo Corp"), not an individual --
+ `RoleResponsibility.actor_name` (renamed from `person_name`) now
+ carries `actor_type_code` (Person / Organization, W3C PROV-O
+ grounded) and an LLM-inferred `affiliated_organization_name` for
+ person actors. The popup's R&R list shows a Person/Organization
+ badge and the inferred affiliation; only a person actor still links
+ to the Keyman panel. See ADR 0006.
+
## [0.67.0] - 2026-08-14
### Added
@@ -1097,7 +1447,7 @@ All notable changes to this project are documented here. Format follows
embedding, against the live embedding provider.
- `docs/lineage-bi-research-notes.md`: new "Chunking" section with the
four units' grounding and an explicit, honest note that this project's
- real dataset's only free-text field is too short to need chunking in
+ unseen dataset's only free-text field is too short to need chunking in
practice -- the module exists for richer content sources (e.g. the raw
MHTML artifacts that dataset's records were derived from).
- `lineageweave/image_content.py`: pluggable vision channel for base64
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..c5a1828f2
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,23 @@
+# CLAUDE.md
+
+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.85.0)
+
+`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.
diff --git a/Makefile b/Makefile
index 68ee850eb..82aa30faf 100644
--- a/Makefile
+++ b/Makefile
@@ -23,4 +23,5 @@ smoke:
# users' real subject ids, plus Valkey ticket_created events so Activity
# is not empty (see scripts/seed_demo_data.py). Run after `up`.
seed:
+ @test -n "$${KEYCLOAK_ADMIN_PASSWORD:-}" || { echo "KEYCLOAK_ADMIN_PASSWORD is required" >&2; exit 1; }; \
python3 scripts/seed_demo_data.py
diff --git a/backend/Dockerfile b/backend/Dockerfile
index b0c504cc8..eb6b86288 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,33 +1,37 @@
FROM python:3.12-slim@sha256:229a2c5bfa27522db7815ea81f9bed70af17ccb9de9fc7ad142b1877b5830d36
WORKDIR /app
-# rankweave and fast-mlsirm install from a git URL (see pyproject.toml) --
-# neither has a PyPI release yet. fast-mlsirm additionally ships a
-# PyO3/maturin Rust core with no fallback wheel (ADR 0003), so this build
-# needs a real Rust toolchain, not just Python -- build-essential supplies
-# the C linker maturin needs on Linux. Create the runtime user here so the
-# later USER instruction is not a no-op against a missing account
-# (DS-0002: explicit non-root USER).
-RUN apt-get update && apt-get install -y --no-install-recommends git build-essential curl ca-certificates \
+# Hash-pinned bootstrap input is copied before dependency installation so the
+# build cannot resolve a different uv artifact for the same version.
+COPY backend/uv-bootstrap-requirements.txt /tmp/uv-bootstrap-requirements.txt
+
+# rankweave and fast-mlsirm install from immutable git commit references.
+# fast-mlsirm builds a PyO3/maturin core, so the image needs a C linker and
+# the repository-pinned Rust toolchain. The runtime user is created before
+# dependencies so no application process runs as root.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git build-essential curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 1000 appuser \
- && useradd --uid 1000 --gid appuser --create-home appuser
+ && useradd --uid 1000 --gid appuser --create-home appuser \
+ && python -m pip install --no-cache-dir --no-deps \
+ --require-hashes --only-binary=:all: \
+ -r /tmp/uv-bootstrap-requirements.txt
-# Pinned, non-interactive rustup install (minimal profile: no docs/clippy,
-# just rustc+cargo) -- same "pinned, reproducible install" discipline this
-# project already applies to rankweave/fast-mlsirm's own commit pins.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
-ENV PATH="/root/.cargo/bin:${PATH}"
+ENV PATH="/app/.venv/bin:/root/.cargo/bin:${PATH}"
-COPY pyproject.toml ./
+COPY pyproject.toml uv.lock README.md ./
COPY lineageweave ./lineageweave
COPY backend ./backend
# lineageweave/ontology.py resolves this path relative to itself
# (parents[1] = /app) -- ADR 0004.
COPY docs/ontology ./docs/ontology
-RUN pip install --no-cache-dir ".[backend]" \
+# Install exactly the committed universal lock. --no-editable prevents a
+# runtime dependency on source-tree editability while retaining package data.
+RUN uv sync --frozen --no-dev --extra backend --no-editable \
&& chown -R appuser:appuser /app
USER appuser
diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py
new file mode 100644
index 000000000..d26eb6f6e
--- /dev/null
+++ b/backend/app/analysis_run_ingestion.py
@@ -0,0 +1,650 @@
+"""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.
+
+``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, run,
+scope, and the first Pending event atomically. It does not reconstruct
+lineage or invent a TEPP score.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+import asyncpg
+
+from backend.app.knowledge_graph import labels_for_codes
+from lineageweave import __version__ as PACKAGE_VERSION
+
+_ALLOWED_CREATE_KINDS = frozenset({"analysis_run_lineage", "analysis_run_tepp"})
+_CORPORATE_SCOPE = "analysis_scope_corporate_entity"
+_CAPTURE_CONTRACT_VERSION = "analysis-run-capture-v1"
+_KIND_SCHEMA_VERSION = {
+ "analysis_run_lineage": "lineage-run-v1",
+ "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 (
+ select 1 from source_post p
+ where p.thread_group_key = scope.scope_key
+ and p.created_at <= run.knowledge_cutoff
+ and (
+ p.visibility_code = 'public'
+ or p.corporate_entity_id = any($2::uuid[])
+ )
+ )
+ )
+"""
+
+_RUN_SELECT = f"""
+ select
+ run.analysis_run_id,
+ run.run_kind_code,
+ run.knowledge_cutoff,
+ run.requested_at,
+ run.configuration_schema_version,
+ run.configuration_sha256,
+ run.code_revision_sha,
+ scope.scope_kind_code,
+ scope.corporate_entity_id,
+ scope.process_unit_id,
+ scope.scope_key,
+ corp.entity_name as scope_entity_name,
+ status.status_code,
+ status.failure_code
+ from analysis_run run
+ join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id
+ left join analysis_run_current_status status
+ on status.analysis_run_id = run.analysis_run_id
+ left join corporate_entity corp
+ on corp.corporate_entity_id = scope.corporate_entity_id
+ where {{where}}
+ order by run.requested_at desc
+"""
+
+
+def _iso(value: Any) -> str:
+ """Serialize a timestamptz the same way post payloads do."""
+ return value.isoformat() if hasattr(value, "isoformat") else str(value)
+
+
+async def _counts_by_run(
+ conn: asyncpg.Connection,
+ run_ids: list[str],
+) -> dict[str, list[asyncpg.Record]]:
+ """Load aggregate snapshot counts for the given runs."""
+ if not run_ids:
+ return {}
+ rows = await conn.fetch(
+ """
+ select run.analysis_run_id, counts.count_type_code, counts.count_value
+ from analysis_run run
+ join analysis_source_count counts
+ on counts.analysis_source_snapshot_id = run.analysis_source_snapshot_id
+ where run.analysis_run_id = any($1::uuid[])
+ order by counts.count_type_code
+ """,
+ run_ids,
+ )
+ grouped: dict[str, list[asyncpg.Record]] = {}
+ for row in rows:
+ grouped.setdefault(str(row["analysis_run_id"]), []).append(row)
+ return grouped
+
+
+async def _status_history(
+ conn: asyncpg.Connection,
+ analysis_run_id: str,
+) -> list[dict[str, Any]]:
+ """Labeled append-only lifecycle for one already-visible run."""
+ rows = await conn.fetch(
+ """
+ select status_ordinal, status_code, occurred_at, failure_code
+ from analysis_run_status_event
+ where analysis_run_id = $1::uuid
+ order by status_ordinal
+ """,
+ analysis_run_id,
+ )
+ labels = await labels_for_codes(conn, [row["status_code"] for row in rows])
+ history: list[dict[str, Any]] = []
+ for row in rows:
+ item: dict[str, Any] = {
+ "status_ordinal": int(row["status_ordinal"]),
+ "status_code": row["status_code"],
+ "status_label": labels.get(row["status_code"], row["status_code"]),
+ "occurred_at": _iso(row["occurred_at"]),
+ }
+ if row["failure_code"]:
+ item["failure_code"] = row["failure_code"]
+ history.append(item)
+ return history
+
+
+async def _serialize_runs(
+ conn: asyncpg.Connection,
+ rows: list[asyncpg.Record],
+) -> list[dict[str, Any]]:
+ """Project registry rows into the authorized buyer-facing payload."""
+ if not rows:
+ return []
+ count_rows = await _counts_by_run(conn, [str(row["analysis_run_id"]) for row in rows])
+ labels = await labels_for_codes(
+ conn,
+ [row["run_kind_code"] for row in rows]
+ + [row["scope_kind_code"] for row in rows]
+ + [row["status_code"] for row in rows if row["status_code"]]
+ + [
+ count["count_type_code"]
+ for counts in count_rows.values()
+ for count in counts
+ ],
+ )
+ payload: list[dict[str, Any]] = []
+ for row in rows:
+ run_id = str(row["analysis_run_id"])
+ kind = row["run_kind_code"]
+ scope = row["scope_kind_code"]
+ status = row["status_code"]
+ item: dict[str, Any] = {
+ "analysis_run_id": run_id,
+ "run_kind_code": kind,
+ "run_kind_label": labels.get(kind, kind),
+ "scope_kind_code": scope,
+ "scope_kind_label": labels.get(scope, scope),
+ "status_code": status,
+ "status_label": labels.get(status, status) if status else None,
+ "knowledge_cutoff": _iso(row["knowledge_cutoff"]),
+ "requested_at": _iso(row["requested_at"]),
+ "source_counts": [
+ {
+ "count_type_code": count["count_type_code"],
+ "count_type_label": labels.get(
+ count["count_type_code"], count["count_type_code"]
+ ),
+ "count_value": int(count["count_value"]),
+ }
+ for count in count_rows.get(run_id, [])
+ ],
+ }
+ if row["scope_entity_name"]:
+ item["scope_entity_name"] = row["scope_entity_name"]
+ payload.append(item)
+ return payload
+
+
+async def fetch_visible_analysis_runs(
+ conn: asyncpg.Connection,
+ account_id: str,
+ affiliated_entity_ids: list[str],
+) -> list[dict[str, Any]]:
+ """Runs the account requested or whose scope they may already walk."""
+ rows = await conn.fetch(
+ _RUN_SELECT.format(where=_VISIBLE_RUN_SQL),
+ account_id,
+ affiliated_entity_ids,
+ )
+ return await _serialize_runs(conn, rows)
+
+
+async def fetch_visible_analysis_run(
+ conn: asyncpg.Connection,
+ analysis_run_id: str,
+ account_id: str,
+ affiliated_entity_ids: list[str],
+) -> dict[str, Any] | None:
+ """One visible run, or None when it is missing or hidden."""
+ rows = await conn.fetch(
+ _RUN_SELECT.format(
+ where=f"run.analysis_run_id = $3 and ({_VISIBLE_RUN_SQL})"
+ ),
+ account_id,
+ affiliated_entity_ids,
+ analysis_run_id,
+ )
+ payload = await _serialize_runs(conn, rows)
+ if not payload:
+ return None
+ detail = payload[0]
+ row = rows[0]
+ detail["configuration_schema_version"] = row["configuration_schema_version"]
+ detail["configuration_sha256"] = row["configuration_sha256"]
+ detail["code_revision_sha"] = row["code_revision_sha"]
+ if row["failure_code"]:
+ detail["failure_code"] = row["failure_code"]
+ detail["status_history"] = await _status_history(conn, analysis_run_id)
+ detail["visible_posts"] = await fetch_visible_scope_posts(
+ conn,
+ row["scope_kind_code"],
+ row["corporate_entity_id"],
+ row["process_unit_id"],
+ row["scope_key"],
+ affiliated_entity_ids,
+ row["knowledge_cutoff"],
+ )
+ return detail
+
+
+async def fetch_visible_scope_posts(
+ conn: asyncpg.Connection,
+ scope_kind_code: str,
+ corporate_entity_id: Any,
+ process_unit_id: Any,
+ scope_key: str | None,
+ affiliated_entity_ids: list[str],
+ knowledge_cutoff: Any,
+) -> list[dict[str, str]]:
+ """ABAC-visible post titles known at the run cutoff -- never a hidden body.
+
+ ``knowledge_cutoff`` is the analysis clock (W3C Time / ISO 8601-1:2019;
+ ADR 0013/0016). A later live post must not appear inside an earlier run.
+ """
+ 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 where created_at <= $1 "
+ "order by created_at, post_title",
+ knowledge_cutoff,
+ )
+ else:
+ return []
+ affiliated = {str(entity_id) for entity_id in affiliated_entity_ids}
+ posts: list[dict[str, str]] = []
+ for row in rows:
+ visible = row["visibility_code"] == "public" or str(row["corporate_entity_id"]) in affiliated
+ if not visible:
+ continue
+ posts.append({"post_id": str(row["post_id"]), "post_title": row["post_title"]})
+ return posts
+
+
+class AnalysisRunCreateError(Exception):
+ """Fail-closed create: HTTP status plus a next-action detail string."""
+
+ def __init__(self, status_code: int, detail: str) -> None:
+ super().__init__(detail)
+ self.status_code = status_code
+ self.detail = detail
+
+
+@dataclass(frozen=True)
+class AnalysisRunCapture:
+ """Immutable capture plan for one authorized create (no source rows)."""
+
+ snapshot_sha256: str
+ maximum_available_time: datetime
+ document_count: int
+ thread_count: int
+ configuration_sha256: str
+ configuration_schema_version: str
+ code_revision_sha: str
+
+
+def utc_iso(value: datetime) -> str:
+ """Normalize a timestamp to UTC ISO-8601 for digest stability."""
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=timezone.utc)
+ return value.astimezone(timezone.utc).isoformat()
+
+
+def plan_analysis_run_capture(
+ *,
+ run_kind_code: str,
+ scope_kind_code: str,
+ corporate_entity_id: str,
+ knowledge_cutoff: datetime,
+ idempotency_key: str,
+ post_ids: list[str],
+ thread_keys: list[str],
+ latest_post_created_at: datetime | None,
+ cutoff_explicit: bool = True,
+) -> AnalysisRunCapture:
+ """Hash the authorized cutoff bag. Never stores a post body or DSN.
+
+ An omitted cutoff is hashed as ``unspecified`` so a retry of the same
+ client key does not 409 just because the clock moved.
+ """
+ cutoff_token = utc_iso(knowledge_cutoff) if cutoff_explicit else "unspecified"
+ snapshot_material = json.dumps(
+ {
+ "scope_kind_code": scope_kind_code,
+ "corporate_entity_id": corporate_entity_id,
+ "knowledge_cutoff": cutoff_token,
+ "post_ids": sorted(post_ids),
+ },
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ configuration_material = json.dumps(
+ {
+ "run_kind_code": run_kind_code,
+ "scope_kind_code": scope_kind_code,
+ "corporate_entity_id": corporate_entity_id,
+ "knowledge_cutoff": cutoff_token,
+ "idempotency_key": idempotency_key,
+ "configuration_schema_version": _KIND_SCHEMA_VERSION[run_kind_code],
+ },
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ available = latest_post_created_at if latest_post_created_at is not None else knowledge_cutoff
+ return AnalysisRunCapture(
+ snapshot_sha256=hashlib.sha256(snapshot_material.encode()).hexdigest(),
+ maximum_available_time=available,
+ document_count=len(post_ids),
+ thread_count=len(set(thread_keys)),
+ configuration_sha256=hashlib.sha256(configuration_material.encode()).hexdigest(),
+ configuration_schema_version=_KIND_SCHEMA_VERSION[run_kind_code],
+ code_revision_sha=hashlib.sha256(f"lineageweave-{PACKAGE_VERSION}".encode()).hexdigest(),
+ )
+
+
+def _canonical_idempotency_key(raw: str) -> str:
+ """Trim and reject empty or control-bearing client keys."""
+ key = raw.strip()
+ if not key or len(key) > 256 or any(ord(char) < 32 for char in key):
+ raise AnalysisRunCreateError(
+ 422,
+ "Use a 1–256 character idempotency key without control characters, then retry.",
+ )
+ return key
+
+
+def _resolve_corporate_entity_id(
+ corporate_entity_id: str | None,
+ affiliated_entity_ids: list[str],
+) -> str:
+ """Return the affiliated corp this run may cover, or a next-action error."""
+ affiliated = [entity_id for entity_id in affiliated_entity_ids if entity_id]
+ if corporate_entity_id:
+ try:
+ UUID(corporate_entity_id)
+ except ValueError as exc:
+ raise AnalysisRunCreateError(
+ 404,
+ "This corporate entity is not visible to this account.",
+ ) from exc
+ if corporate_entity_id not in affiliated:
+ raise AnalysisRunCreateError(
+ 404,
+ "This corporate entity is not visible to this account.",
+ )
+ return corporate_entity_id
+ if len(affiliated) != 1:
+ raise AnalysisRunCreateError(
+ 422,
+ "Choose the corporate entity this run should cover.",
+ )
+ return affiliated[0]
+
+
+async def create_pending_analysis_run(
+ conn: asyncpg.Connection,
+ *,
+ account_id: str,
+ affiliated_entity_ids: list[str],
+ run_kind_code: str,
+ scope_kind_code: str,
+ corporate_entity_id: str | None,
+ knowledge_cutoff: datetime | None,
+ idempotency_key: str,
+) -> dict[str, Any]:
+ """Insert snapshot, counts, run, scope, and Pending in one transaction.
+
+ Does not reconstruct lineage and does not call TEPP. A missing
+ measurement stays a later worker slice; this write only records the
+ request. Idempotent retries compare ``configuration_sha256``.
+ """
+ if run_kind_code not in _ALLOWED_CREATE_KINDS:
+ raise AnalysisRunCreateError(
+ 422,
+ "Request a lineage reconstruction or a TEPP measurement. Other kinds are not available yet.",
+ )
+ if scope_kind_code != _CORPORATE_SCOPE:
+ raise AnalysisRunCreateError(
+ 422,
+ "Request a corporate-entity run. Other scopes are not available yet.",
+ )
+ cutoff_explicit = knowledge_cutoff is not None
+ if knowledge_cutoff is None:
+ knowledge_cutoff = datetime.now(timezone.utc)
+ elif knowledge_cutoff.tzinfo is None:
+ knowledge_cutoff = knowledge_cutoff.replace(tzinfo=timezone.utc)
+ now = datetime.now(timezone.utc)
+ if knowledge_cutoff > now:
+ raise AnalysisRunCreateError(
+ 422,
+ "Choose a knowledge cutoff at or before now, then request the run again.",
+ )
+ key = _canonical_idempotency_key(idempotency_key)
+ corp_id = _resolve_corporate_entity_id(corporate_entity_id, affiliated_entity_ids)
+
+ existing = await conn.fetchrow(
+ """
+ select analysis_run_id, configuration_sha256
+ from analysis_run
+ where requested_by_account_id = $1 and idempotency_key = $2
+ """,
+ account_id,
+ key,
+ )
+
+ rows = await conn.fetch(
+ """
+ select post_id, post_title, thread_group_key, created_at,
+ visibility_code, corporate_entity_id
+ from source_post
+ where corporate_entity_id = $1 and created_at <= $2
+ order by created_at, post_title
+ """,
+ corp_id,
+ knowledge_cutoff,
+ )
+ affiliated = {str(entity_id) for entity_id in affiliated_entity_ids}
+ visible_rows = [
+ row
+ for row in rows
+ if row["visibility_code"] == "public" or str(row["corporate_entity_id"]) in affiliated
+ ]
+ post_ids = [str(row["post_id"]) for row in visible_rows]
+ thread_keys = [row["thread_group_key"] for row in visible_rows]
+ latest = max((row["created_at"] for row in visible_rows), default=None)
+ capture = plan_analysis_run_capture(
+ run_kind_code=run_kind_code,
+ scope_kind_code=scope_kind_code,
+ corporate_entity_id=corp_id,
+ knowledge_cutoff=knowledge_cutoff,
+ idempotency_key=key,
+ post_ids=post_ids,
+ thread_keys=thread_keys,
+ latest_post_created_at=latest,
+ cutoff_explicit=cutoff_explicit,
+ )
+ if existing is not None:
+ if existing["configuration_sha256"] != capture.configuration_sha256:
+ raise AnalysisRunCreateError(
+ 409,
+ "This request does not match the earlier run with the same key. "
+ "Open that run, or retry with a new idempotency key.",
+ )
+ replayed = await fetch_visible_analysis_run(
+ conn,
+ str(existing["analysis_run_id"]),
+ account_id,
+ affiliated_entity_ids,
+ )
+ if replayed is None:
+ raise AnalysisRunCreateError(404, "This analysis run is not visible.")
+ return replayed
+
+ snapshot_id = await conn.fetchval(
+ """
+ insert into analysis_source_snapshot
+ (snapshot_sha256, source_contract_version,
+ maximum_available_time, captured_at, created_at)
+ values ($1, $2, $3, $4, $4)
+ on conflict (snapshot_sha256) do nothing
+ returning analysis_source_snapshot_id
+ """,
+ capture.snapshot_sha256,
+ _CAPTURE_CONTRACT_VERSION,
+ capture.maximum_available_time,
+ now,
+ )
+ if snapshot_id is None:
+ snapshot_id = await conn.fetchval(
+ """
+ select analysis_source_snapshot_id
+ from analysis_source_snapshot
+ where snapshot_sha256 = $1
+ for update
+ """,
+ capture.snapshot_sha256,
+ )
+ count_exists = await conn.fetchval(
+ """
+ select 1 from analysis_source_count
+ where analysis_source_snapshot_id = $1
+ limit 1
+ """,
+ snapshot_id,
+ )
+ if count_exists is None:
+ await conn.execute(
+ """
+ insert into analysis_source_count
+ (analysis_source_snapshot_id, count_type_code, count_value)
+ values
+ ($1, 'analysis_count_document', $2),
+ ($1, 'analysis_count_thread', $3)
+ """,
+ snapshot_id,
+ capture.document_count,
+ capture.thread_count,
+ )
+ try:
+ run_id = await conn.fetchval(
+ """
+ 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 ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ returning analysis_run_id
+ """,
+ snapshot_id,
+ run_kind_code,
+ key,
+ account_id,
+ knowledge_cutoff,
+ capture.configuration_schema_version,
+ capture.configuration_sha256,
+ capture.code_revision_sha,
+ now,
+ )
+ except asyncpg.UniqueViolationError:
+ raced = await conn.fetchrow(
+ """
+ select analysis_run_id, configuration_sha256
+ from analysis_run
+ where requested_by_account_id = $1 and idempotency_key = $2
+ """,
+ account_id,
+ key,
+ )
+ if raced is None or raced["configuration_sha256"] != capture.configuration_sha256:
+ raise AnalysisRunCreateError(
+ 409,
+ "This request does not match the earlier run with the same key. "
+ "Open that run, or retry with a new idempotency key.",
+ ) from None
+ replayed = await fetch_visible_analysis_run(
+ conn,
+ str(raced["analysis_run_id"]),
+ account_id,
+ affiliated_entity_ids,
+ )
+ if replayed is None:
+ raise AnalysisRunCreateError(404, "This analysis run is not visible.")
+ return replayed
+ await conn.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code, corporate_entity_id)
+ values ($1, $2, $3)
+ """,
+ run_id,
+ scope_kind_code,
+ corp_id,
+ )
+ await conn.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at)
+ values ($1, 1, 'analysis_status_pending', $2)
+ """,
+ run_id,
+ now,
+ )
+ created = await fetch_visible_analysis_run(
+ conn,
+ str(run_id),
+ account_id,
+ affiliated_entity_ids,
+ )
+ if created is None:
+ raise AnalysisRunCreateError(404, "This analysis run is not visible.")
+ return created
diff --git a/backend/app/corporate_entity_ingestion.py b/backend/app/corporate_entity_ingestion.py
new file mode 100644
index 000000000..57baadc5e
--- /dev/null
+++ b/backend/app/corporate_entity_ingestion.py
@@ -0,0 +1,193 @@
+
+"""Resolve an organization mention to the corporate hierarchy catalog.
+
+Existing similarity matches are reused. A previously unseen entity is
+created only after inference proposes its complete hierarchy placement
+and external verification corroborates that placement. Parent failure,
+cycles, and excessive depth all fail closed. See ADR 0010.
+
+Creation writes take one named Postgres advisory transaction lock
+(``pg_advisory_xact_lock``) after network inference/verification, then
+reload catalog candidates before inserting. See ADR 0012.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+
+import asyncpg
+
+from lineageweave.corporate_hierarchy_inference import (
+ CorporateHierarchyInferenceClient,
+ HierarchyProposal,
+)
+from lineageweave.corporate_hierarchy_resolution import (
+ CorporateEntityCandidate,
+ resolve_corporate_entity,
+)
+from lineageweave.relation_verification import (
+ STATUS_CORROBORATED,
+ RelationVerificationClient,
+)
+
+_AUTO_CODE_PREFIX = "AUTO-"
+_MAX_HIERARCHY_DEPTH = 4
+_CREATION_LOCK_KEY = "lineageweave:corporate_entity_creation"
+
+
+def _auto_entity_code(organization_name: str) -> str:
+ """Return a deterministic, namespace-separated code."""
+ digest = hashlib.sha256(organization_name.encode("utf-8")).hexdigest()[:16]
+ return f"{_AUTO_CODE_PREFIX}{digest}"
+
+
+def _hierarchy_verification_label(proposal: HierarchyProposal) -> str:
+ """Describe every persisted hierarchy field in one claim."""
+ parent = proposal.parent_name if proposal.parent_name is not None else "NO_PARENT"
+ return f"corporate hierarchy level={proposal.level_code}; immediate_parent={parent}"
+
+
+async def _create_entity(
+ conn: asyncpg.Connection,
+ organization_name: str,
+ level_code: str,
+ parent_entity_id: str | None,
+) -> str:
+ """Insert one entity atomically and return its catalog id."""
+ row = await conn.fetchrow(
+ """
+ insert into corporate_entity
+ (parent_entity_id, corporate_entity_code, entity_name, entity_level_code)
+ values ($1, $2, $3, $4)
+ on conflict (corporate_entity_code) do update set
+ entity_name = excluded.entity_name,
+ entity_level_code = excluded.entity_level_code,
+ parent_entity_id = excluded.parent_entity_id
+ returning corporate_entity_id
+ """,
+ parent_entity_id,
+ _auto_entity_code(organization_name),
+ organization_name,
+ level_code,
+ )
+ return str(row["corporate_entity_id"])
+
+
+async def _reload_candidates(conn: asyncpg.Connection) -> list[CorporateEntityCandidate]:
+ """Read every cataloged entity after the creation lock is held."""
+ rows = await conn.fetch("select corporate_entity_id, entity_name from corporate_entity")
+ return [
+ CorporateEntityCandidate(str(row["corporate_entity_id"]), row["entity_name"])
+ for row in rows
+ ]
+
+
+def _remember_candidate(
+ candidates: list[CorporateEntityCandidate],
+ corporate_entity_id: str,
+ entity_name: str,
+) -> None:
+ """Keep the caller's in-memory snapshot aligned with a resolved id."""
+ if any(candidate.corporate_entity_id == corporate_entity_id for candidate in candidates):
+ return
+ candidates.append(
+ CorporateEntityCandidate(
+ corporate_entity_id=corporate_entity_id,
+ entity_name=entity_name,
+ )
+ )
+
+
+async def get_or_create_corporate_entity(
+ conn: asyncpg.Connection,
+ organization_name: str,
+ context_text: str,
+ inference_client: CorporateHierarchyInferenceClient,
+ verification_client: RelationVerificationClient,
+ candidates: list[CorporateEntityCandidate],
+ *,
+ _depth: int = 0,
+ _visited_names: frozenset[str] = frozenset(),
+) -> str | None:
+ """Return a verified catalog id, otherwise ``None``.
+
+ A proposed parent must independently corroborate and resolve before
+ the child can be inserted. Repeated names in the recursion path are
+ cycles, including multi-node cycles such as A -> B -> A.
+ """
+ normalized_name = organization_name.strip()
+ if not normalized_name:
+ return None
+ visit_key = normalized_name.casefold()
+ if visit_key in _visited_names:
+ return None
+
+ existing_id = resolve_corporate_entity(normalized_name, candidates)
+ if existing_id is not None:
+ return existing_id
+ if _depth >= _MAX_HIERARCHY_DEPTH or not inference_client.available:
+ return None
+
+ proposal = await asyncio.to_thread(
+ inference_client.infer,
+ normalized_name,
+ context_text,
+ )
+ if proposal is None or not verification_client.available:
+ return None
+
+ placement_result = await asyncio.to_thread(
+ verification_client.verify,
+ normalized_name,
+ _hierarchy_verification_label(proposal),
+ )
+ if placement_result.status_code != STATUS_CORROBORATED:
+ return None
+
+ visited_names = _visited_names | {visit_key}
+ parent_entity_id: str | None = None
+ if proposal.parent_name is not None:
+ normalized_parent = proposal.parent_name.strip()
+ if not normalized_parent or normalized_parent.casefold() in visited_names:
+ return None
+ parent_result = await asyncio.to_thread(
+ verification_client.verify,
+ normalized_parent,
+ f"immediate parent of {normalized_name}",
+ )
+ if parent_result.status_code != STATUS_CORROBORATED:
+ return None
+ parent_entity_id = await get_or_create_corporate_entity(
+ conn,
+ normalized_parent,
+ context_text,
+ inference_client,
+ verification_client,
+ candidates,
+ _depth=_depth + 1,
+ _visited_names=visited_names,
+ )
+ if parent_entity_id is None:
+ return None
+
+ async with conn.transaction():
+ await conn.execute(
+ "select pg_advisory_xact_lock(hashtext($1))",
+ _CREATION_LOCK_KEY,
+ )
+ fresh_existing_id = resolve_corporate_entity(
+ normalized_name,
+ await _reload_candidates(conn),
+ )
+ if fresh_existing_id is not None:
+ _remember_candidate(candidates, fresh_existing_id, normalized_name)
+ return fresh_existing_id
+ new_id = await _create_entity(
+ conn,
+ normalized_name,
+ proposal.level_code,
+ parent_entity_id,
+ )
+ _remember_candidate(candidates, new_id, normalized_name)
+ return new_id
diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py
index 30fd62361..091e58f40 100644
--- a/backend/app/entity_relationship_ingestion.py
+++ b/backend/app/entity_relationship_ingestion.py
@@ -6,6 +6,7 @@
from __future__ import annotations
+import asyncio
from collections.abc import Mapping, Sequence
from typing import Any
@@ -38,7 +39,9 @@ async def ingest_post_entity_relationships(
if not organization_names:
return []
- relationships = client.classify(post_title, post_body, organization_names)
+ relationships = await asyncio.to_thread(
+ client.classify, post_title, post_body, organization_names
+ )
for relationship in relationships:
await conn.execute(
diff --git a/backend/app/keyman_ingestion.py b/backend/app/keyman_ingestion.py
index e5451a9bc..906442ba4 100644
--- a/backend/app/keyman_ingestion.py
+++ b/backend/app/keyman_ingestion.py
@@ -6,23 +6,63 @@
`person_affiliation` (N:N, matched to a real `corporate_entity` via
similarity-based resolution -- see
`lineageweave.corporate_hierarchy_resolution`, so an abbreviation or
-trailing legal suffix still resolves, not just an exact string match),
-and `post_person_mention`. Finishes by calling
-`knowledge_graph.persist_edges_for_post` so the Knowledge Graph edges are
-computed from the same write, not a separate manual step.
+trailing legal suffix still resolves, not just an exact string match --
+plus `role_title`, a schema column that already existed and was
+previously never populated by this pipeline), and `post_person_mention`.
+Finishes by calling `knowledge_graph.persist_edges_for_post` so the
+Knowledge Graph edges are computed from the same write, not a separate
+manual step.
+
+Same-name disambiguation: `_upsert_person`'s name+side match is a real,
+known simplification (documented above), but a stated job title is real
+evidence a same-name match should NOT blindly trust -- when the new
+mention names a title that conflicts with a title already on file for
+that name+side (both stated, genuinely different), a fresh
+`cataloged_person` row is created rather than merging two people who
+happen to share a name. A person's title legitimately changes over time
+(a promotion), so this only splits on an actual stated conflict, never
+on a missing title on either side.
+
+Abbreviation resolution (ADR 0008): before matching against
+`corporate_entity`, each affiliated organization name is run through
+`organization_name_resolution_ingestion.resolve_organization_name` --
+character-similarity matching alone cannot bridge an initialism like
+"AGP" to its expansion "Aurora Grid Power". Only a search-corroborated
+resolution is substituted in; an unresolved or unverified name still
+flows through unchanged.
+
+Hierarchy auto-creation (ADR 0010): a unseen dataset's first mention of
+any new counterparty organization has no existing `corporate_entity`
+candidate for similarity matching to find at all -- matching alone can
+only ever locate an already-cataloged entity. `get_or_create_corporate_entity`
+tries similarity matching first, then falls back to an LLM-proposed,
+search-corroborated hierarchy placement (level + parent) before
+creating a real new row, so the "통합 고객사 계열 tree AI" requirement
+is actually populated from real extraction, not left permanently empty.
"""
from __future__ import annotations
+import asyncio
+from dataclasses import replace
+
import asyncpg
-from lineageweave.corporate_hierarchy_resolution import (
- CorporateEntityCandidate,
- resolve_corporate_entity,
+from lineageweave.corporate_hierarchy_inference import (
+ CorporateHierarchyInferenceClient,
+ NullCorporateHierarchyInferenceClient,
)
+from lineageweave.corporate_hierarchy_resolution import CorporateEntityCandidate
from lineageweave.keyman_extraction import KeymanExtractionClient, PersonMention
+from lineageweave.organization_name_resolution import (
+ NullOrganizationNameResolutionClient,
+ OrganizationNameResolutionClient,
+)
+from lineageweave.relation_verification import NullRelationVerificationClient, RelationVerificationClient
+from .corporate_entity_ingestion import get_or_create_corporate_entity
from .knowledge_graph import persist_edges_for_post
+from .organization_name_resolution_ingestion import resolve_organization_name
async def _load_corporate_entity_candidates(conn: asyncpg.Connection) -> list[CorporateEntityCandidate]:
@@ -34,60 +74,187 @@ async def _load_corporate_entity_candidates(conn: asyncpg.Connection) -> list[Co
async def _upsert_person(conn: asyncpg.Connection, mention: PersonMention) -> str:
- """Reuse a same-name, same-side row so re-extraction does not duplicate."""
- row = await conn.fetchrow(
- "select person_id from cataloged_person where person_name = $1 and person_side_code = $2",
+ """Reuse a same-name, same-side row so re-extraction does not duplicate
+ -- unless the new mention's stated job title conflicts with a title
+ already on file for that name+side (`last_known_job_title`, checked
+ even when this mention names no affiliated organization -- a title
+ is real same-name-disambiguation evidence on its own, see module
+ docstring), in which case a same name is not trusted as the same
+ real person.
+ """
+ candidates = await conn.fetch(
+ "select person_id, last_known_job_title from cataloged_person "
+ "where person_name = $1 and person_side_code = $2",
mention.person_name,
mention.person_side_code,
)
- if row is not None:
- return str(row["person_id"])
+ if candidates and mention.job_title:
+ for candidate in candidates:
+ on_file = candidate["last_known_job_title"]
+ if on_file is not None and on_file != mention.job_title:
+ continue # stated title conflicts -- do not reuse this row
+ if on_file is None:
+ await conn.execute(
+ "update cataloged_person set last_known_job_title = $1 where person_id = $2",
+ mention.job_title,
+ candidate["person_id"],
+ )
+ return str(candidate["person_id"])
+ elif candidates:
+ return str(candidates[0]["person_id"])
+
row = await conn.fetchrow(
- "insert into cataloged_person (person_name, person_side_code) values ($1, $2) returning person_id",
+ "insert into cataloged_person (person_name, person_side_code, last_known_job_title) "
+ "values ($1, $2, $3) returning person_id",
mention.person_name,
mention.person_side_code,
+ mention.job_title,
)
return str(row["person_id"])
+
+async def _upsert_affiliation(
+ conn: asyncpg.Connection,
+ person_id: str,
+ raw_name: str,
+ resolved_name: str,
+ corporate_entity_id: str | None,
+ role_title: str | None,
+) -> None:
+ """Promote a raw affiliation row into one canonical identity."""
+ await conn.execute(
+ """
+ with legacy_affiliation as (
+ select affiliated_corporate_entity_id, role_title
+ from person_affiliation
+ where person_id = $1
+ and affiliated_organization_name = $2
+ ),
+ canonical_affiliation as (
+ insert into person_affiliation
+ (person_id, affiliated_organization_name,
+ affiliated_corporate_entity_id, role_title)
+ values (
+ $1,
+ $3,
+ coalesce($4, (select affiliated_corporate_entity_id from legacy_affiliation)),
+ coalesce($5, (select role_title from legacy_affiliation))
+ )
+ on conflict (person_id, affiliated_organization_name)
+ do update set
+ affiliated_corporate_entity_id = coalesce(
+ excluded.affiliated_corporate_entity_id,
+ person_affiliation.affiliated_corporate_entity_id
+ ),
+ role_title = coalesce(
+ excluded.role_title,
+ person_affiliation.role_title
+ )
+ returning person_affiliation_id
+ )
+ delete from person_affiliation
+ where person_id = $1
+ and affiliated_organization_name = $2
+ and $2 <> $3
+ """,
+ person_id,
+ raw_name,
+ resolved_name,
+ corporate_entity_id,
+ role_title,
+ )
+
+
async def ingest_post_keymen(
conn: asyncpg.Connection,
client: KeymanExtractionClient,
post_id: str,
post_title: str,
post_body: str,
+ *,
+ resolution_client: OrganizationNameResolutionClient | None = None,
+ verification_client: RelationVerificationClient | None = None,
+ hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,
+ persist_graph: bool = True,
) -> list[PersonMention]:
"""Extracts, persists, and returns the `PersonMention`s found in one post.
+ `resolution_client`/`verification_client`/`hierarchy_inference_client`
+ default to the unavailable Null clients -- callers that don't pass
+ real ones get the exact same behavior as before ADR 0008/0010 (raw
+ affiliation names, unresolved).
+
+ Organization resolution and hierarchy creation finish before the Keyman
+ write transaction. Callers must not wrap this function in an outer
+ transaction: that would turn ``pg_advisory_xact_lock`` into a savepoint
+ and hold the creation lock across later LLM work. The post's prior
+ Keyman mention set is replaced atomically after enrichment.
+ ``persist_graph=False`` lets a larger caller persist edges in its own
+ short write transaction after this function returns.
+
Raises whatever `client.extract` raises (e.g. a `NullKeymanExtractionClient`
would raise `RuntimeError`) -- callers should check `client.available`
first, same discipline as every other pluggable channel in this repo.
"""
- mentions = client.extract(post_title, post_body)
+ resolution_client = resolution_client or NullOrganizationNameResolutionClient()
+ verification_client = verification_client or NullRelationVerificationClient()
+ hierarchy_inference_client = hierarchy_inference_client or NullCorporateHierarchyInferenceClient()
+ mentions = await asyncio.to_thread(client.extract, post_title, post_body)
candidates = await _load_corporate_entity_candidates(conn)
-
+ resolved_by_mention: list[tuple[PersonMention, list[tuple[str, str, str | None]]]] = []
for mention in mentions:
- person_id = await _upsert_person(conn, mention)
+ resolved_orgs: list[tuple[str, str, str | None]] = []
+ for organization_name in mention.affiliated_organization_names:
+ resolved_name = await resolve_organization_name(
+ conn,
+ resolution_client,
+ verification_client,
+ organization_name,
+ post_body,
+ )
+ corporate_entity_id = await get_or_create_corporate_entity(
+ conn,
+ resolved_name,
+ post_body,
+ hierarchy_inference_client,
+ verification_client,
+ candidates,
+ )
+ resolved_orgs.append((organization_name, resolved_name, corporate_entity_id))
+ resolved_by_mention.append((mention, resolved_orgs))
+
+ normalized_mentions: list[PersonMention] = []
+ async with conn.transaction():
await conn.execute(
- "insert into post_person_mention (post_id, person_id) values ($1, $2) on conflict do nothing",
- post_id,
- person_id,
+ "delete from post_person_mention where post_id = $1", post_id
)
- for organization_name in mention.affiliated_organization_names:
- corporate_entity_id = resolve_corporate_entity(organization_name, candidates)
+ for mention, resolved_orgs in resolved_by_mention:
+ person_id = await _upsert_person(conn, mention)
await conn.execute(
- """
- insert into person_affiliation (person_id, affiliated_organization_name, affiliated_corporate_entity_id)
- values ($1, $2, $3)
- on conflict (person_id, affiliated_organization_name)
- do update set affiliated_corporate_entity_id = excluded.affiliated_corporate_entity_id
- """,
+ "insert into post_person_mention (post_id, person_id) values ($1, $2) on conflict do nothing",
+ post_id,
person_id,
- organization_name,
- corporate_entity_id,
)
+ resolved_names: list[str] = []
+ for organization_name, resolved_name, corporate_entity_id in resolved_orgs:
+ await _upsert_affiliation(
+ conn,
+ person_id,
+ organization_name,
+ resolved_name,
+ corporate_entity_id,
+ mention.job_title,
+ )
+ if resolved_name not in resolved_names:
+ resolved_names.append(resolved_name)
+ normalized_mentions.append(
+ replace(
+ mention,
+ affiliated_organization_names=tuple(resolved_names),
+ )
+ )
+ if persist_graph:
+ await persist_edges_for_post(conn, post_id)
- if mentions:
- await persist_edges_for_post(conn, post_id)
-
- return mentions
+ return normalized_mentions
diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py
index bb398d141..ce7289bb5 100644
--- a/backend/app/knowledge_graph.py
+++ b/backend/app/knowledge_graph.py
@@ -18,9 +18,13 @@
EDGE_AFFILIATION,
EDGE_CO_MENTION,
EDGE_MENTION,
+ EDGE_MENTION_ORGANIZATION,
+ EDGE_MENTION_TEAM,
+ EDGE_TEAM_AFFILIATION,
NODE_CORPORATE_ENTITY,
NODE_PERSON,
NODE_POST,
+ NODE_TEAM,
KnowledgeGraphEdgeSpec,
adjacency_from_edges,
knowledge_graph_edges_for_post,
@@ -31,6 +35,9 @@
)
+_GRAPH_PROJECTION_LOCK_KEY = "lineageweave:knowledge_graph_projection"
+
+
def edge_spec_from_row(row: asyncpg.Record) -> KnowledgeGraphEdgeSpec:
"""Map one ``knowledge_graph_edge`` row onto the library spec."""
return KnowledgeGraphEdgeSpec(
@@ -63,7 +70,7 @@ async def fetch_post_keymen(conn: asyncpg.Connection, post_id: str) -> list[dict
"""Load mentioned people and their affiliations for one post."""
person_rows = await conn.fetch(
"""
- select p.person_id, p.person_name, p.person_side_code, ppm.mention_context
+ select p.person_id, p.person_name, p.person_side_code, p.last_known_job_title, ppm.mention_context
from post_person_mention ppm
join cataloged_person p on p.person_id = ppm.person_id
where ppm.post_id = $1
@@ -105,16 +112,34 @@ async def fetch_post_keymen(conn: asyncpg.Connection, post_id: str) -> list[dict
"person_side_code": row["person_side_code"],
"person_side_label": side_labels.get(row["person_side_code"], row["person_side_code"]),
"mention_context": row["mention_context"],
+ "last_known_job_title": row["last_known_job_title"],
"affiliations": affiliations_by_person.get(str(row["person_id"]), []),
}
for row in person_rows
]
-async def persist_edges_for_post(conn: asyncpg.Connection, post_id: str) -> list[KnowledgeGraphEdgeSpec]:
- """Insert mention, affiliation, and co-mention edges for one post."""
+async def persist_edges_for_post(
+ conn: asyncpg.Connection, post_id: str
+) -> list[KnowledgeGraphEdgeSpec]:
+ """Reconcile one post's evidence-backed navigation projection.
+
+ Callers own the surrounding transaction. A transaction-scoped
+ advisory lock serializes the small materialized projection so two
+ writers cannot interleave evidence deletion and orphan pruning.
+ Keyman and R&R person sources stay distinct in their writable tables;
+ ``combined_post_person_mention`` is used only to derive graph edges.
+ """
+ await conn.execute(
+ "select pg_advisory_xact_lock(hashtext($1))",
+ _GRAPH_PROJECTION_LOCK_KEY,
+ )
+ await conn.execute(
+ "delete from knowledge_graph_edge_evidence where evidence_post_id = $1",
+ post_id,
+ )
mention_rows = await conn.fetch(
- "select person_id from post_person_mention where post_id = $1",
+ "select person_id from combined_post_person_mention where post_id = $1",
post_id,
)
affiliation_rows = await conn.fetch(
@@ -126,6 +151,23 @@ async def persist_edges_for_post(conn: asyncpg.Connection, post_id: str) -> list
""",
[row["person_id"] for row in mention_rows],
)
+ team_mention_rows = await conn.fetch(
+ "select team_id from post_team_mention where post_id = $1",
+ post_id,
+ )
+ team_affiliation_rows = await conn.fetch(
+ """
+ select team_id, affiliated_corporate_entity_id
+ from cataloged_team
+ where team_id = any($1::uuid[])
+ and affiliated_corporate_entity_id is not null
+ """,
+ [row["team_id"] for row in team_mention_rows],
+ )
+ organization_mention_rows = await conn.fetch(
+ "select corporate_entity_id from post_organization_mention where post_id = $1",
+ post_id,
+ )
edges = knowledge_graph_edges_for_post(
post_id,
[str(row["person_id"]) for row in mention_rows],
@@ -133,24 +175,27 @@ async def persist_edges_for_post(conn: asyncpg.Connection, post_id: str) -> list
(str(row["person_id"]), str(row["affiliated_corporate_entity_id"]))
for row in affiliation_rows
],
+ [str(row["team_id"]) for row in team_mention_rows],
+ [
+ (str(row["team_id"]), str(row["affiliated_corporate_entity_id"]))
+ for row in team_affiliation_rows
+ ],
+ [str(row["corporate_entity_id"]) for row in organization_mention_rows],
)
for edge in edges:
- await conn.execute(
+ await conn.fetchrow(
"""
insert into knowledge_graph_edge (
source_node_type_code, source_node_id,
target_node_type_code, target_node_id,
edge_type_code, edge_weight
- )
- select $1, $2::uuid, $3, $4::uuid, $5, $6
- where not exists (
- select 1 from knowledge_graph_edge
- where source_node_type_code = $1
- and source_node_id = $2::uuid
- and target_node_type_code = $3
- and target_node_id = $4::uuid
- and edge_type_code = $5
- )
+ ) values ($1, $2::uuid, $3, $4::uuid, $5, $6)
+ on conflict (
+ source_node_type_code, source_node_id,
+ target_node_type_code, target_node_id,
+ edge_type_code
+ ) do update set edge_weight = excluded.edge_weight
+ returning knowledge_graph_edge_id
""",
edge.source_node_type_code,
edge.source_node_id,
@@ -159,9 +204,19 @@ async def persist_edges_for_post(conn: asyncpg.Connection, post_id: str) -> list
edge.edge_type_code,
edge.edge_weight,
)
+ await conn.execute(
+ """
+ delete from knowledge_graph_edge edge_row
+ where not exists (
+ select 1
+ from knowledge_graph_edge_evidence evidence
+ where evidence.knowledge_graph_edge_id =
+ edge_row.knowledge_graph_edge_id
+ )
+ """
+ )
return edges
-
async def person_exists(conn: asyncpg.Connection, person_id: str) -> bool:
"""True when ``person_id`` is a UUID that exists in ``cataloged_person``."""
try:
@@ -184,83 +239,189 @@ async def corporate_entity_exists(conn: asyncpg.Connection, entity_id: str) -> b
return row is not None
+async def team_exists(conn: asyncpg.Connection, team_id: str) -> bool:
+ """True when ``team_id`` is a UUID that exists in ``cataloged_team``."""
+ try:
+ UUID(team_id)
+ except ValueError:
+ return False
+ row = await conn.fetchrow("select 1 from cataloged_team where team_id = $1", team_id)
+ return row is not None
+
+
async def visible_mention_post_ids(
conn: asyncpg.Connection,
person_id: str,
can_see_post,
) -> list[str]:
- """Post ids that mention ``person_id`` and pass the caller's ABAC check."""
+ """Visible post ids supported by Keyman or R&R person evidence."""
rows = await conn.fetch(
"""
- select p.post_id, p.visibility_code, p.corporate_entity_id
- from post_person_mention ppm
- join source_post p on p.post_id = ppm.post_id
- where ppm.person_id = $1
+ select post.post_id, post.visibility_code, post.corporate_entity_id
+ from combined_post_person_mention mention
+ join source_post post on post.post_id = mention.post_id
+ where mention.person_id = $1
+ order by post.created_at, post.post_id
""",
person_id,
)
return [str(row["post_id"]) for row in rows if can_see_post(row)]
-
async def visible_affiliation_post_ids(
conn: asyncpg.Connection,
entity_id: str,
can_see_post,
) -> list[str]:
- """Post ids that mention someone affiliated with ``entity_id`` and pass ABAC."""
+ """Visible posts that mention an entity via a person or a direct org mention."""
rows = await conn.fetch(
"""
- select distinct p.post_id, p.visibility_code, p.corporate_entity_id
- from person_affiliation pa
- join post_person_mention ppm on ppm.person_id = pa.person_id
- join source_post p on p.post_id = ppm.post_id
- where pa.affiliated_corporate_entity_id = $1
+ select distinct post.post_id, post.visibility_code,
+ post.corporate_entity_id, post.created_at
+ from source_post post
+ where post.post_id in (
+ select mention.post_id
+ from person_affiliation affiliation
+ join combined_post_person_mention mention
+ on mention.person_id = affiliation.person_id
+ where affiliation.affiliated_corporate_entity_id = $1
+ union
+ select org_mention.post_id
+ from post_organization_mention org_mention
+ where org_mention.corporate_entity_id = $1
+ )
+ order by post.created_at, post.post_id
""",
entity_id,
)
return [str(row["post_id"]) for row in rows if can_see_post(row)]
+async def visible_team_mention_post_ids(
+ conn: asyncpg.Connection,
+ team_id: str,
+ can_see_post,
+) -> list[str]:
+ """Visible post ids supported by a cataloged team mention."""
+ rows = await conn.fetch(
+ """
+ select post.post_id, post.visibility_code, post.corporate_entity_id
+ from post_team_mention mention
+ join source_post post on post.post_id = mention.post_id
+ where mention.team_id = $1
+ order by post.created_at, post.post_id
+ """,
+ team_id,
+ )
+ return [str(row["post_id"]) for row in rows if can_see_post(row)]
+
async def load_visible_subgraph(
conn: asyncpg.Connection,
visible_post_ids: list[str],
) -> list[KnowledgeGraphEdgeSpec]:
- """Edges whose endpoints the account can already see via those posts."""
+ """Edges supported by at least one post the account may already see.
+
+ Person, team, and organization mention channels are independent. A
+ team-only or organization-only post must still walk (ADR 0018).
+ """
if not visible_post_ids:
return []
person_rows = await conn.fetch(
- "select distinct person_id from post_person_mention where post_id = any($1::uuid[])",
+ "select distinct person_id from combined_post_person_mention "
+ "where post_id = any($1::uuid[])",
visible_post_ids,
)
person_ids = [row["person_id"] for row in person_rows]
- if not person_ids:
+ team_rows = await conn.fetch(
+ "select distinct team_id from post_team_mention "
+ "where post_id = any($1::uuid[])",
+ visible_post_ids,
+ )
+ team_ids = [row["team_id"] for row in team_rows]
+ organization_rows = await conn.fetch(
+ "select distinct corporate_entity_id from post_organization_mention "
+ "where post_id = any($1::uuid[])",
+ visible_post_ids,
+ )
+ organization_ids = [row["corporate_entity_id"] for row in organization_rows]
+ if not person_ids and not team_ids and not organization_ids:
return []
rows = await conn.fetch(
"""
- select source_node_type_code, source_node_id,
- target_node_type_code, target_node_id,
- edge_type_code, edge_weight
- from knowledge_graph_edge
- where
+ select distinct edge.source_node_type_code, edge.source_node_id,
+ edge.target_node_type_code, edge.target_node_id,
+ edge.edge_type_code, edge.edge_weight
+ from knowledge_graph_edge edge
+ join knowledge_graph_edge_evidence evidence
+ on evidence.knowledge_graph_edge_id = edge.knowledge_graph_edge_id
+ and evidence.evidence_post_id = any($1::uuid[])
+ where
(
- edge_type_code = $3
+ edge.edge_type_code = $3
and (
- (source_node_type_code = $4 and source_node_id = any($1::uuid[]))
- or (target_node_type_code = $4 and target_node_id = any($1::uuid[]))
+ (edge.source_node_type_code = $4
+ and edge.source_node_id = any($1::uuid[]))
+ or
+ (edge.target_node_type_code = $4
+ and edge.target_node_id = any($1::uuid[]))
)
)
or (
- edge_type_code = $5
- and source_node_type_code = $6
- and target_node_type_code = $6
- and source_node_id = any($2::uuid[])
- and target_node_id = any($2::uuid[])
+ edge.edge_type_code = $5
+ and edge.source_node_type_code = $6
+ and edge.target_node_type_code = $6
+ and edge.source_node_id = any($2::uuid[])
+ and edge.target_node_id = any($2::uuid[])
)
or (
- edge_type_code = $7
+ edge.edge_type_code = $7
and (
- (source_node_type_code = $6 and source_node_id = any($2::uuid[]))
- or (target_node_type_code = $6 and target_node_id = any($2::uuid[]))
+ (edge.source_node_type_code = $6
+ and edge.source_node_id = any($2::uuid[]))
+ or
+ (edge.target_node_type_code = $6
+ and edge.target_node_id = any($2::uuid[]))
+ )
+ )
+ or (
+ edge.edge_type_code = $8
+ and (
+ (edge.source_node_type_code = $4
+ and edge.source_node_id = any($1::uuid[]))
+ or
+ (edge.target_node_type_code = $4
+ and edge.target_node_id = any($1::uuid[]))
+ or
+ (edge.source_node_type_code = $9
+ and edge.source_node_id = any($10::uuid[]))
+ or
+ (edge.target_node_type_code = $9
+ and edge.target_node_id = any($10::uuid[]))
+ )
+ )
+ or (
+ edge.edge_type_code = $11
+ and (
+ (edge.source_node_type_code = $9
+ and edge.source_node_id = any($10::uuid[]))
+ or
+ (edge.target_node_type_code = $9
+ and edge.target_node_id = any($10::uuid[]))
+ )
+ )
+ or (
+ edge.edge_type_code = $12
+ and (
+ (edge.source_node_type_code = $4
+ and edge.source_node_id = any($1::uuid[]))
+ or
+ (edge.target_node_type_code = $4
+ and edge.target_node_id = any($1::uuid[]))
+ or
+ (edge.source_node_type_code = $13
+ and edge.source_node_id = any($14::uuid[]))
+ or
+ (edge.target_node_type_code = $13
+ and edge.target_node_id = any($14::uuid[]))
)
)
""",
@@ -271,10 +432,16 @@ async def load_visible_subgraph(
EDGE_CO_MENTION,
NODE_PERSON,
EDGE_AFFILIATION,
+ EDGE_MENTION_TEAM,
+ NODE_TEAM,
+ team_ids,
+ EDGE_TEAM_AFFILIATION,
+ EDGE_MENTION_ORGANIZATION,
+ NODE_CORPORATE_ENTITY,
+ organization_ids,
)
return [edge_spec_from_row(row) for row in rows]
-
async def hydrate_related_nodes(
conn: asyncpg.Connection,
related: list[tuple[str, float]],
@@ -287,6 +454,7 @@ async def hydrate_related_nodes(
person_ids: list[str] = []
post_ids: list[str] = []
corp_ids: list[str] = []
+ team_ids: list[str] = []
parsed: list[tuple[str, str, float]] = []
for key, score in related:
node_type_code, node_id = parse_node_key(key)
@@ -297,6 +465,8 @@ async def hydrate_related_nodes(
post_ids.append(node_id)
elif node_type_code == NODE_CORPORATE_ENTITY:
corp_ids.append(node_id)
+ elif node_type_code == NODE_TEAM:
+ team_ids.append(node_id)
people = {
str(row["person_id"]): row
@@ -319,6 +489,17 @@ async def hydrate_related_nodes(
corp_ids,
)
} if corp_ids else {}
+ teams = {
+ str(row["team_id"]): row
+ for row in await conn.fetch(
+ "select team_id, team_name from cataloged_team where team_id = any($1::uuid[])",
+ team_ids,
+ )
+ } if team_ids else {}
+
+ side_labels = await labels_for_codes(
+ conn, [row["person_side_code"] for row in people.values()]
+ )
payload: list[dict[str, Any]] = []
for node_type_code, node_id, score in parsed:
@@ -329,12 +510,16 @@ async def hydrate_related_nodes(
**ontology_annotations(node_type_code),
}
if node_type_code == NODE_PERSON and node_id in people:
+ side = people[node_id]["person_side_code"]
item["label"] = people[node_id]["person_name"]
- item["person_side_code"] = people[node_id]["person_side_code"]
+ item["person_side_code"] = side
+ item["person_side_label"] = side_labels.get(side, side)
elif node_type_code == NODE_POST and node_id in posts:
item["label"] = posts[node_id]["post_title"]
elif node_type_code == NODE_CORPORATE_ENTITY and node_id in corps:
item["label"] = corps[node_id]["entity_name"]
+ elif node_type_code == NODE_TEAM and node_id in teams:
+ item["label"] = teams[node_id]["team_name"]
else:
continue
payload.append(item)
@@ -371,3 +556,12 @@ async def related_for_entity(
) -> list[dict[str, Any]]:
"""Run RWR from ``entity_id`` over the account's visible subgraph."""
return await related_for_start(conn, NODE_CORPORATE_ENTITY, entity_id, visible_post_ids)
+
+
+async def related_for_team(
+ conn: asyncpg.Connection,
+ team_id: str,
+ visible_post_ids: list[str],
+) -> list[dict[str, Any]]:
+ """Run RWR from ``team_id`` over the account's visible subgraph."""
+ return await related_for_start(conn, NODE_TEAM, team_id, visible_post_ids)
diff --git a/backend/app/main.py b/backend/app/main.py
index c69609a93..adb7a20a8 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -19,8 +19,11 @@
from __future__ import annotations
+import asyncio
from contextlib import asynccontextmanager
+from datetime import datetime
from typing import Any
+from uuid import UUID
import asyncpg
import redis.asyncio as redis
@@ -37,10 +40,18 @@
NullEntityRelationshipClient,
)
from lineageweave.image_content import orchestrator_vision_client
+from lineageweave.corporate_hierarchy_inference import (
+ ContextualOrchestratorHierarchyInferenceClient,
+ NullCorporateHierarchyInferenceClient,
+)
from lineageweave.keyman_extraction import (
ContextualOrchestratorKeymanExtractionClient,
NullKeymanExtractionClient,
)
+from lineageweave.organization_name_resolution import (
+ ContextualOrchestratorOrganizationNameResolutionClient,
+ NullOrganizationNameResolutionClient,
+)
from lineageweave.post_chat import (
ContextualOrchestratorPostChatClient,
NullPostChatClient,
@@ -55,6 +66,12 @@
from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient
from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient
+from backend.app.analysis_run_ingestion import (
+ AnalysisRunCreateError,
+ create_pending_analysis_run,
+ fetch_visible_analysis_run,
+ fetch_visible_analysis_runs,
+)
from backend.app.activity_stream import (
create_valkey_client,
get_valkey,
@@ -96,10 +113,14 @@
fetch_post_keymen,
labels_for_codes,
person_exists,
+ persist_edges_for_post,
related_for_entity,
related_for_person,
+ related_for_team,
+ team_exists,
visible_affiliation_post_ids,
visible_mention_post_ids,
+ visible_team_mention_post_ids,
)
from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph
from backend.app.post_chat_ingestion import (
@@ -178,6 +199,26 @@ def _relation_verification_client():
return SearxngRelationVerificationClient(base_url=settings.searxng_base_url)
+def _organization_name_resolution_client():
+ """Live orchestrator client when configured; otherwise the unavailable null."""
+ settings = load_settings()
+ if not (settings.orchestrator_base_url and settings.orchestrator_api_key):
+ return NullOrganizationNameResolutionClient()
+ return ContextualOrchestratorOrganizationNameResolutionClient(
+ base_url=settings.orchestrator_base_url, api_key=settings.orchestrator_api_key
+ )
+
+
+def _corporate_hierarchy_inference_client():
+ """Live orchestrator client when configured; otherwise the unavailable null."""
+ settings = load_settings()
+ if not (settings.orchestrator_base_url and settings.orchestrator_api_key):
+ return NullCorporateHierarchyInferenceClient()
+ return ContextualOrchestratorHierarchyInferenceClient(
+ base_url=settings.orchestrator_base_url, api_key=settings.orchestrator_api_key
+ )
+
+
def _post_summary_client():
"""Live orchestrator client when configured; otherwise the unavailable null."""
settings = load_settings()
@@ -435,6 +476,34 @@ async def read_related_corporate_entity(
}
+@app.get("/api/teams/{team_id}/related")
+async def read_related_team(
+ team_id: str,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """RWR-ranked related nodes from one cataloged team, hiding unseen posts."""
+ _require_post_read(account)
+ async with pool.acquire() as conn:
+ if not await team_exists(conn, team_id):
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "team not found")
+ visible_post_ids = await visible_team_mention_post_ids(
+ conn, team_id, lambda row: _can_see_post(account, row)
+ )
+ if not visible_post_ids:
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this team")
+ team = await conn.fetchrow(
+ "select team_id, team_name from cataloged_team where team_id = $1",
+ team_id,
+ )
+ related = await related_for_team(conn, team_id, visible_post_ids)
+ return {
+ "team_id": str(team["team_id"]),
+ "team_name": team["team_name"],
+ "related": related,
+ }
+
+
@app.get("/api/posts/{post_id}/counterparties")
async def read_post_counterparties(
post_id: str,
@@ -553,17 +622,28 @@ async def extract_post_keymen(
# literal text either blows the token budget or is silently
# ignored (see lineageweave/post_content_normalization.py).
post_body = normalize_post_body(raw_body, vision_client=_vision_client()).text
+ mentions = await ingest_post_keymen(
+ conn,
+ keyman_client,
+ post_id,
+ post["post_title"],
+ post_body,
+ resolution_client=_organization_name_resolution_client(),
+ verification_client=_relation_verification_client(),
+ hierarchy_inference_client=_corporate_hierarchy_inference_client(),
+ persist_graph=False,
+ )
+ organization_names = sorted(
+ {name for mention in mentions for name in mention.affiliated_organization_names}
+ )
+ # relationship_client is gated by the same settings check as
+ # keyman_client above (both read ORCHESTRATOR_BASE_URL/_API_KEY),
+ # so reaching here means it is available too.
+ relationships = await ingest_post_entity_relationships(
+ conn, relationship_client, post_id, post["post_title"], post_body, organization_names
+ )
async with conn.transaction():
- mentions = await ingest_post_keymen(conn, keyman_client, post_id, post["post_title"], post_body)
- organization_names = sorted(
- {name for mention in mentions for name in mention.affiliated_organization_names}
- )
- # relationship_client is gated by the same settings check as
- # keyman_client above (both read ORCHESTRATOR_BASE_URL/_API_KEY),
- # so reaching here means it is available too.
- relationships = await ingest_post_entity_relationships(
- conn, relationship_client, post_id, post["post_title"], post_body, organization_names
- )
+ await persist_edges_for_post(conn, post_id)
return {
"post_id": str(post["post_id"]),
"extracted_count": len(mentions),
@@ -814,8 +894,17 @@ async def read_post_summary(
)
body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id)
normalized_body = normalize_post_body(body_row["post_body"], vision_client=_vision_client()).text
- summary = client.summarize(post["post_title"], normalized_body)
- return await persist_post_summary(conn, post_id, summary)
+ summary = await asyncio.to_thread(
+ client.summarize, post["post_title"], normalized_body
+ )
+ return await persist_post_summary(
+ conn,
+ post_id,
+ summary,
+ post_body=normalized_body,
+ hierarchy_inference_client=_corporate_hierarchy_inference_client(),
+ verification_client=_relation_verification_client(),
+ )
class ChatRequest(BaseModel):
@@ -1098,6 +1187,99 @@ async def derive_post_commitment(
return {"post_id": str(post["post_id"]), "has_commitment": True, "ticket": ticket}
+@app.get("/api/analysis-runs")
+async def list_analysis_runs(
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> 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.
+ """
+ _require_post_read(account)
+ async with pool.acquire() as conn:
+ runs = await fetch_visible_analysis_runs(
+ conn,
+ account.user_account_id,
+ list(account.corporate_entity_ids),
+ )
+ return {"analysis_runs": runs}
+
+
+class CreateAnalysisRunRequest(BaseModel):
+ """JSON body for ``POST /api/analysis-runs``.
+
+ Omitting ``corporate_entity_id`` uses the account's sole affiliation.
+ Reconstruction and TEPP execution stay later slices; this write
+ records Pending only.
+ """
+
+ run_kind_code: str = "analysis_run_lineage"
+ scope_kind_code: str = "analysis_scope_corporate_entity"
+ corporate_entity_id: str | None = None
+ knowledge_cutoff: datetime | None = None
+ idempotency_key: str
+
+
+@app.post("/api/analysis-runs", status_code=status.HTTP_201_CREATED)
+async def create_analysis_run(
+ request: CreateAnalysisRunRequest,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """Record a Pending analysis run on an authorized cutoff capture.
+
+ post_read is enough: the caller requests a run of a corp they
+ already walk. The payload is the same authorized detail as GET.
+ Hidden scopes 404. A matching idempotent retry returns the same run.
+ """
+ _require_post_read(account)
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ try:
+ created = await create_pending_analysis_run(
+ conn,
+ account_id=account.user_account_id,
+ affiliated_entity_ids=list(account.corporate_entity_ids),
+ run_kind_code=request.run_kind_code,
+ scope_kind_code=request.scope_kind_code,
+ corporate_entity_id=request.corporate_entity_id,
+ knowledge_cutoff=request.knowledge_cutoff,
+ idempotency_key=request.idempotency_key,
+ )
+ except AnalysisRunCreateError as exc:
+ raise HTTPException(exc.status_code, exc.detail) from exc
+ return created
+
+
+@app.get("/api/analysis-runs/{analysis_run_id}")
+async def read_analysis_run(
+ analysis_run_id: str,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """One authorized analysis-run projection, or 404 when hidden.
+
+ Detail adds the labeled status history. Hidden runs never leak events.
+ """
+ _require_post_read(account)
+ try:
+ UUID(analysis_run_id)
+ except ValueError:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found") from None
+ async with pool.acquire() as conn:
+ run = await fetch_visible_analysis_run(
+ conn,
+ analysis_run_id,
+ account.user_account_id,
+ list(account.corporate_entity_ids),
+ )
+ if run is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found")
+ return run
+
+
@app.get("/api/calendar")
async def read_calendar(
account: CurrentAccount = Depends(get_current_account),
diff --git a/backend/app/organization_name_resolution_ingestion.py b/backend/app/organization_name_resolution_ingestion.py
new file mode 100644
index 000000000..9300586c4
--- /dev/null
+++ b/backend/app/organization_name_resolution_ingestion.py
@@ -0,0 +1,73 @@
+
+"""Cache and persist verified organization-name normalization."""
+
+from __future__ import annotations
+
+import asyncio
+
+import asyncpg
+
+from lineageweave.organization_name_resolution import (
+ OrganizationNameResolutionClient,
+ resolve_and_verify_organization_name,
+)
+from lineageweave.relation_verification import (
+ STATUS_CORROBORATED,
+ RelationVerificationClient,
+)
+
+
+async def resolve_organization_name(
+ conn: asyncpg.Connection,
+ resolution_client: OrganizationNameResolutionClient,
+ verification_client: RelationVerificationClient,
+ raw_name: str,
+ context_text: str,
+) -> str:
+ """Return the corroborated canonical name, otherwise ``raw_name``.
+
+ Synchronous network adapters run in a worker thread so this async
+ ingestion path does not block unrelated requests.
+ """
+ cached = await conn.fetchrow(
+ "select resolved_organization_name, verification_status_code "
+ "from organization_name_resolution where raw_organization_name = $1",
+ raw_name,
+ )
+ if cached is not None:
+ if cached["verification_status_code"] == STATUS_CORROBORATED:
+ return cached["resolved_organization_name"]
+ return raw_name
+ if not resolution_client.available:
+ return raw_name
+
+ resolution = await asyncio.to_thread(
+ resolve_and_verify_organization_name,
+ raw_name,
+ context_text,
+ resolution_client,
+ verification_client,
+ )
+ if resolution is None:
+ return raw_name
+
+ await conn.execute(
+ """
+ insert into organization_name_resolution
+ (raw_organization_name, resolved_organization_name,
+ verification_status_code, verification_evidence_url)
+ values ($1, $2, $3, $4)
+ on conflict (raw_organization_name) do update set
+ resolved_organization_name = excluded.resolved_organization_name,
+ verification_status_code = excluded.verification_status_code,
+ verification_evidence_url = excluded.verification_evidence_url,
+ resolved_at = now()
+ """,
+ resolution.raw_organization_name,
+ resolution.resolved_organization_name,
+ resolution.verification_status_code,
+ resolution.verification_evidence_url,
+ )
+ if resolution.verification_status_code == STATUS_CORROBORATED:
+ return resolution.resolved_organization_name
+ return raw_name
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 2f9fe77ff..794faa10d 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -66,13 +66,14 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked
# Discover them here first: every post that mentions any person this
# post itself mentions, then load the subgraph over that expanded set.
person_rows = await conn.fetch(
- "select distinct person_id from post_person_mention where post_id = $1", post_id
+ "select distinct person_id from combined_post_person_mention where post_id = $1", post_id
)
person_ids = [row["person_id"] for row in person_rows]
sibling_post_ids = [post_id]
if person_ids:
sibling_rows = await conn.fetch(
- "select distinct post_id from post_person_mention where person_id = any($1::uuid[])",
+ "select distinct post_id from combined_post_person_mention "
+ "where person_id = any($1::uuid[])",
person_ids,
)
sibling_post_ids = list({str(row["post_id"]) for row in sibling_rows} | {post_id})
diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py
index 7e7569d68..3febf9b21 100644
--- a/backend/app/post_summary_ingestion.py
+++ b/backend/app/post_summary_ingestion.py
@@ -1,4 +1,28 @@
-"""Persist and load the popup's Korean summary / key events / R&R."""
+"""Persist and load the popup's Korean summary / key events / R&R.
+
+ADR 0009 / 0019: an R&R actor is not just per-post free text -- when it
+is a team or organization, it is resolved to a shared catalog identity
+(``cataloged_team`` / ``corporate_entity``) stored on the role row and
+a Knowledge Graph mention edge is written, so the same "설계팀" or
+organization named across two posts becomes one linkable node. Fetch
+never reconstructs that id by ``entity_name``; that column is not unique.
+A person actor is opportunistically joined to an *existing*
+``cataloged_person`` row by name when Keyman extraction has already
+cataloged that name. The R&R evidence is written to
+``post_summary_person_mention`` rather than Keyman's
+``post_person_mention`` so either extractor can replace its own result
+without leaving or deleting the other's evidence.
+
+ADR 0010: an organization actor's name is resolved via
+``get_or_create_corporate_entity`` -- similarity matching first, then
+an LLM-proposed, search-corroborated hierarchy placement before
+creating a real new row, so a real dataset's first mention of a
+counterparty organization actually populates the corporate hierarchy
+tree instead of staying permanently unresolved. Inference,
+verification, and the short advisory-lock creation transaction finish
+before the summary-replacement transaction begins; slow external work
+therefore cannot extend the lock or the atomic replacement window.
+"""
from __future__ import annotations
@@ -6,12 +30,40 @@
import asyncpg
+from lineageweave.corporate_hierarchy_inference import (
+ CorporateHierarchyInferenceClient,
+ NullCorporateHierarchyInferenceClient,
+)
from lineageweave.fixtures import fixture_thread_cast
-from lineageweave.post_summary import PostSummary, RoleResponsibility
+from lineageweave.knowledge_graph import NODE_CORPORATE_ENTITY, NODE_TEAM
+from lineageweave.ontology import ontology_annotations
+from lineageweave.post_summary import (
+ ACTOR_TYPE_ORGANIZATION,
+ ACTOR_TYPE_PERSON,
+ ACTOR_TYPE_TEAM,
+ PostSummary,
+ RoleResponsibility,
+)
+from lineageweave.relation_verification import (
+ NullRelationVerificationClient,
+ RelationVerificationClient,
+)
+
+from .corporate_entity_ingestion import get_or_create_corporate_entity
+from .keyman_ingestion import _load_corporate_entity_candidates
+from .knowledge_graph import persist_edges_for_post
+from .team_ingestion import upsert_team
-async def fetch_persisted_summary(conn: asyncpg.Connection, post_id: str) -> dict[str, Any] | None:
- """Return the stored summary payload, or None when none has been written."""
+async def fetch_persisted_summary(
+ conn: asyncpg.Connection, post_id: str
+) -> dict[str, Any] | None:
+ """Return the stored summary payload, or None when none has been written.
+
+ ``catalog_node_id`` comes from the role row's catalog foreign keys
+ (ADR 0019). This function does not join ``corporate_entity`` by
+ ``entity_name``.
+ """
header = await conn.fetchrow(
"select korean_summary from post_summary_result where post_id = $1",
post_id,
@@ -23,23 +75,127 @@ async def fetch_persisted_summary(conn: asyncpg.Connection, post_id: str) -> dic
post_id,
)
roles = await conn.fetch(
- "select person_name, responsibility from post_summary_role "
- "where post_id = $1 order by person_name",
+ """
+ select role.actor_name, role.responsibility, role.actor_type_code,
+ role.affiliated_organization_name,
+ role.cataloged_team_id,
+ role.cataloged_corporate_entity_id
+ from post_summary_role role
+ where role.post_id = $1
+ order by role.actor_name
+ """,
post_id,
)
+ payload_roles: list[dict[str, Any]] = []
+ for row in roles:
+ catalog_node_id = None
+ catalog_node_type_code = None
+ if row["cataloged_team_id"] is not None:
+ catalog_node_id = str(row["cataloged_team_id"])
+ catalog_node_type_code = NODE_TEAM
+ elif row["cataloged_corporate_entity_id"] is not None:
+ catalog_node_id = str(row["cataloged_corporate_entity_id"])
+ catalog_node_type_code = NODE_CORPORATE_ENTITY
+ payload_roles.append(
+ {
+ "actor_name": row["actor_name"],
+ "responsibility": row["responsibility"],
+ "actor_type_code": row["actor_type_code"],
+ "affiliated_organization_name": row["affiliated_organization_name"],
+ "catalog_node_id": catalog_node_id,
+ "catalog_node_type_code": catalog_node_type_code,
+ **ontology_annotations(row["actor_type_code"]),
+ }
+ )
return {
"post_id": post_id,
"korean_summary": header["korean_summary"],
"key_events": [row["event_text"] for row in events],
- "roles_and_responsibilities": [
- {"person_name": row["person_name"], "responsibility": row["responsibility"]}
- for row in roles
- ],
+ "roles_and_responsibilities": payload_roles,
}
-async def persist_post_summary(conn: asyncpg.Connection, post_id: str, summary: PostSummary) -> dict[str, Any]:
- """Replace the stored summary for ``post_id`` and return the public payload."""
+async def persist_post_summary(
+ conn: asyncpg.Connection,
+ post_id: str,
+ summary: PostSummary,
+ *,
+ post_body: str | None = None,
+ hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,
+ verification_client: RelationVerificationClient | None = None,
+) -> dict[str, Any]:
+ """Replace the stored summary for ``post_id`` and return the public payload.
+
+ ``post_body`` is the context an organization-actor hierarchy proposal
+ is inferred from (ADR 0010); it falls back to the summary's own Korean
+ text when not given. The pluggable clients default to unavailable Null
+ clients, so an organization actor then only resolves against an existing
+ ``corporate_entity``.
+
+ Organization inference, verification, and any lock-protected catalog
+ creation complete before the atomic summary transaction. The catalog is
+ an idempotent shared identity registry; keeping that enrichment separate
+ prevents network latency and ``pg_advisory_xact_lock`` from extending the
+ summary replacement transaction while all post-owned rows still commit or
+ roll back together.
+ """
+ hierarchy_inference_client = (
+ hierarchy_inference_client or NullCorporateHierarchyInferenceClient()
+ )
+ verification_client = verification_client or NullRelationVerificationClient()
+
+ context_text = post_body if post_body is not None else summary.korean_summary
+ candidates = (
+ await _load_corporate_entity_candidates(conn)
+ if summary.roles_and_responsibilities
+ else []
+ )
+ resolved_organization_ids: dict[int, str] = {}
+ for role_index, role in enumerate(summary.roles_and_responsibilities):
+ if role.actor_type_code != ACTOR_TYPE_ORGANIZATION:
+ continue
+ corporate_entity_id = await get_or_create_corporate_entity(
+ conn,
+ role.actor_name,
+ context_text,
+ hierarchy_inference_client,
+ verification_client,
+ candidates,
+ )
+ if corporate_entity_id is not None:
+ resolved_organization_ids[role_index] = corporate_entity_id
+
+ async with conn.transaction():
+ await _replace_summary_projection(
+ conn,
+ post_id,
+ summary,
+ candidates,
+ resolved_organization_ids,
+ )
+
+ payload = await fetch_persisted_summary(conn, post_id)
+ if payload is None:
+ raise RuntimeError("persist_post_summary wrote no row")
+ return payload
+
+
+async def _replace_summary_projection(
+ conn: asyncpg.Connection,
+ post_id: str,
+ summary: PostSummary,
+ candidates: list[Any],
+ resolved_organization_ids: dict[int, str],
+) -> None:
+ """Write one atomic replacement using pre-resolved shared identities."""
+ # Summary replacement owns only R&R projections. Keyman mentions remain
+ # independent and are combined only by the graph read/derivation view.
+ await conn.execute(
+ "delete from post_summary_person_mention where post_id = $1",
+ post_id,
+ )
+ await conn.execute("delete from post_team_mention where post_id = $1", post_id)
+ await conn.execute("delete from post_organization_mention where post_id = $1", post_id)
await conn.execute("delete from post_summary_result where post_id = $1", post_id)
await conn.execute(
"insert into post_summary_result (post_id, korean_summary) values ($1, $2)",
@@ -48,22 +204,70 @@ async def persist_post_summary(conn: asyncpg.Connection, post_id: str, summary:
)
for ordinal, event_text in enumerate(summary.key_events):
await conn.execute(
- "insert into post_summary_event (post_id, event_ordinal, event_text) values ($1, $2, $3)",
+ "insert into post_summary_event (post_id, event_ordinal, event_text) "
+ "values ($1, $2, $3)",
post_id,
ordinal,
event_text,
)
- for role in summary.roles_and_responsibilities:
+ # ADR 0009 / 0019: resolve catalog identity before writing the role
+ # row so fetch never reconstructs it by a non-unique name.
+ for role_index, role in enumerate(summary.roles_and_responsibilities):
+ cataloged_team_id = None
+ cataloged_corporate_entity_id = None
+ if role.actor_type_code == ACTOR_TYPE_TEAM:
+ cataloged_team_id = await upsert_team(
+ conn,
+ role.actor_name,
+ role.affiliated_organization_name,
+ candidates,
+ )
+ elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION:
+ cataloged_corporate_entity_id = resolved_organization_ids.get(
+ role_index
+ )
await conn.execute(
- "insert into post_summary_role (post_id, person_name, responsibility) values ($1, $2, $3)",
+ "insert into post_summary_role "
+ "(post_id, actor_name, responsibility, actor_type_code, "
+ "affiliated_organization_name, cataloged_team_id, "
+ "cataloged_corporate_entity_id) values "
+ "($1, $2, $3, $4, $5, $6, $7)",
post_id,
- role.person_name,
+ role.actor_name,
role.responsibility,
+ role.actor_type_code,
+ role.affiliated_organization_name,
+ cataloged_team_id,
+ cataloged_corporate_entity_id,
)
- payload = await fetch_persisted_summary(conn, post_id)
- if payload is None:
- raise RuntimeError("persist_post_summary wrote no row")
- return payload
+ if cataloged_team_id is not None:
+ await conn.execute(
+ "insert into post_team_mention (post_id, team_id) values ($1, $2) "
+ "on conflict do nothing",
+ post_id,
+ cataloged_team_id,
+ )
+ elif cataloged_corporate_entity_id is not None:
+ await conn.execute(
+ "insert into post_organization_mention "
+ "(post_id, corporate_entity_id) values ($1, $2) "
+ "on conflict do nothing",
+ post_id,
+ cataloged_corporate_entity_id,
+ )
+ 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",
+ role.actor_name,
+ )
+ if person_row is not None:
+ await conn.execute(
+ "insert into post_summary_person_mention (post_id, person_id) "
+ "values ($1, $2) on conflict do nothing",
+ post_id,
+ str(person_row["person_id"]),
+ )
+ await persist_edges_for_post(conn, post_id)
def seeded_demo_summary() -> PostSummary:
@@ -75,8 +279,21 @@ def seeded_demo_summary() -> PostSummary:
),
key_events=("출하 지연 후속 연락",),
roles_and_responsibilities=(
- RoleResponsibility(person_name="Ada West", responsibility="일정 확인 후속"),
- RoleResponsibility(person_name="Priya Nair", responsibility="고객 측 수신"),
+ RoleResponsibility(
+ actor_name="Ada West",
+ responsibility="일정 확인 후속",
+ affiliated_organization_name="Demo Corp",
+ ),
+ RoleResponsibility(
+ actor_name="Priya Nair",
+ responsibility="고객 측 수신",
+ affiliated_organization_name="Northridge Grid",
+ ),
+ RoleResponsibility(
+ actor_name="당사",
+ responsibility="출하 일정 확정",
+ actor_type_code=ACTOR_TYPE_ORGANIZATION,
+ ),
),
)
@@ -108,7 +325,11 @@ def _roles_for_fixture(post_title: str) -> tuple[RoleResponsibility, ...]:
if cast is None or not cast.person_names:
return ()
return tuple(
- RoleResponsibility(person_name=name, responsibility=responsibility)
+ RoleResponsibility(
+ actor_name=name,
+ responsibility=responsibility,
+ affiliated_organization_name=_FIXTURE_ROLE_AFFILIATION.get(name),
+ )
for name in cast.person_names
if (responsibility := _FIXTURE_ROLE_RESPONSIBILITY.get(name))
)
@@ -120,8 +341,15 @@ def _roles_for_fixture(post_title: str) -> tuple[RoleResponsibility, ...]:
"Jordan Hale": "사양 검토",
}
+_FIXTURE_ROLE_AFFILIATION = {
+ "Ada West": "Demo Corp",
+ "Priya Nair": "Northridge Grid",
+ "Jordan Hale": "Westfield Power",
+}
+
def _summary(korean: str, *events: str) -> PostSummary:
+ """Create one compact synthetic fixture summary."""
return PostSummary(korean_summary=korean, key_events=events)
diff --git a/backend/app/team_ingestion.py b/backend/app/team_ingestion.py
new file mode 100644
index 000000000..2d0c8787d
--- /dev/null
+++ b/backend/app/team_ingestion.py
@@ -0,0 +1,48 @@
+
+"""Resolve an R&R team actor to one shared cross-post identity."""
+
+from __future__ import annotations
+
+import asyncpg
+
+from lineageweave.corporate_hierarchy_resolution import (
+ CorporateEntityCandidate,
+ resolve_corporate_entity,
+)
+
+
+async def upsert_team(
+ conn: asyncpg.Connection,
+ team_name: str,
+ affiliated_organization_name: str | None,
+ candidates: list[CorporateEntityCandidate],
+) -> str:
+ """Atomically return the unique team identity for the pair.
+
+ ``UNIQUE NULLS NOT DISTINCT`` makes NULL affiliations participate
+ in the same conflict rule. One upsert removes the prior
+ read-then-insert race.
+ """
+ corporate_entity_id = (
+ resolve_corporate_entity(affiliated_organization_name, candidates)
+ if affiliated_organization_name
+ else None
+ )
+ row = await conn.fetchrow(
+ """
+ insert into cataloged_team
+ (team_name, affiliated_organization_name,
+ affiliated_corporate_entity_id)
+ values ($1, $2, $3)
+ on conflict (team_name, affiliated_organization_name) do update set
+ affiliated_corporate_entity_id = coalesce(
+ excluded.affiliated_corporate_entity_id,
+ cataloged_team.affiliated_corporate_entity_id
+ )
+ returning team_id
+ """,
+ team_name,
+ affiliated_organization_name,
+ corporate_entity_id,
+ )
+ return str(row["team_id"])
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 74ab44701..21c71bc9a 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -22,6 +22,7 @@
import redis
from lineageweave.http_client import HttpClientError, get_json, post_form
+from lineageweave.knowledge_graph import knowledge_graph_edges_for_post
_POSTGRES_ADMIN_DSN = os.environ.get(
"LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave"
@@ -30,6 +31,7 @@
_VALKEY_URL = os.environ.get("LINEAGEWEAVE_TEST_VALKEY_URL", "redis://localhost:16379/0")
_REALM = "lineageweave-demo"
_MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql"
+_REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql"
def _postgres_available() -> bool:
@@ -112,10 +114,12 @@ def seeded_db(demo_analyst_token):
try:
with conn.cursor() as cur:
cur.execute(_MIGRATION_PATH.read_text())
+ cur.execute(_REGISTRY_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
"('corporate_entity_level', 'company', 'Company'), "
+ "('corporate_entity_level', 'plant', 'Plant'), "
"('post_visibility', 'public', 'Public'), "
"('post_visibility', 'private', 'Private'), "
"('voc_type', 'voc', 'Voice of Customer'), "
@@ -125,9 +129,13 @@ def seeded_db(demo_analyst_token):
"('node_type', 'node_person', 'Person'), "
"('node_type', 'node_corporate_entity', 'Corporate entity'), "
"('node_type', 'node_post', 'Post'), "
+ "('node_type', 'node_team', 'Team'), "
"('edge_type', 'edge_mention', 'Mentioned in'), "
"('edge_type', 'edge_affiliation', 'Affiliated with'), "
"('edge_type', 'edge_co_mention', 'Co-mentioned'), "
+ "('edge_type', 'edge_mention_team', 'Team mentioned in'), "
+ "('edge_type', 'edge_team_affiliation', 'Team affiliated with'), "
+ "('edge_type', 'edge_mention_organization', 'Organization mentioned in'), "
"('entity_relationship_type', 'rel_voc', 'Voice of Customer'), "
"('entity_relationship_type', 'rel_vom', 'Voice of Market'), "
"('entity_relationship_type', 'rel_vop', 'Voice of Partner'), "
@@ -142,7 +150,10 @@ def seeded_db(demo_analyst_token):
"('relation_verification_status', 'verify_uncorroborated', 'No corroborating evidence found'), "
"('evaluation_criterion', 'general_sentiment_positive', 'Constructive stance'), "
"('evaluation_criterion', 'general_sentiment_negative', 'Negative stance'), "
- "('evaluation_criterion', 'sales_lead_specificity', 'Sales-lead specificity')"
+ "('evaluation_criterion', 'sales_lead_specificity', 'Sales-lead specificity'), "
+ "('prov_agent_type', 'prov_person', 'Person'), "
+ "('prov_agent_type', 'prov_organization', 'Organization'), "
+ "('prov_agent_type', 'prov_team', 'Team')"
)
cur.execute(
"insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) "
@@ -179,16 +190,124 @@ def seeded_db(demo_analyst_token):
"insert into role_permission (access_role_id, permission_code) values (%s, 'post_read')",
(role_id,),
)
+
+ def _seed_analysis_run(
+ digest: str,
+ idempotency_key: str,
+ requester_id,
+ scope_kind: str,
+ corp_id=None,
+ ) -> str:
+ 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
+ """,
+ (digest,),
+ )
+ snapshot_id = cur.fetchone()[0]
+ cur.execute(
+ """
+ insert into analysis_source_count
+ (analysis_source_snapshot_id, count_type_code, count_value)
+ values (%s, 'analysis_count_document', 3)
+ """,
+ (snapshot_id,),
+ )
+ 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, %s,
+ '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s,
+ '2026-01-12T12:30:00Z')
+ returning analysis_run_id
+ """,
+ (snapshot_id, idempotency_key, requester_id, "b" * 64, "c" * 40),
+ )
+ run_id = str(cur.fetchone()[0])
+ if scope_kind == "analysis_scope_corporate_entity":
+ cur.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code, corporate_entity_id)
+ values (%s, %s, %s)
+ """,
+ (run_id, scope_kind, corp_id),
+ )
+ else:
+ cur.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code)
+ values (%s, %s)
+ """,
+ (run_id, scope_kind),
+ )
+ for ordinal, status, occurred in (
+ (1, "analysis_status_pending", "2026-01-12T12:31:00Z"),
+ (2, "analysis_status_running", "2026-01-12T12:32:00Z"),
+ (3, "analysis_status_succeeded", "2026-01-12T12:33:00Z"),
+ ):
+ cur.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at)
+ values (%s, %s, %s, %s)
+ """,
+ (run_id, ordinal, status, occurred),
+ )
+ return run_id
+
+ cur.execute(
+ "insert into user_account (external_subject_id, display_name, email_address) "
+ "values (%s, 'Other Analyst', 'other.analyst@example.test') returning user_account_id",
+ (f"other-{uuid.uuid4()}",),
+ )
+ other_account_id = cur.fetchone()[0]
+ visible_run_id = _seed_analysis_run(
+ "a" * 64,
+ "visible-own-corp",
+ account_id,
+ "analysis_scope_corporate_entity",
+ own_corp_id,
+ )
+ hidden_run_id = _seed_analysis_run(
+ "d" * 64,
+ "hidden-other-corp",
+ other_account_id,
+ "analysis_scope_corporate_entity",
+ other_corp_id,
+ )
+ hidden_all_visible_id = _seed_analysis_run(
+ "e" * 64,
+ "hidden-all-visible",
+ other_account_id,
+ "analysis_scope_all_visible",
+ )
cur.execute(
"insert into account_role_assignment (user_account_id, access_role_id) values (%s, %s)",
(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-10T12: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])
@@ -201,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")
+ late_own_private_post_id = _insert_post(
+ "Late own-corp private post",
+ own_corp_id,
+ "private",
+ "A follow-up written after the January 2026 run cutoff.",
+ created_at="2026-01-20T12:00:00Z",
+ )
cur.execute(
"insert into cataloged_person (person_name, person_side_code) values "
@@ -286,10 +412,14 @@ def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: st
"own_corp_id": str(own_corp_id),
"other_corp_id": str(other_corp_id),
"own_private_post_id": own_private_post_id,
+ "late_own_private_post_id": late_own_private_post_id,
"other_private_post_id": other_private_post_id,
"our_person_id": our_person_id,
"counterpart_person_id": counterpart_person_id,
"hidden_person_id": hidden_person_id,
+ "visible_run_id": visible_run_id,
+ "hidden_run_id": hidden_run_id,
+ "hidden_all_visible_id": hidden_all_visible_id,
}
finally:
conn.close()
@@ -312,6 +442,137 @@ def client(seeded_db):
yield test_client
+def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Demo analyst sees the Test Corp run, never the Other Corp or outsider run."""
+ listed = client.get("/api/analysis-runs", headers={"Authorization": f"Bearer {demo_analyst_token}"})
+ assert listed.status_code == 200
+ runs = listed.json()["analysis_runs"]
+ ids = {run["analysis_run_id"] for run in runs}
+ assert seeded_db["visible_run_id"] in ids
+ assert seeded_db["hidden_run_id"] not in ids
+ assert seeded_db["hidden_all_visible_id"] not in ids
+ visible = next(run for run in runs if run["analysis_run_id"] == seeded_db["visible_run_id"])
+ assert visible["run_kind_label"] == "Lineage reconstruction"
+ assert visible["status_label"] == "Succeeded"
+ assert visible["scope_kind_label"] == "Corporate entity"
+ assert visible["scope_entity_name"] == "Test Corp"
+ assert visible["source_counts"] == [
+ {
+ "count_type_code": "analysis_count_document",
+ "count_type_label": "Documents",
+ "count_value": 3,
+ }
+ ]
+ dumped = str(visible)
+ assert "postgresql://" not in dumped
+ assert "select " not in dumped.lower()
+ assert "status_history" not in visible
+
+ detail = client.get(
+ f"/api/analysis-runs/{seeded_db['visible_run_id']}",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert detail.status_code == 200
+ body = detail.json()
+ assert body["configuration_schema_version"] == "lineage-run-v1"
+ assert "snapshot_sha256" not in body
+ history = body["status_history"]
+ assert [event["status_label"] for event in history] == [
+ "Pending",
+ "Running",
+ "Succeeded",
+ ]
+ assert [event["occurred_at"][:16] for event in history] == [
+ "2026-01-12T12:31",
+ "2026-01-12T12:32",
+ "2026-01-12T12:33",
+ ]
+ 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 "postgresql://" not in str(body)
+ assert "visible_posts" not in visible
+
+ hidden = client.get(
+ f"/api/analysis-runs/{seeded_db['hidden_run_id']}",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert hidden.status_code == 404
+
+ unauthenticated = client.get("/api/analysis-runs")
+ assert unauthenticated.status_code == 401
+
+
+def test_create_analysis_run_records_pending_without_inventing_a_score(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """POST /api/analysis-runs writes Pending on the authorized cutoff bag."""
+ created = client.post(
+ "/api/analysis-runs",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ json={
+ "run_kind_code": "analysis_run_lineage",
+ "corporate_entity_id": seeded_db["own_corp_id"],
+ "idempotency_key": "buyer-create-2026-w02",
+ },
+ )
+ assert created.status_code == 201
+ body = created.json()
+ assert body["run_kind_label"] == "Lineage reconstruction"
+ assert body["status_label"] == "Pending"
+ assert body["status_history"][0]["status_label"] == "Pending"
+ assert all(event["status_label"] != "Succeeded" for event in body["status_history"])
+ titles = {post["post_title"] for post in body["visible_posts"]}
+ assert "Own-corp private post" in titles
+ assert "Other-corp private post" not in titles
+ assert "theta" not in str(body).lower()
+ assert "postgresql://" not in str(body)
+
+ replay = client.post(
+ "/api/analysis-runs",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ json={
+ "run_kind_code": "analysis_run_lineage",
+ "corporate_entity_id": seeded_db["own_corp_id"],
+ "idempotency_key": "buyer-create-2026-w02",
+ },
+ )
+ assert replay.status_code == 201
+ assert replay.json()["analysis_run_id"] == body["analysis_run_id"]
+
+ conflict = client.post(
+ "/api/analysis-runs",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ json={
+ "run_kind_code": "analysis_run_tepp",
+ "corporate_entity_id": seeded_db["own_corp_id"],
+ "idempotency_key": "buyer-create-2026-w02",
+ },
+ )
+ assert conflict.status_code == 409
+
+ hidden = client.post(
+ "/api/analysis-runs",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ json={
+ "run_kind_code": "analysis_run_lineage",
+ "corporate_entity_id": seeded_db["other_corp_id"],
+ "idempotency_key": "buyer-create-hidden-corp",
+ },
+ )
+ assert hidden.status_code == 404
+
+ unauthenticated = client.post(
+ "/api/analysis-runs",
+ json={"idempotency_key": "buyer-create-unauthenticated"},
+ )
+ assert unauthenticated.status_code == 401
+
+
def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> None:
response = client.get("/api/me", headers={"Authorization": f"Bearer {demo_analyst_token}"})
assert response.status_code == 200
@@ -324,7 +585,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"
@@ -363,8 +624,9 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token
(seeded_db["public_post_id"],),
)
cur.execute(
- "insert into post_summary_role (post_id, person_name, responsibility) "
- "values (%s, 'Ada West', '후속 연락')",
+ "insert into post_summary_role "
+ "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) "
+ "values (%s, 'Ada West', '후속 연락', 'prov_person', 'Demo Corp')",
(seeded_db["public_post_id"],),
)
finally:
@@ -378,9 +640,13 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token
body = response.json()
assert body["korean_summary"] == "저장된 한국어 요약입니다."
assert body["key_events"] == ["저장된 이벤트"]
- assert body["roles_and_responsibilities"] == [
- {"person_name": "Ada West", "responsibility": "후속 연락"}
- ]
+ assert len(body["roles_and_responsibilities"]) == 1
+ role = body["roles_and_responsibilities"][0]
+ assert role["actor_name"] == "Ada West"
+ assert role["responsibility"] == "후속 연락"
+ assert role["actor_type_code"] == "prov_person"
+ assert role["affiliated_organization_name"] == "Demo Corp"
+ assert role["ontology_label"] == "Role actor (person)"
def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, seeded_db) -> None:
@@ -407,7 +673,10 @@ def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, s
body = response.json()
assert "에이다" in body["korean_summary"]
assert body["key_events"]
- assert any(role["person_name"] == "Ada West" for role in body["roles_and_responsibilities"])
+ roles = {role["actor_name"]: role for role in body["roles_and_responsibilities"]}
+ assert roles["Ada West"]["actor_type_code"] == "prov_person"
+ assert roles["당사"]["actor_type_code"] == "prov_organization"
+ assert roles["당사"]["ontology_label"] == "Role actor (organization)"
def test_seed_fixture_summaries_surface_on_get_summary(client, demo_analyst_token, seeded_db) -> None:
@@ -466,7 +735,7 @@ def test_seed_fixture_summaries_surface_on_get_summary(client, demo_analyst_toke
assert fork.status_code == 200, fork.text
assert "재협상" in fork.json()["korean_summary"]
assert fork.json()["key_events"]
- fork_roles = {role["person_name"] for role in fork.json()["roles_and_responsibilities"]}
+ fork_roles = {role["actor_name"] for role in fork.json()["roles_and_responsibilities"]}
assert fork_roles == {"Ada West", "Priya Nair"}
calendar = client.get(
@@ -883,6 +1152,8 @@ def test_related_keymen_use_rwr_and_hide_invisible_posts(client, demo_analyst_to
counterpart = by_id[seeded_db["counterpart_person_id"]]
assert counterpart["ontology_label"] == "Person"
assert counterpart["ontology_iri"].endswith("#Person")
+ assert counterpart["person_side_code"] == "counterparty"
+ assert counterpart["person_side_label"] == "Counterparty"
own_post = by_id[seeded_db["own_private_post_id"]]
assert own_post["ontology_label"] == "Post"
@@ -902,6 +1173,9 @@ def test_related_corporate_entity_uses_rwr_and_hides_invisible_posts(
assert body["entity_name"] == "Test Corp"
related_ids = {node["node_id"] for node in body["related"]}
assert seeded_db["our_person_id"] in related_ids
+ our_person = next(node for node in body["related"] if node["node_id"] == seeded_db["our_person_id"])
+ assert our_person["person_side_code"] == "our_side"
+ assert our_person["person_side_label"] == "Our side"
assert seeded_db["other_private_post_id"] not in related_ids
assert seeded_db["hidden_person_id"] not in related_ids
@@ -927,6 +1201,44 @@ def test_unknown_corporate_entity_related_is_not_found(
assert response.status_code == 404
+def test_team_only_on_other_corp_private_post_is_forbidden(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """A team mentioned only on another corp's private post must 403."""
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ "insert into cataloged_team (team_name, affiliated_organization_name) "
+ "values ('비공개 설계팀', 'Other Corp') returning team_id"
+ )
+ team_id = str(cur.fetchone()[0])
+ cur.execute(
+ "insert into post_team_mention (post_id, team_id) values (%s, %s)",
+ (seeded_db["other_private_post_id"], team_id),
+ )
+ finally:
+ admin_conn.close()
+
+ response = client.get(
+ f"/api/teams/{team_id}/related",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 403
+
+
+def test_unknown_team_related_is_not_found(client, demo_analyst_token) -> None:
+ """An unknown team UUID must 404, matching person and entity related."""
+
+ response = client.get(
+ f"/api/teams/{uuid.uuid4()}/related",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 404
+
+
def test_keyman_only_on_other_corp_private_post_is_forbidden(client, demo_analyst_token, seeded_db) -> None:
response = client.get(
f"/api/keymen/{seeded_db['hidden_person_id']}/related",
@@ -947,6 +1259,552 @@ def test_extract_keymen_requires_post_admin(client, demo_analyst_token, seeded_d
_ORCHESTRATOR_API_KEY = os.environ.get("LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY")
+def test_extract_keymen_does_not_merge_same_name_people_with_conflicting_titles(
+ client, demo_analyst_token, seeded_db, monkeypatch
+) -> None:
+ """Two different real people can share a name -- extracting a second
+ post that names the same person_name+side but a genuinely different
+ stated job_title must NOT reuse the first post's cataloged_person row.
+ A deterministic fake client (not a real orchestrator call) so this
+ is CI-stable: the point under test is `_upsert_person`'s own SQL
+ logic, not LLM extraction quality.
+ """
+ from lineageweave.keyman_extraction import COUNTERPARTY, PersonMention
+
+ _grant_post_admin(seeded_db["dsn"])
+
+ class _FakeClient:
+ available = True
+
+ def __init__(self, job_title: str) -> None:
+ self._job_title = job_title
+
+ def extract(self, post_title: str, post_body: str) -> list[PersonMention]:
+ return [PersonMention(person_name="Kim Cheolsu", person_side_code=COUNTERPARTY, job_title=self._job_title)]
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ post_ids = []
+ for title in ("Sales follow-up", "Purchasing follow-up"):
+ cur.execute(
+ "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) "
+ "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' "
+ "from source_post where post_id = %s "
+ "returning post_id",
+ (title, "placeholder body", seeded_db["own_private_post_id"]),
+ )
+ post_ids.append(str(cur.fetchone()[0]))
+ finally:
+ admin_conn.close()
+
+ monkeypatch.setattr("backend.app.main._entity_relationship_client", lambda: _FakeClient("unused"))
+
+ monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FakeClient("Sales Manager"))
+ response_a = client.post(
+ f"/api/posts/{post_ids[0]}/extract-keymen",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response_a.status_code == 200, response_a.text
+
+ monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FakeClient("Purchasing Lead"))
+ response_b = client.post(
+ f"/api/posts/{post_ids[1]}/extract-keymen",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response_b.status_code == 200, response_b.text
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ "select count(distinct person_id) from cataloged_person where person_name = 'Kim Cheolsu'"
+ )
+ distinct_people = cur.fetchone()[0]
+ finally:
+ admin_conn.close()
+
+ assert distinct_people == 2, "conflicting stated job titles for the same name must not be merged into one person"
+
+
+def test_extract_keymen_resolves_and_caches_an_abbreviated_organization_name(
+ client, demo_analyst_token, seeded_db, monkeypatch
+) -> None:
+ """ADR 0008: an affiliated organization named by abbreviation
+ ("AGP") must be resolved to its canonical name
+ ("Aurora Grid Power") and cross-verified before that name is trusted --
+ deterministic fake resolution/verification clients (not a real LLM
+ or Searxng call) so this is CI-stable; the point under test is the
+ resolve-then-persist wiring, not model/search quality.
+ """
+ from lineageweave.keyman_extraction import COUNTERPARTY, PersonMention
+ from lineageweave.relation_verification import STATUS_CORROBORATED, RelationVerificationResult
+
+ _grant_post_admin(seeded_db["dsn"])
+
+ class _FakeKeymanClient:
+ available = True
+
+ def extract(self, post_title: str, post_body: str) -> list[PersonMention]:
+ return [
+ PersonMention(
+ person_name="Kim Cheolsu",
+ person_side_code=COUNTERPARTY,
+ affiliated_organization_names=("AGP",),
+ )
+ ]
+
+ class _FakeRelationshipClient:
+ available = True
+
+ def classify(self, post_title: str, post_body: str, organization_names: list[str]):
+ return []
+
+ class _FakeResolutionClient:
+ available = True
+
+ def resolve(self, raw_name: str, context_text: str) -> str | None:
+ assert raw_name == "AGP"
+ return "Aurora Grid Power"
+
+ class _FakeVerificationClient:
+ available = True
+
+ def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult:
+ assert organization_name == "Aurora Grid Power"
+ assert relationship_label == "AGP"
+ return RelationVerificationResult(
+ status_code=STATUS_CORROBORATED, evidence_url="https://example.org/khnp"
+ )
+
+ monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FakeKeymanClient())
+ monkeypatch.setattr("backend.app.main._entity_relationship_client", lambda: _FakeRelationshipClient())
+ monkeypatch.setattr(
+ "backend.app.main._organization_name_resolution_client", lambda: _FakeResolutionClient()
+ )
+ monkeypatch.setattr("backend.app.main._relation_verification_client", lambda: _FakeVerificationClient())
+
+ response = client.post(
+ f"/api/posts/{seeded_db['own_private_post_id']}/extract-keymen",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ "select resolved_organization_name, verification_status_code, verification_evidence_url "
+ "from organization_name_resolution where raw_organization_name = 'AGP'"
+ )
+ cached = cur.fetchone()
+ cur.execute(
+ "select pa.affiliated_organization_name from person_affiliation pa "
+ "join cataloged_person cp on cp.person_id = pa.person_id "
+ "where cp.person_name = 'Kim Cheolsu'"
+ )
+ affiliation_name = cur.fetchone()[0]
+ finally:
+ admin_conn.close()
+
+ assert cached == ("Aurora Grid Power", STATUS_CORROBORATED, "https://example.org/khnp")
+ assert affiliation_name == "Aurora Grid Power", "a corroborated resolution must be the stored affiliation name"
+
+
+def test_same_team_named_in_two_posts_resolves_to_one_cataloged_team(
+ client, demo_analyst_token, seeded_db, monkeypatch
+) -> None:
+ """ADR 0009: extraction runs per-post, but "설계팀" (design team) at
+ the same company named in two different posts must resolve to the
+ same cataloged_team row -- otherwise every extraction is an island
+ and can never become a cross-post Knowledge Graph clue. A
+ deterministic fake summary client (not a real LLM call) so this is
+ CI-stable; the point under test is the upsert-then-dedupe wiring.
+ """
+ from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility
+
+ class _FakeSummaryClient:
+ available = True
+
+ def summarize(self, post_title: str, post_body: str) -> PostSummary:
+ return PostSummary(
+ korean_summary="설계팀이 도면을 검토했다.",
+ roles_and_responsibilities=(
+ RoleResponsibility(
+ actor_name="설계팀",
+ responsibility="도면 검토",
+ actor_type_code=ACTOR_TYPE_TEAM,
+ affiliated_organization_name="Demo Corp",
+ ),
+ ),
+ )
+
+ monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient())
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ post_ids = []
+ for title in ("설계 검토 회의 1", "설계 검토 회의 2"):
+ cur.execute(
+ "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) "
+ "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' "
+ "from source_post where post_id = %s returning post_id",
+ (title, "placeholder body", seeded_db["own_private_post_id"]),
+ )
+ post_ids.append(str(cur.fetchone()[0]))
+ finally:
+ admin_conn.close()
+
+ headers = {"Authorization": f"Bearer {demo_analyst_token}"}
+ for post_id in post_ids:
+ response = client.get(f"/api/posts/{post_id}/summary", headers=headers)
+ assert response.status_code == 200, response.text
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute("select count(*), count(distinct team_id) from cataloged_team where team_name = '설계팀'")
+ team_row_count, distinct_team_count = cur.fetchone()
+ cur.execute(
+ "select count(distinct pt.post_id) from post_team_mention pt "
+ "join cataloged_team ct on ct.team_id = pt.team_id "
+ "where ct.team_name = '설계팀'"
+ )
+ mentioning_post_count = cur.fetchone()[0]
+ cur.execute(
+ "select count(*) from knowledge_graph_edge "
+ "where source_node_type_code = 'node_team' and edge_type_code = 'edge_mention_team'"
+ )
+ team_mention_edge_count = cur.fetchone()[0]
+ finally:
+ admin_conn.close()
+
+ assert (team_row_count, distinct_team_count) == (1, 1), "the same team+org pair must dedupe to one row"
+ assert mentioning_post_count == 2, "both posts must link to the single cataloged team"
+ assert team_mention_edge_count == 2, "each post's mention must become a real KG edge"
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute("select team_id from cataloged_team where team_name = '설계팀'")
+ team_id = str(cur.fetchone()[0])
+ finally:
+ admin_conn.close()
+
+ related = client.get(
+ f"/api/teams/{team_id}/related",
+ headers=headers,
+ )
+ assert related.status_code == 200, related.text
+ related_ids = {node["node_id"] for node in related.json()["related"]}
+ assert set(post_ids) <= related_ids
+ summaries = [
+ client.get(f"/api/posts/{post_id}/summary", headers=headers).json()
+ for post_id in post_ids
+ ]
+ for body in summaries:
+ role = body["roles_and_responsibilities"][0]
+ assert role["catalog_node_id"] == team_id
+ assert role["catalog_node_type_code"] == "node_team"
+
+
+def test_organization_mention_only_posts_appear_in_entity_related(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """An org mentioned with no affiliated person must still start a related walk."""
+
+ 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) "
+ "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' "
+ "from source_post where post_id = %s returning post_id",
+ ("Org-only mention", "Test Corp was named without a person.", seeded_db["own_private_post_id"]),
+ )
+ org_only_post_id = str(cur.fetchone()[0])
+ cur.execute(
+ "insert into post_organization_mention (post_id, corporate_entity_id) values (%s, %s)",
+ (org_only_post_id, seeded_db["own_corp_id"]),
+ )
+ for edge in knowledge_graph_edges_for_post(
+ org_only_post_id,
+ [],
+ organization_corporate_entity_ids=[seeded_db["own_corp_id"]],
+ ):
+ cur.execute(
+ "insert into knowledge_graph_edge ("
+ "source_node_type_code, source_node_id, target_node_type_code, "
+ "target_node_id, edge_type_code, edge_weight"
+ ") values (%s, %s, %s, %s, %s, %s) "
+ "on conflict do nothing",
+ (
+ edge.source_node_type_code,
+ edge.source_node_id,
+ edge.target_node_type_code,
+ edge.target_node_id,
+ edge.edge_type_code,
+ edge.edge_weight,
+ ),
+ )
+ finally:
+ admin_conn.close()
+
+ response = client.get(
+ f"/api/corporate-entities/{seeded_db['own_corp_id']}/related",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ related_ids = {node["node_id"] for node in response.json()["related"]}
+ assert org_only_post_id in related_ids
+
+
+def test_private_other_corp_organization_mention_does_not_leak(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """The org-mention UNION must still apply ABAC per post."""
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ "insert into post_organization_mention (post_id, corporate_entity_id) "
+ "values (%s, %s) on conflict do nothing",
+ (seeded_db["other_private_post_id"], seeded_db["own_corp_id"]),
+ )
+ cur.execute(
+ "insert into post_organization_mention (post_id, corporate_entity_id) "
+ "values (%s, %s) on conflict do nothing",
+ (seeded_db["other_private_post_id"], seeded_db["other_corp_id"]),
+ )
+ for entity_id in (seeded_db["own_corp_id"], seeded_db["other_corp_id"]):
+ for edge in knowledge_graph_edges_for_post(
+ seeded_db["other_private_post_id"],
+ [],
+ organization_corporate_entity_ids=[entity_id],
+ ):
+ cur.execute(
+ "insert into knowledge_graph_edge ("
+ "source_node_type_code, source_node_id, target_node_type_code, "
+ "target_node_id, edge_type_code, edge_weight"
+ ") values (%s, %s, %s, %s, %s, %s) "
+ "on conflict do nothing",
+ (
+ edge.source_node_type_code,
+ edge.source_node_id,
+ edge.target_node_type_code,
+ edge.target_node_id,
+ edge.edge_type_code,
+ edge.edge_weight,
+ ),
+ )
+ finally:
+ admin_conn.close()
+
+ headers = {"Authorization": f"Bearer {demo_analyst_token}"}
+ own = client.get(
+ f"/api/corporate-entities/{seeded_db['own_corp_id']}/related",
+ headers=headers,
+ )
+ assert own.status_code == 200, own.text
+ own_ids = {node["node_id"] for node in own.json()["related"]}
+ assert seeded_db["other_private_post_id"] not in own_ids
+
+ hidden = client.get(
+ f"/api/corporate-entities/{seeded_db['other_corp_id']}/related",
+ headers=headers,
+ )
+ assert hidden.status_code == 403
+
+
+def test_thread_group_run_list_honors_knowledge_cutoff(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """A later public post must not surface a previously hidden thread-group run."""
+
+ 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 thread-group post",
+ "Written after the January cutoff.",
+ "late-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
+ """,
+ ("f" * 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 = 'other.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-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-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()
+
+ listed = client.get(
+ "/api/analysis-runs",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ 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
+
+
+def test_first_mention_of_a_new_counterparty_creates_a_real_corporate_entity(
+ client, demo_analyst_token, seeded_db, monkeypatch
+) -> None:
+ """ADR 0010: a person's affiliation to an organization with no
+ existing corporate_entity candidate must not stay permanently
+ unresolved -- an LLM-proposed, search-corroborated hierarchy
+ placement creates a real new row, closing the "통합 고객사 계열
+ tree AI" gap synthetic regression corpus data confirmed (0 of thousands of
+ real affiliations ever resolved before this). Deterministic fake
+ clients, CI-stable -- the point under test is the create-then-link
+ wiring, not model/search quality.
+ """
+ from lineageweave.corporate_hierarchy_inference import HierarchyProposal
+ from lineageweave.keyman_extraction import COUNTERPARTY, PersonMention
+ from lineageweave.relation_verification import STATUS_CORROBORATED, RelationVerificationResult
+
+ _grant_post_admin(seeded_db["dsn"])
+
+ class _FakeKeymanClient:
+ available = True
+
+ def extract(self, post_title: str, post_body: str) -> list[PersonMention]:
+ return [
+ PersonMention(
+ person_name="Priya Sharma",
+ person_side_code=COUNTERPARTY,
+ affiliated_organization_names=("Northwind Turbines Gwangju Plant",),
+ )
+ ]
+
+ class _FakeRelationshipClient:
+ available = True
+
+ def classify(self, post_title: str, post_body: str, organization_names: list[str]):
+ return []
+
+ class _FakeHierarchyInferenceClient:
+ available = True
+
+ def infer(self, organization_name: str, context_text: str) -> HierarchyProposal | None:
+ if organization_name == "Northwind Turbines Gwangju Plant":
+ return HierarchyProposal(level_code="plant", parent_name="Northwind Turbines")
+ if organization_name == "Northwind Turbines":
+ return HierarchyProposal(level_code="company", parent_name=None)
+ return None
+
+ class _FakeVerificationClient:
+ available = True
+
+ def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult:
+ return RelationVerificationResult(
+ status_code=STATUS_CORROBORATED, evidence_url=f"https://example.org/{organization_name}"
+ )
+
+ monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FakeKeymanClient())
+ monkeypatch.setattr("backend.app.main._entity_relationship_client", lambda: _FakeRelationshipClient())
+ monkeypatch.setattr(
+ "backend.app.main._corporate_hierarchy_inference_client", lambda: _FakeHierarchyInferenceClient()
+ )
+ monkeypatch.setattr("backend.app.main._relation_verification_client", lambda: _FakeVerificationClient())
+
+ response = client.post(
+ f"/api/posts/{seeded_db['own_private_post_id']}/extract-keymen",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ "select corporate_entity_id, entity_level_code, parent_entity_id, corporate_entity_code "
+ "from corporate_entity where entity_name = 'Northwind Turbines Gwangju Plant'"
+ )
+ plant_row = cur.fetchone()
+ cur.execute(
+ "select corporate_entity_id, entity_level_code "
+ "from corporate_entity where entity_name = 'Northwind Turbines'"
+ )
+ company_row = cur.fetchone()
+ cur.execute(
+ "select pa.affiliated_corporate_entity_id from person_affiliation pa "
+ "join cataloged_person cp on cp.person_id = pa.person_id "
+ "where cp.person_name = 'Priya Sharma'"
+ )
+ affiliation_entity_id = cur.fetchone()[0]
+ finally:
+ admin_conn.close()
+
+ assert plant_row is not None, "the plant-level entity must be created"
+ plant_entity_id, plant_level_code, plant_parent_id, plant_code = plant_row
+ assert plant_level_code == "plant"
+ assert plant_code.startswith("AUTO-"), "an auto-created code must never collide with a real login corp code"
+ assert company_row is not None, "the inferred parent company must also be created, not left dangling"
+ company_entity_id, company_level_code = company_row
+ assert company_level_code == "company"
+ assert str(plant_parent_id) == str(company_entity_id), "the plant's parent must be the real created company"
+ assert str(affiliation_entity_id) == str(plant_entity_id), "the affiliation must link to the real created plant"
+
+
@pytest.mark.skipif(
not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY),
reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run",
diff --git a/backend/uv-bootstrap-requirements.txt b/backend/uv-bootstrap-requirements.txt
new file mode 100644
index 000000000..c981ffe9c
--- /dev/null
+++ b/backend/uv-bootstrap-requirements.txt
@@ -0,0 +1,14 @@
+# uv bootstrap dependency for backend/Dockerfile.
+#
+# All hashes are the PyPI trusted-published uv 0.11.28 binary wheels for the
+# Linux architectures supported by the pinned python:3.12-slim base manifest.
+# --only-binary=:all: in the Dockerfile makes unsupported platforms fail closed
+# instead of falling back to an unhashed source build.
+uv==0.11.28 \
+ --hash=sha256:49fe42df9f42056037473f3876adec1615709b57d3470ed39178ff420f3afb9f \
+ --hash=sha256:041e4b80bebc58d7142ac9394370cacd73185fd8d066d6675d14707d83408f6d \
+ --hash=sha256:185416a5316df8c5442b47178349f1f27fc1034468670ac1fb499eae3b25bd68 \
+ --hash=sha256:a4a9fe246cb2882532277f5d5e5bd8a59462981462a2f98426f35ecfca82460e \
+ --hash=sha256:6f7ce6f6015a3e857bc6a663514afa62856b669ee5c1bd120e4c58ac2ef5513d \
+ --hash=sha256:6b3d0ea11e83b373a2166b82dd0864f5677fbadf98db64541ab2e59c42968905 \
+ --hash=sha256:8c60294e3be4fa203a04015fc02ac8a31d936e86fde06dcb43c7f8f22661dfff
diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile
index 0c6323a92..e394d376e 100644
--- a/docker/postgres-init/Dockerfile
+++ b/docker/postgres-init/Dockerfile
@@ -3,10 +3,9 @@ FROM postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f5
# Keycloak-database bootstrap and the product schema can be copied from
# their single sources of truth.
COPY docker/postgres-init/01-create-keycloak-db.sql /docker-entrypoint-initdb.d/01-create-keycloak-db.sql
-# The exact same migration file tests/test_schema.py applies -- single
-# source of truth, no re-typed copy. Runs against POSTGRES_DB (the "app"
-# database) because docker-entrypoint-initdb.d executes each *.sql file
-# with that database already selected.
+# The exact same migration files the PostgreSQL contract tests apply -- single
+# source of truth, no re-typed copy. docker-entrypoint-initdb.d executes each
+# file against POSTGRES_DB in lexical order.
COPY migrations/0001_initial_schema.sql /docker-entrypoint-initdb.d/02-app-schema.sql
COPY migrations/0002_thread_grouping_keys.sql /docker-entrypoint-initdb.d/03-thread-grouping-keys.sql
COPY migrations/0003_ticket_commitment_calendar.sql /docker-entrypoint-initdb.d/04-ticket-commitment-calendar.sql
@@ -18,6 +17,14 @@ COPY migrations/0008_post_summary_result.sql /docker-entrypoint-initdb.d/09-post
COPY migrations/0009_shared_metric_bank.sql /docker-entrypoint-initdb.d/10-shared-metric-bank.sql
COPY migrations/0010_report_item_information.sql /docker-entrypoint-initdb.d/11-report-item-information.sql
COPY migrations/0011_post_chat_result.sql /docker-entrypoint-initdb.d/12-post-chat-result.sql
+COPY migrations/0012_role_responsibility_agent_type.sql /docker-entrypoint-initdb.d/13-role-responsibility-agent-type.sql
+COPY migrations/0013_person_job_title.sql /docker-entrypoint-initdb.d/14-person-job-title.sql
+COPY migrations/0014_role_responsibility_team_actor_type.sql /docker-entrypoint-initdb.d/15-role-responsibility-team-actor-type.sql
+COPY migrations/0015_organization_name_resolution.sql /docker-entrypoint-initdb.d/16-organization-name-resolution.sql
+COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/17-cross-post-actor-identity.sql
+COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/18-prov-o-standard-relations.sql
+COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql
+COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql
# Official image already drops to this account at runtime; declare it so
# the Dockerfile itself satisfies DS-0002 (explicit non-root USER).
USER postgres
diff --git a/docs/PROV_O_IMPLEMENTATION.md b/docs/PROV_O_IMPLEMENTATION.md
new file mode 100644
index 000000000..96b471e96
--- /dev/null
+++ b/docs/PROV_O_IMPLEMENTATION.md
@@ -0,0 +1,102 @@
+# W3C PROV-O implementation
+
+## Requirement
+
+LineageWeave must accept, validate, persist, infer, and serialize every normative relation in *PROV-O: The PROV Ontology* without flattening qualified influences or literal-valued properties into the existing navigation graph.
+
+## Runtime architecture
+
+```mermaid
+flowchart LR
+ A[Product records and external RDF] --> B[PROV-O canonicalizer]
+ B --> C[Domain/range and literal validator]
+ C --> D[Explicit provenance assertions]
+ D --> E[Deterministic materializer]
+ E --> F[Qualified-to-unqualified implications]
+ E --> G[Property hierarchy and inverse closure]
+ E --> H[Qualified event-time shortcuts]
+ D --> I[(Normalized PostgreSQL provenance store)]
+ E --> J[RDF/Turtle/JSON-LD via rdflib]
+ I --> K[Explicit projection]
+ K --> L[(knowledge_graph_edge navigation graph)]
+```
+
+## Supported standard surface
+
+The machine-verifiable inventory is in [`PROV_O_IMPLEMENTATION_MATRIX.md`](PROV_O_IMPLEMENTATION_MATRIX.md):
+
+- all 30 PROV-O classes;
+- all 50 normative properties;
+- exact object/datatype distinction;
+- direct domains, resource ranges, and `xsd:dateTime` ranges;
+- class and property hierarchies;
+- both normative qualification tables;
+- every Appendix B recommended inverse name.
+
+## Canonicalization contract
+
+Inputs may use a local name, `prov:` compact name, full W3C IRI, or a reserved Appendix B inverse name. A canonical property name always retains its standard direction. A reserved inverse name that is not itself one of the 50 normative properties reverses subject and object into the preferred relation.
+
+```text
+source prov:hadDerivation derived
+ ↓ canonicalize and reverse
+derived prov:wasDerivedFrom source
+```
+
+No inverse alias is accepted for datatype properties because reversing a literal cannot produce a valid RDF subject.
+
+## Qualification contract
+
+For each normative mapping:
+
+```text
+influenced --qualifiedRelation--> influence
+influence --influencerProperty--> influencer
+```
+
+LineageWeave materializes:
+
+```text
+influenced --unqualifiedRelation--> influencer
+```
+
+This applies to Generation, Derivation, Attribution, Usage, Communication, Association, Delegation, generic Influence, PrimarySource, Quotation, Revision, Invalidation, Start, and End.
+
+## Persistence contract
+
+`migrations/0017_prov_o_standard_relations.sql` creates a third-normal-form catalog and assertion store. A PostgreSQL trigger recursively checks subject domains and resource ranges through the class hierarchy and checks datatype-property literals before insertion. One assertion has exactly one resource or literal object. Inference provenance is represented by the many-to-many `provenance_assertion_derivation` table.
+
+## Security and tenancy boundary
+
+- External IRIs and lexical values are data, never executable instructions.
+- RDF serialization performs no external fetch.
+- The support profile uses `owl:imports` as metadata; runtime code does not dereference it.
+- Assertions are rejected if resources are undeclared or incorrectly typed.
+- The migration does not weaken existing row-level access decisions. API exposure must apply the same authenticated product boundary before binding product nodes to provenance resources.
+
+## Operability
+
+- Definitions are idempotently seeded.
+- Exact W3C IRIs are stable; relational codes are multiword snake case.
+- Standard definitions and runtime data are separate, so ontology upgrades can be reviewed without rewriting assertions.
+- `provenance_resource_binding` is the only bridge to LineageWeave node identifiers; projections remain reproducible and removable.
+
+## Acceptance evidence
+
+```bash
+pytest -q tests/test_prov_o.py
+coverage run --branch -m pytest -q tests/test_prov_o.py
+coverage report -m lineageweave/prov_o.py
+python -m compileall -q lineageweave tests
+```
+
+Expected focused result: all tests pass and `lineageweave/prov_o.py` reports 100% statements and branches.
+
+## OWL 2 RL compatibility domains are not universal permissions
+
+Appendix A also publishes broad `prov:Influence` domains for
+`prov:hadActivity` and `prov:hadRole` as OWL 2 RL compatibility aids.
+The Recommendation explicitly warns that these broad domains must not be
+read as permission to use either property on every Influence. Runtime and
+database validation therefore enforce the normative union members rather
+than weakening the contract.
diff --git a/docs/PROV_O_IMPLEMENTATION_MATRIX.md b/docs/PROV_O_IMPLEMENTATION_MATRIX.md
new file mode 100644
index 000000000..8a3c28617
--- /dev/null
+++ b/docs/PROV_O_IMPLEMENTATION_MATRIX.md
@@ -0,0 +1,67 @@
+# PROV-O implementation matrix
+
+LineageWeave implements the W3C PROV-O Recommendation as a separate standards-complete provenance layer. The product-specific `knowledge_graph_edge` remains a compact navigation projection; it is not used to flatten literal-valued or qualified PROV-O assertions.
+
+## Coverage contract
+
+- 30 normative classes.
+- 50 normative properties: 44 object properties and 6 datatype properties.
+- 14 qualified influence mappings from Tables 2 and 3.
+- Qualified forms imply their unqualified forms.
+- Transitive subproperty closure, defined inverses, `alternateOf` symmetry, and qualified event-time shortcuts are materialized deterministically.
+- All 44 Appendix B inverse names are cataloged; non-canonical reserved names are accepted by reversing into the preferred PROV-O direction.
+
+## Property matrix
+
+| PROV-O property | Kind | Domain | Range / datatype | Superproperty | Qualification | Appendix B inverse |
+|---|---|---|---|---|---|---|
+| `prov:wasGeneratedBy` | object | Entity | Activity | wasInfluencedBy | qualifiedGeneration → Generation.activity | `prov:generated` |
+| `prov:wasDerivedFrom` | object | Entity | Entity | wasInfluencedBy | qualifiedDerivation → Derivation.entity | `prov:hadDerivation` |
+| `prov:wasAttributedTo` | object | Entity | Agent | wasInfluencedBy | qualifiedAttribution → Attribution.agent | `prov:contributed` |
+| `prov:startedAtTime` | datatype | Activity | http://www.w3.org/2001/XMLSchema#dateTime | — | — | — |
+| `prov:used` | object | Activity | Entity | wasInfluencedBy | qualifiedUsage → Usage.entity | `prov:wasUsedBy` |
+| `prov:wasInformedBy` | object | Activity | Activity | wasInfluencedBy | qualifiedCommunication → Communication.activity | `prov:informed` |
+| `prov:endedAtTime` | datatype | Activity | http://www.w3.org/2001/XMLSchema#dateTime | — | — | — |
+| `prov:wasAssociatedWith` | object | Activity | Agent | wasInfluencedBy | qualifiedAssociation → Association.agent | `prov:wasAssociateFor` |
+| `prov:actedOnBehalfOf` | object | Agent | Agent | wasInfluencedBy | qualifiedDelegation → Delegation.agent | `prov:hadDelegate` |
+| `prov:alternateOf` | object | Entity | Entity | — | — | `prov:alternateOf` |
+| `prov:specializationOf` | object | Entity | Entity | alternateOf | — | `prov:generalizationOf` |
+| `prov:generatedAtTime` | datatype | Entity | http://www.w3.org/2001/XMLSchema#dateTime | — | — | — |
+| `prov:hadPrimarySource` | object | Entity | Entity | wasDerivedFrom | qualifiedPrimarySource → PrimarySource.entity | `prov:wasPrimarySourceOf` |
+| `prov:value` | datatype | Entity | RDF literal | — | — | — |
+| `prov:wasQuotedFrom` | object | Entity | Entity | wasDerivedFrom | qualifiedQuotation → Quotation.entity | `prov:quotedAs` |
+| `prov:wasRevisionOf` | object | Entity | Entity | wasDerivedFrom | qualifiedRevision → Revision.entity | `prov:hadRevision` |
+| `prov:invalidatedAtTime` | datatype | Entity | http://www.w3.org/2001/XMLSchema#dateTime | — | — | — |
+| `prov:wasInvalidatedBy` | object | Entity | Activity | wasInfluencedBy | qualifiedInvalidation → Invalidation.activity | `prov:invalidated` |
+| `prov:hadMember` | object | Collection | Entity | wasInfluencedBy | — | `prov:wasMemberOf` |
+| `prov:wasStartedBy` | object | Activity | Entity | wasInfluencedBy | qualifiedStart → Start.entity | `prov:started` |
+| `prov:wasEndedBy` | object | Activity | Entity | wasInfluencedBy | qualifiedEnd → End.entity | `prov:ended` |
+| `prov:invalidated` | object | Activity | Entity | influenced | — | `prov:wasInvalidatedBy` |
+| `prov:influenced` | object | Entity / Activity / Agent | Entity / Activity / Agent | — | — | `prov:wasInfluencedBy` |
+| `prov:atLocation` | object | Activity / Agent / Entity / InstantaneousEvent | Location | — | — | `prov:locationOf` |
+| `prov:generated` | object | Activity | Entity | influenced | — | `prov:wasGeneratedBy` |
+| `prov:wasInfluencedBy` | object | Entity / Activity / Agent | Entity / Activity / Agent | — | qualifiedInfluence → Influence.influencer | `prov:influenced` |
+| `prov:qualifiedInfluence` | object | Entity / Activity / Agent | Influence | — | — | `prov:qualifiedInfluenceOf` |
+| `prov:qualifiedGeneration` | object | Entity | Generation | qualifiedInfluence | — | `prov:qualifiedGenerationOf` |
+| `prov:qualifiedDerivation` | object | Entity | Derivation | qualifiedInfluence | — | `prov:qualifiedDerivationOf` |
+| `prov:qualifiedPrimarySource` | object | Entity | PrimarySource | qualifiedInfluence | — | `prov:qualifiedSourceOf` |
+| `prov:qualifiedQuotation` | object | Entity | Quotation | qualifiedInfluence | — | `prov:qualifiedQuotationOf` |
+| `prov:qualifiedRevision` | object | Entity | Revision | qualifiedInfluence | — | `prov:revisedEntity` |
+| `prov:qualifiedAttribution` | object | Entity | Attribution | qualifiedInfluence | — | `prov:qualifiedAttributionOf` |
+| `prov:qualifiedInvalidation` | object | Entity | Invalidation | qualifiedInfluence | — | `prov:qualifiedInvalidationOf` |
+| `prov:qualifiedStart` | object | Activity | Start | qualifiedInfluence | — | `prov:qualifiedStartOf` |
+| `prov:qualifiedUsage` | object | Activity | Usage | qualifiedInfluence | — | `prov:qualifiedUsingActivity` |
+| `prov:qualifiedCommunication` | object | Activity | Communication | qualifiedInfluence | — | `prov:qualifiedCommunicationOf` |
+| `prov:qualifiedAssociation` | object | Activity | Association | qualifiedInfluence | — | `prov:qualifiedAssociationOf` |
+| `prov:qualifiedEnd` | object | Activity | End | qualifiedInfluence | — | `prov:qualifiedEndOf` |
+| `prov:qualifiedDelegation` | object | Agent | Delegation | qualifiedInfluence | — | `prov:qualifiedDelegationOf` |
+| `prov:influencer` | object | Influence | Entity / Activity / Agent | — | — | `prov:hadInfluence` |
+| `prov:entity` | object | EntityInfluence | Entity | influencer | — | `prov:entityOfInfluence` |
+| `prov:hadUsage` | object | Derivation | Usage | — | — | `prov:wasUsedInDerivation` |
+| `prov:hadGeneration` | object | Derivation | Generation | — | — | `prov:generatedAsDerivation` |
+| `prov:activity` | object | ActivityInfluence | Activity | influencer | — | `prov:activityOfInfluence` |
+| `prov:agent` | object | AgentInfluence | Agent | influencer | — | `prov:agentOfInfluence` |
+| `prov:hadPlan` | object | Association | Plan | — | — | `prov:wasPlanOf` |
+| `prov:hadActivity` | object | Delegation / Derivation / End / Start | Activity | — | — | `prov:wasActivityOfInfluence` |
+| `prov:atTime` | datatype | InstantaneousEvent | http://www.w3.org/2001/XMLSchema#dateTime | — | — | — |
+| `prov:hadRole` | object | Association / InstantaneousEvent | Role | — | — | `prov:wasRoleIn` |
diff --git a/docs/adr/0006-role-responsibility-agent-ontology.md b/docs/adr/0006-role-responsibility-agent-ontology.md
new file mode 100644
index 000000000..8ded02b81
--- /dev/null
+++ b/docs/adr/0006-role-responsibility-agent-ontology.md
@@ -0,0 +1,107 @@
+# ADR 0006 — R&R's named actor is a PROV-O Agent, not always a person
+
+**Decision status:** Accepted
+**Date:** 2026-08-14
+
+## Context
+
+`post_summary.py`'s R&R (roles & responsibilities) extraction treats
+the acting party a post's text names as if it were always a person.
+Business correspondence routinely names an organization acting in its
+own name -- "당사" (our company), "Demo Corp" -- not a named
+individual. The product requirement is to handle that with a general
+standard ontology, and to infer a person actor's affiliation so a
+bare name is not left unplaced.
+
+Before this change, `RoleResponsibility.person_name` had no way to
+express "this actor is an organization" -- every entry was forced into
+a person slot, and an organization actor's name would sit
+indistinguishable from an unresolved person.
+
+## Decision
+
+Ground the distinction in W3C PROV-O (Lebo, Sahoo, & McGuinness, 2013):
+`prov:Agent` is the general acting-party class, with `prov:Person` and
+`prov:Organization` as its two recognized subclasses -- an existing,
+widely-adopted standard for exactly this "who/what acted" provenance
+question, not a bespoke local invention.
+
+`RoleResponsibility` (`lineageweave/post_summary.py`) gains:
+- `actor_name` (renamed from `person_name` -- the field can now hold an
+ organization's name too, so "person" in the field name would be
+ actively wrong).
+- `actor_type_code`: `prov_person` / `prov_organization`
+ (`common_lookup_value` category `prov_agent_type`), defaulting to
+ `prov_person` when the LLM's response omits the field, matching this
+ repo's existing degrade-gracefully-not-fail discipline.
+- `affiliated_organization_name`: for a person actor, the organization
+ the text names or clearly implies they work for, inferred by the same
+ LLM call rather than left for a human to cross-reference against the
+ Keyman panel separately. `None` when the text gives nothing to infer,
+ or when the actor is itself an organization (its own name already
+ answers "which organization").
+
+The LLM prompt now explicitly instructs the model to decide
+person-vs-organization per actor rather than defaulting every named
+actor to a person, and to give an affiliation when the text supports
+one.
+
+Ontology (`docs/ontology/lineageweave-kg.ttl`, extending
+[ADR 0004](0004-knowledge-graph-ontology.md)'s vocabulary):
+`:RoleActorPerson rdfs:subClassOf prov:Person` and
+`:RoleActorOrganization rdfs:subClassOf prov:Organization`, each
+carrying the `:lookupCode` annotation linking it to the matching
+`common_lookup_value` row -- these are genuinely subclasses of the real
+external PROV-O classes (imported via the `prov:` prefix), not
+same-named local terms that merely resemble the standard. Kept distinct
+from the ontology's existing `:Person` (node_type's `node_person`,
+i.e. a cataloged Keyman with a stable `person_id`): an R&R actor is a
+free-text name with no cataloged identity of its own, and may not even
+resolve to a Keyman row.
+
+Persistence: `post_summary_role` gains `actor_type_code` (FK to
+`common_lookup_value`, default `prov_person`) and
+`affiliated_organization_name`; `person_name` is renamed to
+`actor_name` via `migrations/0012_role_responsibility_agent_type.sql`'s
+`ALTER TABLE ... RENAME COLUMN` (preserves every existing row's data,
+unlike a drop/recreate) plus the two new `ADD COLUMN IF NOT EXISTS`
+statements, with `migrations/0001_initial_schema.sql` updated directly
+for a fresh install, matching this repo's established pattern (e.g.
+ADR 0005's `verification_status_code` additions).
+
+UI: the popup's R&R list (`frontend/src/App.tsx`) shows a
+Person/Organization badge per actor and the inferred affiliation in
+parentheses; only a person actor is still linked to the Keyman panel
+(an organization actor has no `person_id` to link to).
+
+## Consequences
+
+- `RoleResponsibility.person_name` is a breaking rename to `actor_name`
+ across the JSON wire contract (`GET /api/posts/{id}/summary`), the
+ DB column, and every call site. Accepted because the field's old name
+ was actively misleading once an organization actor is an intended
+ value, not a hypothetical edge case.
+- `prov_agent_type` is a `common_lookup_value` category seeded by its
+ own migration file (0012), not literally embedded in
+ `scripts/seed_demo_data.py`'s SQL string the way ADR 0004's original
+ five covered categories are -- `tests/test_ontology.py`'s round-trip
+ check reads 0012's file content alongside the seed script's own text
+ so this still closes the loop, rather than being silently excluded
+ the way `evaluation_criterion` / `relation_verification_status`
+ currently are.
+- The affiliation inference is opportunistic, not authoritative: it is
+ a same-request LLM guess from the post's own text, not resolved
+ against `corporate_entity` the way Keyman affiliations are (see
+ `lineageweave/corporate_hierarchy_resolution.py`). A future slice
+ could route it through the same resolver if real usage shows the
+ free-text name needs matching back to a cataloged organization.
+
+## Related
+
+Extends [ADR 0004](0004-knowledge-graph-ontology.md)'s Ontology/
+Semantic-Layer vocabulary and reuses its round-trip enforcement
+mechanism (`tests/test_ontology.py`).
+
+## References (APA 7th)
+
+Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/
diff --git a/docs/adr/0007-team-actor-type.md b/docs/adr/0007-team-actor-type.md
new file mode 100644
index 000000000..1da18883b
--- /dev/null
+++ b/docs/adr/0007-team-actor-type.md
@@ -0,0 +1,90 @@
+# ADR 0007 — R&R's named actor can be a team, a meso-level unit, not just a person/organization
+
+**Decision status:** Accepted
+**Date:** 2026-08-14
+
+## Context
+
+ADR 0006 gave R&R's `actor_type_code` two values: `prov_person` and
+`prov_organization`, grounded in W3C PROV-O's `prov:Agent` subclasses.
+Real post text surfaced a third, distinct case those two do not cover:
+a named sub-unit of a company -- e.g. "설계팀" (design team) -- acting
+in the text. A team is not a person, and forcing it into
+`prov_organization` is wrong for the same reason ADR 0006 rejected
+forcing an organization into a person slot: it collapses a real,
+useful distinction. A team is meso-level -- part of a company, not the
+company itself, and not an individual either.
+
+PROV-O has no sub-organization concept to reuse here; `prov:Agent`'s
+two subclasses are exhaustive for PROV-O's own purposes (an
+organization's internal structure is out of PROV-O's scope).
+
+## Decision
+
+Ground the team case in the W3C Organization Ontology (Reynolds, 2014):
+`org:OrganizationalUnit`, defined for exactly this -- representing the
+division of an organization into sub-organizational units, linked to
+its parent via `org:unitOf`/`org:subOrganizationOf`. This is a
+different, complementary W3C vocabulary from PROV-O, not a conflicting
+one: PROV-O models "who/what acted," ORG models "how an organization is
+structured internally" -- a team acting in a post's text needs both a
+`prov:Agent`-shaped role (it does something) and an
+`org:OrganizationalUnit`-shaped identity (it belongs to a company).
+`:RoleActorTeam` is declared `rdfs:subClassOf org:OrganizationalUnit`
+for that reason, parallel to how `:RoleActorPerson`/
+`:RoleActorOrganization` subclass PROV-O's classes.
+
+`post_summary.py` gains `ACTOR_TYPE_TEAM = "prov_team"`
+(`common_lookup_value` category `prov_agent_type`, extending ADR
+0006's two existing values). The LLM prompt now offers three actor
+types (person / organization / team) and explicitly requires a team
+actor to also carry `affiliated_organization_name` -- unlike an
+organization actor (whose own name already answers "which
+organization"), a team's name alone does not identify a company, so
+the field is not optional in the same "opportunistic" sense ADR 0006
+described for a person actor; a team is always someone's team, and the
+prompt asks the model to infer the parent company from context when
+the text supports it.
+
+No new `RoleResponsibility` field is needed:
+`affiliated_organization_name` already exists (ADR 0006) and applies
+unchanged to this actor type -- only its *meaning* extends from
+"the person's employer" to "the person's or team's parent
+organization," which the dataclass docstring now says explicitly.
+
+Persistence: `migrations/0014_role_responsibility_team_actor_type.sql`
+inserts the `prov_team` lookup row -- purely additive
+(`insert ... on conflict (lookup_code) do nothing`), no column or
+constraint change, since `actor_type_code` already stores an arbitrary
+FK'd lookup code and needs no schema change to accept a third value.
+
+## Consequences
+
+- `_VALID_ACTOR_TYPE_CODES` in `post_summary.py` now has three members;
+ any code elsewhere that pattern-matches strictly on the first two
+ (rather than treating an unrecognized/future code as "not this one")
+ needs review. Found and fixed one: the frontend badge's CSS class name
+ (`actor-type-${code}`) was already generic, but its *label text* was a
+ binary person/organization ternary that would have mislabeled a team
+ actor as "Organization" -- now a three-way check.
+- A team actor is never linked to the Keyman panel (same as an
+ organization actor in ADR 0006) -- it has no `person_id`.
+- Distinguishing "설계팀" (a team) from "Design Corp" (an organization)
+ is a real LLM judgment call with no hard syntactic rule; the prompt
+ gives the model the concept and an example, matching this repo's
+ existing degrade-gracefully discipline for judgment-call extraction
+ fields (a wrong guess is a labeling error on one row, not lost data --
+ the raw `actor_name` string is preserved regardless of which type it
+ is filed under).
+
+## Related
+
+Extends [ADR 0006](0006-role-responsibility-agent-ontology.md), which
+itself extends [ADR 0004](0004-knowledge-graph-ontology.md)'s Ontology/
+Semantic-Layer vocabulary.
+
+## References (APA 7th)
+
+Reynolds, D. (Ed.). (2014). *The organization ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/vocab-org/
+
+Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/
diff --git a/docs/adr/0008-organization-abbreviation-resolution.md b/docs/adr/0008-organization-abbreviation-resolution.md
new file mode 100644
index 000000000..72b121253
--- /dev/null
+++ b/docs/adr/0008-organization-abbreviation-resolution.md
@@ -0,0 +1,120 @@
+# ADR 0008 — Abbreviated organization names are resolved and search-verified, not left opaque
+
+**Decision status:** Accepted
+**Date:** 2026-08-14
+
+## Context
+
+Real post text names organizations by abbreviated or slang forms a
+human reader immediately recognizes but a string-matching pipeline
+cannot -- e.g. "AGP," a synthetic contraction of "Aurora Grid Power".
+`lineageweave.corporate_hierarchy_resolution`
+already resolves near-matches (a trailing legal suffix, a minor
+abbreviation) via character-sequence similarity
+(`difflib.SequenceMatcher`, grounded in Bhattacharya & Getoor, 2007's
+candidate-generation stage), but an initialism/contraction like "AGP"
+shares almost no character substring with its expansion -- no
+similarity threshold recovers it, because the two strings are not
+similar, they are *related by real-world knowledge* the text or an
+external source has to supply.
+
+Left unresolved, every mention of the same real organization under its
+abbreviated name creates its own unmatched, un-linkable free-text
+string in `person_affiliation`/R&R -- the same organization looks like
+N different unknown entities across N posts, each failing to link into
+the corporate hierarchy a human reader would recognize instantly.
+
+## Decision
+
+A two-stage pipeline, reusing infrastructure this repo already has for
+a structurally identical problem (ADR: `lineageweave.relation_verification`,
+FEVER-style claim verification) rather than building a second web-search
+integration:
+
+1. **LLM context resolution**
+ (`lineageweave.organization_name_resolution.ContextualOrchestratorOrganizationNameResolutionClient`):
+ given the raw abbreviated name and the post's own text as context,
+ ask the model for the organization's full real-world name, or
+ `UNKNOWN` when the text gives no real basis to determine one --
+ never inventing an expansion from the abbreviation's letters alone.
+2. **External search cross-verification**
+ (reusing `lineageweave.relation_verification.RelationVerificationClient`
+ as-is, not a new client class): the proposed full name plus the raw
+ abbreviation together become the search query (e.g. "Aurora Grid Power
+ AGP") -- a real page mentioning both together is strong
+ corroboration the specific pairing is correct, not just that the
+ full name exists as *some* organization.
+
+Grounded in SKOS (Miles & Bechhofer, 2009): `skos:prefLabel` (a
+resource's one preferred/canonical label) and `skos:altLabel` (an
+alternative label -- exactly the abbreviation/synonym relationship) is
+the standard vocabulary for a raw-name/canonical-name pair. This is a
+different, complementary standard from ADR 0006/0007's PROV-O/ORG
+classes: SKOS here labels the *string identity* relationship between
+two names for the same thing, not the *type* of the named actor.
+
+Only a search-corroborated resolution is ever substituted in for
+downstream entity matching (`resolve_corporate_entity`) -- an
+LLM-proposed name with no corroboration, or with verification itself
+unavailable, leaves the raw name flowing unchanged. This is the same
+never-trust-an-unverified-guess discipline `relation_verification`
+itself already established: a wrong resolution corrupts every
+downstream Knowledge Graph link through it, so "did not resolve" must
+stay a real, distinguishable outcome from "resolved to X."
+
+Persistence: a new `organization_name_resolution` cache table
+(`migrations/0015_organization_name_resolution.sql`), keyed by
+`raw_organization_name` -- the same abbreviation is resolved once, not
+re-queried on every one of its (potentially many) mentions across
+posts. `verification_status_code` reuses the existing
+`relation_verification_status` lookup category rather than a
+near-duplicate one: a resolved name is corroborated/uncorroborated the
+exact same way a classified relationship already is.
+
+Wired into `backend/app/keyman_ingestion.py`'s affiliation loop (the
+concrete case real data surfaced): each affiliated organization name is
+resolved before corporate-entity matching and creation. A corroborated
+canonical name is returned to the caller as part of the normalized
+`PersonMention`, so the same request's relationship classifier uses the
+canonical form too rather than reintroducing the raw abbreviation.
+
+## Consequences
+
+- The raw abbreviated form is not duplicated onto every row that
+ mentions it (e.g. `person_affiliation.affiliated_organization_name`
+ stores the resolved canonical name once corroborated) -- it remains
+ fully recoverable via a join against `organization_name_resolution`,
+ which is the authoritative raw-form/canonical-form/evidence record.
+ This is 3NF-motivated, not a loss: repeating the raw-to-canonical
+ mapping per affiliation row would be the actual redundancy.
+- Resolution availability can improve between extraction runs. When a
+ prior raw affiliation later resolves, `_upsert_affiliation` promotes
+ it transactionally: the canonical row is inserted or updated while
+ preserving any previously resolved corporate-entity link and role
+ title, and the obsolete raw-name row is deleted when the two names
+ differ. This avoids leaving duplicate raw and canonical identities for
+ the same person.
+- `ingest_post_keymen` returns the normalized mentions it persisted.
+ `extract_post_keymen` therefore passes canonical organization names to
+ entity-relationship classification and returns those same names on
+ the API response; affiliation persistence, relationship classification,
+ and the caller-visible payload agree within one transaction.
+- Every channel here follows the existing pluggable-client discipline:
+ `NullOrganizationNameResolutionClient`/an unavailable verification
+ client degrade to "use the raw name," never a fabricated resolution.
+
+## Related
+
+Complements [ADR 0006](0006-role-responsibility-agent-ontology.md) and
+[ADR 0007](0007-team-actor-type.md) (actor *type*), and reuses
+`lineageweave.relation_verification` (ADR-less, predates this file, see
+its own module docstring for FEVER grounding) for the verification
+stage rather than duplicating it.
+
+## References (APA 7th)
+
+Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization system reference*. World Wide Web Consortium. https://www.w3.org/TR/skos-reference/
+
+Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in relational data. *ACM Transactions on Knowledge Discovery from Data*, 1(1), Article 5. https://doi.org/10.1145/1217299.1217304
+
+Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A large-scale dataset for fact extraction and VERification. In *Proceedings of the 2018 Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies* (pp. 809–819). Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074
diff --git a/docs/adr/0009-cross-post-actor-identity.md b/docs/adr/0009-cross-post-actor-identity.md
new file mode 100644
index 000000000..7bdbfa091
--- /dev/null
+++ b/docs/adr/0009-cross-post-actor-identity.md
@@ -0,0 +1,130 @@
+# ADR 0009 — R&R team/organization actors get a shared cross-post identity, not just per-post text
+
+**Decision status:** Accepted
+**Date:** 2026-08-14
+
+## Context
+
+Extraction (Keyman, R&R) runs per-post. For a person actor, this was
+already not a dead end: Keyman extraction upserts into
+`cataloged_person`, so the same name across posts (mostly) resolves to
+one row with a stable `person_id` the Knowledge Graph can link through.
+R&R's team actor (ADR 0007, `prov_team`) and organization actor
+(ADR 0006, `prov_organization`) had no equivalent -- each post's
+extraction produced a bare `actor_name` string in `post_summary_role`
+with no catalog entry and no Knowledge Graph mention edge. The same
+"설계팀" (design team) named in ten different posts was ten unrelated
+strings, not one entity a Keyman/team panel could click through to see
+every post it appears in -- exactly the "extraction results must
+themselves become cross-post lineage clues, not just per-post
+artifacts" requirement this product exists to satisfy for people, but
+was not yet satisfying for teams or organizations.
+
+## Decision
+
+**Team**: a new `cataloged_team` catalog table
+(`migrations/0016_cross_post_actor_identity.sql`), the same
+catalog-then-mention shape `cataloged_person`/`post_person_mention`
+already establishes. Identity key is `(team_name,
+affiliated_organization_name)`, not the bare name alone -- "설계팀"
+exists at many real companies, so the pair is what is actually
+identifying (`backend/app/team_ingestion.py`'s `upsert_team`, mirroring
+`keyman_ingestion.py`'s `_upsert_person`). The team's own parent
+organization is resolved to a real `corporate_entity` via the *same*
+`resolve_corporate_entity` collective-entity-resolution matching
+(Bhattacharya & Getoor, 2007) Keyman affiliations already use -- not a
+second matching algorithm.
+
+**Organization**: an R&R organization actor's name is run through the
+same `resolve_corporate_entity` matching; a resolved match writes a
+`post_organization_mention` row (no new catalog needed -- `corporate_entity`
+already is the shared, cross-post organization catalog every VOC
+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.
+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
+risked a wrong side assignment. Documented as a real, deliberate scope
+boundary below, not silently half-done.
+Person evidence sources remain separate: Keyman extraction replaces
+`post_person_mention`; R&R replacement writes
+`post_summary_person_mention`. `combined_post_person_mention` is a
+read-only union used for lineage and KG derivation. This prevents a new
+summary from deleting Keyman evidence and prevents removed R&R actors
+from surviving as stale Keymen. Migration 0016 copies matching R&R
+actor names into `post_summary_person_mention` and must not delete
+overlapping Keyman rows -- `mention_context` has no R&R column, and a
+later summary replacement would otherwise erase the only remaining
+person evidence.
+
+
+Each resolved actor gets a real Knowledge Graph mention edge (new
+`edge_mention_team` / `edge_team_affiliation` / `edge_mention_organization`
+lookup codes, `lineageweave/knowledge_graph.py`'s
+`knowledge_graph_edges_for_post` extended, not a second edge-writing
+path), reusing the same `persist_edges_for_post` entry point Keyman
+ingestion already calls -- one function computes a post's whole edge
+set regardless of which extraction step triggered it.
+
+`knowledge_graph_edge` is a deduplicated materialized registry.
+`knowledge_graph_edge_evidence` records every post that currently supports an
+edge; readers require support from an ABAC-visible post. Writers reconcile one
+post under a transaction-scoped advisory lock, and unsupported registry rows
+are pruned. Edge identity therefore cannot duplicate under concurrency, and a
+replacement cannot leave a buyer-visible orphan edge.
+
+Ontology (`docs/ontology/lineageweave-kg.ttl`): `:Team a owl:Class ;
+rdfs:subClassOf org:OrganizationalUnit` (same W3C ORG grounding as
+ADR 0007's `:RoleActorTeam`, but a distinct term -- `:Team` is a
+`cataloged_team` row with a stable identity, `:RoleActorTeam` is the
+per-row `actor_type_code` classification, the same
+`:Person`/`:RoleActorPerson` split ADR 0006 already established).
+`:mentionsTeam` / `:teamAffiliatedWith` / `:mentionsOrganization` are
+new, distinct object properties rather than widening `:mentions`'s
+domain/range -- stating `rdfs:domain :mentions` twice (once `:Person`,
+once `:Team`) would let RDFS entail every `:mentions` subject is BOTH,
+which is false.
+
+## Consequences
+
+- Team/organization mention persistence only runs when
+ `summary.roles_and_responsibilities` is non-empty (a real, cheap
+ guard, not a correctness gap) -- a post with no R&R never touches
+ `cataloged_team`/`post_organization_mention` at all.
+- **Documented, deliberate gap**: R&R never *creates* a new
+ `cataloged_person` row, only joins to an existing one by exact name.
+ Two failure modes follow from this, both accepted for now: (1) a
+ person named only in R&R (never by Keyman on any post) gets no
+ catalog identity at all until/unless Keyman also names them; (2) an
+ exact-name join has the same same-name-collision risk
+ `keyman_ingestion._upsert_person`'s job-title disambiguation exists
+ to catch, but R&R's join here does not run that check (R&R's own
+ prompt does not currently capture a job title). A future slice could
+ extend the R&R prompt to also ask for `person_side_code` (and
+ optionally a title) so R&R could safely originate new person
+ identities the same way Keyman does, closing this gap properly rather
+ than working around it with a guess.
+- `cataloged_team` uses PostgreSQL's
+ `UNIQUE NULLS NOT DISTINCT (team_name, affiliated_organization_name)`.
+ NULL affiliation therefore participates in the identity key: two
+ bare-team rows with the same name conflict and the atomic upsert
+ returns one shared `team_id`. This database constraint, not a
+ read-before-insert application check, closes the concurrent duplicate
+ race for both affiliated and unplaced teams.
+
+## Related
+
+Depends on [ADR 0006](0006-role-responsibility-agent-ontology.md) and
+[ADR 0007](0007-team-actor-type.md) (actor *type*) and
+`lineageweave.corporate_hierarchy_resolution` (Bhattacharya & Getoor,
+2007, cited there) for the organization-matching this ADR reuses rather
+than re-deriving.
+
+## References (APA 7th)
+
+Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in relational data. *ACM Transactions on Knowledge Discovery from Data*, 1(1), Article 5. https://doi.org/10.1145/1217299.1217304
+
+Reynolds, D. (Ed.). (2014). *The organization ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/vocab-org/
diff --git a/docs/adr/0010-corporate-hierarchy-auto-creation.md b/docs/adr/0010-corporate-hierarchy-auto-creation.md
new file mode 100644
index 000000000..d034fe9b2
--- /dev/null
+++ b/docs/adr/0010-corporate-hierarchy-auto-creation.md
@@ -0,0 +1,100 @@
+# ADR 0010 — a real counterparty organization is auto-created into the corporate hierarchy, not left permanently unresolved
+
+**Decision status:** Accepted
+**Date:** 2026-08-14
+
+## Context
+
+`lineageweave.corporate_hierarchy_resolution`'s similarity-based
+matching only ever locates an *already-cataloged* `corporate_entity`
+row -- it has no path to create one. This was fine while the only
+`corporate_entity` catalog was synthetic demo fixtures with a handful
+of names extraction would naturally already know. A synthetic batch
+where the catalog holds only the employer's own two-row hierarchy
+exposes the same gap: every counterparty named in a post is, by
+definition, outside that catalog. Similarity matching then resolves
+**0 affiliation rows and 0 R&R organization-actor mentions** -- the
+standing integrated customer-affiliate tree requirement (Harbor Group
+-> Harbor Devices Korea -> ... in the synthetic brief) stays empty
+until a verified creation path exists.
+
+## Decision
+
+`get_or_create_corporate_entity` (`backend/app/corporate_entity_ingestion.py`)
+extends the existing resolution pipeline with a creation fallback, not
+a competing algorithm:
+
+1. Try `resolve_corporate_entity` (similarity matching, unchanged,
+ Bhattacharya & Getoor, 2007) first -- an already-cataloged entity
+ still resolves exactly as before.
+2. On a miss, ask an LLM
+ (`lineageweave.corporate_hierarchy_inference.CorporateHierarchyInferenceClient`)
+ to propose this organization's place in the Group -> Company ->
+ Plant hierarchy (`corporate_entity_level`, ADR 0004's existing SKOS
+ `skos:broader`/`skos:narrower` structure) from the post's own text --
+ never inventing a hierarchy the text gives no evidence for; the
+ model may decline with `UNKNOWN`.
+3. The proposal is only trusted after
+ `lineageweave.relation_verification`'s existing Searxng
+ corroboration (the same reused verification client
+ `organization_name_resolution`/ADR 0008 already established this
+ pattern for) -- an uncorroborated or unavailable-channel proposal
+ creates nothing, same never-trust-an-unverified-guess discipline as
+ every other channel here.
+4. Only then is a real new `corporate_entity` row inserted. A proposed
+ parent organization is itself resolved-or-created first (bounded to
+ 4 levels of recursion, so a misbehaving response chain cannot spin
+ into unbounded row creation), so the whole chain gets real
+ `parent_entity_id` links, not an orphaned single-level row.
+
+**Auto-created code namespace**: `corporate_entity_code` is also the
+real login "corp code" attribute Keycloak issues via the `corp_code`
+token claim (`docker/keycloak/realm-export.json`) -- an auto-created
+counterparty row must never collide with that namespace. Every
+auto-created code is prefixed `AUTO-` followed by a deterministic hash
+of the entity name (same name -> same code, so a genuine concurrent
+duplicate-creation race collides on the real SQL `unique` constraint
+and self-resolves via `on conflict`, rather than creating two rows for
+one organization under two different codes).
+
+Wired into both existing organization-resolution call sites --
+`keyman_ingestion.py`'s person-affiliation loop and
+`post_summary_ingestion.py`'s R&R organization-actor loop -- rather
+than a third, separate code path, so both routes to `corporate_entity`
+share one creation policy.
+
+## Consequences
+
+- A wrong hierarchy placement (level or parent) is a real risk this
+ design accepts, bounded by the same LLM-judgment-call discipline
+ ADR 0007's team-vs-organization classification already accepts: the
+ raw name is never lost regardless (it is the `entity_name` itself),
+ so a wrong placement is a correctable graph-structure error, not lost
+ data.
+- The `AUTO-` code namespace is a real, deliberate simplification: an
+ operator wanting a genuinely curated corp-code scheme for these
+ entities later would need to re-code them, not just re-run
+ extraction -- accepted because the alternative (leaving every
+ counterparty unresolved forever) is strictly worse for this
+ product's actual purpose.
+- Every LLM/search call in this path already existed for a different
+ purpose (`organization_name_resolution`'s resolution call shape,
+ `relation_verification`'s verification client) -- no new provider
+ integration was built, keeping this consistent with the project's
+ standing discipline of reusing an existing channel over adding a new
+ one wherever the shape already fits.
+
+## Related
+
+Extends [ADR 0008](0008-organization-abbreviation-resolution.md)'s
+reuse-the-verification-client pattern and
+[ADR 0009](0009-cross-post-actor-identity.md)'s cross-post identity
+work -- an R&R organization actor's identity is now genuinely resolved
+to a real corporate hierarchy node, not left as free text even when no
+prior mention of it existed anywhere in the dataset.
+
+## References (APA 7th)
+
+Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in relational data. *ACM Transactions on Knowledge Discovery from Data*, 1(1), Article 5. https://doi.org/10.1145/1217299.1217304
+
+Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization system reference*. World Wide Web Consortium. https://www.w3.org/TR/skos-reference/
diff --git a/docs/adr/0011-prov-o-standard-relations.md b/docs/adr/0011-prov-o-standard-relations.md
new file mode 100644
index 000000000..8d214a224
--- /dev/null
+++ b/docs/adr/0011-prov-o-standard-relations.md
@@ -0,0 +1,79 @@
+# ADR 0011: Preserve W3C PROV-O as a standards-complete provenance layer
+
+- **Status:** Accepted
+- **Date:** 2026-08-14
+- **Decision owners:** ContextualWisdomLab / LineageWeave
+- **Standard:** W3C PROV-O Recommendation, 30 April 2013
+
+## Context
+
+PR #74 introduced PROV-O-grounded actor categories, but LineageWeave's existing `knowledge_graph_edge` table only represents a compact binary navigation graph. It cannot faithfully represent PROV-O datatype properties such as `prov:startedAtTime` and `prov:value`, nor the intermediate `prov:Influence` resources required by qualified relations. Adding every standard property as another product edge code would therefore flatten the standard and lose the very provenance details it is intended to preserve.
+
+The Recommendation defines 30 classes and 50 normative properties grouped into Starting Point, Expanded, and Qualified terms. Tables 2 and 3 define 14 qualification patterns, and consuming applications should treat each qualified form as implying the corresponding unqualified form. Appendix B reserves interoperable inverse names while intentionally preferring the standard property direction.
+
+## Decision
+
+1. Add a separate `lineageweave.prov_o` runtime containing the complete normative class/property registry.
+2. Validate object-versus-datatype shape, domain, range, subclass membership, timezone-aware `xsd:dateTime`, and Appendix B inverse aliases before accepting an assertion.
+3. Deterministically materialize:
+ - transitive property hierarchy;
+ - defined inverse properties;
+ - `prov:alternateOf` symmetry;
+ - all 14 qualified-to-unqualified implications;
+ - qualified Generation/Invalidation/Start/End `prov:atTime` shortcuts.
+4. Serialize with the exact `http://www.w3.org/ns/prov#` namespace through rdflib.
+5. Store standards-complete provenance in normalized `provenance_*` tables. Keep `knowledge_graph_edge` as a buyer-facing navigation projection and bridge existing nodes through `provenance_resource_binding` rather than conflating the two models.
+6. Catalog every Appendix B inverse name. Names that are not normative properties are accepted only as import aliases and rewritten by reversing endpoints into the preferred PROV-O relation.
+7. Map LineageWeave `Post`, `Person`, `CorporateEntity`, and `Team` classes to PROV-O in a separate support profile that imports rather than redefines the W3C ontology.
+
+## Relational model
+
+```mermaid
+erDiagram
+ provenance_class_definition ||--o{ provenance_class_hierarchy : child
+ provenance_class_definition ||--o{ provenance_class_hierarchy : parent
+ provenance_relation_definition ||--o{ provenance_relation_domain : has
+ provenance_class_definition ||--o{ provenance_relation_domain : constrains
+ provenance_relation_definition ||--o{ provenance_relation_resource_range : has
+ provenance_class_definition ||--o{ provenance_relation_resource_range : constrains
+ provenance_relation_definition ||--o{ provenance_relation_hierarchy : child
+ provenance_relation_definition ||--o{ provenance_relation_hierarchy : parent
+ provenance_relation_definition ||--|| provenance_inverse_definition : documents
+ provenance_relation_definition ||--o| provenance_qualification_definition : qualifies
+ provenance_resource ||--o{ provenance_resource_type : typed_as
+ provenance_class_definition ||--o{ provenance_resource_type : classifies
+ provenance_resource ||--o{ provenance_assertion : subject
+ provenance_relation_definition ||--o{ provenance_assertion : predicate
+ provenance_resource ||--o{ provenance_assertion : resource_object
+ provenance_literal_value ||--o{ provenance_assertion : literal_object
+ provenance_assertion ||--o{ provenance_assertion_derivation : derived
+ provenance_assertion ||--o{ provenance_assertion_derivation : premise
+```
+
+## Consequences
+
+### Positive
+
+- Complete PROV-O interchange without lossy custom edge codes.
+- Qualified provenance retains role, plan, activity, usage, generation, time, and location detail.
+- Existing LineageWeave navigation and RWR behavior remains stable.
+- Database and runtime share stable multiword snake-case codes while preserving exact W3C IRIs.
+- Assertions fail closed in both Python and PostgreSQL.
+
+### Costs
+
+- The product now has a standards graph and a navigation projection; projection logic must remain explicit.
+- Full OWL reasoning is not embedded. The runtime intentionally materializes only the Recommendation rules needed for deterministic product behavior.
+- Bundle serialization remains RDF-technology-specific; the relational layer stores the bundle resource without prescribing TriG.
+
+## Rejected alternatives
+
+- **Add 50 `edge_type` lookup rows:** rejected because literal and qualified relations cannot be represented.
+- **Store arbitrary RDF triples only:** rejected because domain/range and relational integrity would be deferred to callers.
+- **Define every inverse as another preferred property:** rejected because Appendix B explicitly warns that unconstrained inverse proliferation reduces interoperability.
+
+## Verification
+
+- Exact registry tests for 30 classes, 50 properties, 6 datatype properties, 14 qualification mappings, and all 44 object-property inverse names.
+- Behavior-sensitive tests for validation, subclass domains, every qualification implication, superproperty closure, inverse/symmetry, direct time inference, RDF serialization, SQL seed completeness, support-profile mapping, and public docstrings.
+- Owned production module statement and branch coverage: 100%.
diff --git a/docs/adr/0012-corporate-entity-creation-lock.md b/docs/adr/0012-corporate-entity-creation-lock.md
new file mode 100644
index 000000000..f5a01d859
--- /dev/null
+++ b/docs/adr/0012-corporate-entity-creation-lock.md
@@ -0,0 +1,45 @@
+# ADR 0012 — corporate-entity creation is serialized with a Postgres advisory transaction lock, not split into separate read/write databases
+
+**Decision status:** Accepted
+**Date:** 2026-08-14
+
+## Context
+
+ADR 0010's `get_or_create_corporate_entity` introduced concurrent writes on the organization-creation path. A deterministic synthetic regression fixture reproduced a `DeadlockDetectedError`: two transactions created different `corporate_entity` rows in opposite order, so each transaction waited for a row-level lock held by the other.
+
+This repository records the reproducible concurrency shape rather than customer, organization, batch, or production-log details. The architectural defect is independent of any particular dataset: multi-worker extraction can encounter child and parent organizations in different orders.
+
+## Decision
+
+Serialize only the *creation* write path with one named Postgres advisory transaction lock:
+
+```sql
+pg_advisory_xact_lock(
+ hashtext('lineageweave:corporate_entity_creation')
+)
+```
+
+The transaction-scoped lock is acquired immediately before persistence and is released automatically by the enclosing transaction's commit or rollback (PostgreSQL Global Development Group, 2024).
+
+1. The lock is acquired only after inference and verification complete. Holding it across network I/O would unnecessarily serialize unrelated workers.
+2. Under the lock, candidates are reloaded and similarity matching is repeated. Another transaction may have committed the same entity after the caller's original snapshot was read.
+3. The key is one fixed creation-path key rather than a per-name key. Per-name locking still permits the opposite-order multi-entity deadlock shape `A: [X, Y]` versus `B: [Y, X]`.
+4. The already-cataloged resolution path remains lock-free.
+
+Splitting the system into separate read and write databases was considered and rejected. Replica separation does not resolve a write-write lock-ordering defect, while a transaction-scoped advisory lock directly enforces one global creation order.
+
+## Consequences
+
+- New entity creation is serialized cluster-wide. This is accepted because creation is the uncommon branch and correctness dominates throughput for this path.
+- Resolution of existing entities remains concurrent and does not acquire the advisory lock.
+- The lock is re-entrant within the same PostgreSQL session and transaction, so bounded parent-chain recursion does not self-deadlock.
+- No schema split or additional service is required.
+- The regression suite must retain concurrent opposite-order creation coverage.
+
+## Related
+
+This decision extends [ADR 0010](0010-corporate-hierarchy-auto-creation.md) with an explicit concurrency-safety property.
+
+## References — APA 7th
+
+PostgreSQL Global Development Group. (2024). *PostgreSQL 17 documentation: Advisory lock functions*. https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md
new file mode 100644
index 000000000..b60164dc6
--- /dev/null
+++ b/docs/adr/0013-normalized-analysis-run-registry.md
@@ -0,0 +1,273 @@
+# ADR 0013 — Milestone 2 uses a normalized, additive analysis-run registry
+
+**Decision status:** Accepted on this active PR; not protected-main truth until merge
+**Date:** 2026-08-15
+**Depends on:** ADR 0011 standards-complete provenance separation and ADR 0012 corporate-entity creation locking
+
+## Context
+
+LineageWeave has a reviewed React/FastAPI/PostgreSQL product, compact lineage
+navigation, normalized actor identity, report persistence, and a separate
+standards-complete PROV-O layer. Milestone 2 must analyze operator-authorized
+PostgreSQL evidence without replacing that product, duplicating cross-service
+databases, or committing private source identity and content to a public
+repository.
+
+A retained experiment proved that direct PostgreSQL analysis is feasible, but
+its parallel application and denormalized run record cannot become product
+truth. The product needs a small durable root that answers:
+
+- which immutable capture was used;
+- which evidence was available by the run's knowledge cutoff;
+- which authenticated account requested the work;
+- which product scope and reproducibility digests governed the run;
+- which aggregate counts reconcile the capture;
+- which legal lifecycle transitions occurred.
+
+The registry does not store source SQL, DSNs, raw posts, inline images, provider
+payloads, credentials, raw exceptions, or another service's application rows.
+
+## Decision
+
+Migration `0018_analysis_run_registry.sql` introduces five normalized relations
+and one read projection.
+
+```mermaid
+erDiagram
+ ANALYSIS_SOURCE_SNAPSHOT ||--o{ ANALYSIS_SOURCE_COUNT : reconciles
+ ANALYSIS_SOURCE_SNAPSHOT ||--o{ ANALYSIS_RUN : anchors
+ USER_ACCOUNT ||--o{ ANALYSIS_RUN : requests
+ ANALYSIS_RUN ||--o| ANALYSIS_RUN_SCOPE : limits
+ CORPORATE_ENTITY |o--o{ ANALYSIS_RUN_SCOPE : scopes
+ PROCESS_UNIT |o--o{ ANALYSIS_RUN_SCOPE : scopes
+ ANALYSIS_RUN ||--o{ ANALYSIS_RUN_STATUS_EVENT : records
+
+ ANALYSIS_SOURCE_SNAPSHOT {
+ uuid analysis_source_snapshot_id PK
+ text snapshot_sha256 UK
+ text source_contract_version
+ timestamptz maximum_available_time
+ timestamptz captured_at
+ }
+ ANALYSIS_SOURCE_COUNT {
+ uuid analysis_source_snapshot_id PK,FK
+ text count_type_code PK,FK
+ bigint count_value
+ }
+ ANALYSIS_RUN {
+ uuid analysis_run_id PK
+ uuid analysis_source_snapshot_id FK
+ uuid requested_by_account_id FK
+ text idempotency_key UK
+ timestamptz knowledge_cutoff
+ text configuration_sha256
+ text model_contract_sha256
+ text prompt_bundle_sha256
+ text code_revision_sha
+ }
+ ANALYSIS_RUN_SCOPE {
+ uuid analysis_run_id PK,FK
+ text scope_kind_code FK
+ uuid corporate_entity_id FK
+ uuid process_unit_id FK
+ text scope_key
+ }
+ ANALYSIS_RUN_STATUS_EVENT {
+ uuid analysis_run_id PK,FK
+ int status_ordinal PK
+ text status_code FK
+ timestamptz occurred_at
+ timestamptz recorded_at
+ text failure_code
+ boolean retryable
+ }
+```
+
+### Temporal ownership
+
+`analysis_source_snapshot.maximum_available_time` is an evidence fact: the
+latest time at which any admitted fact became available. `analysis_run.knowledge_cutoff`
+is an analysis fact: the latest information that this particular run may use.
+A reusable capture therefore does **not** own one knowledge cutoff.
+
+Run creation locks the snapshot and requires:
+
+```text
+maximum_available_time <= knowledge_cutoff <= requested_at
+captured_at <= requested_at
+```
+
+This aggregate guard complements TEPP's finer event, assertion, document,
+system, availability, and cutoff clocks. It does not replace TEPP temporal or
+psychometric computation.
+
+### Identity and idempotency
+
+Every run references a real `user_account`. `requested_by_account_id` is not
+nullable. Idempotency keys are trimmed, control-free canonical values and are
+unique per authenticated account rather than
+globally, because independent callers may legitimately choose the same opaque
+client key. A later repository must compare request digests on retry and return
+a conflict when the same account/key names different evidence or configuration.
+
+### Immutability and concurrency
+
+Snapshot identity and availability reject updates. Aggregate count values reject
+updates. Count insert/delete and first run creation acquire the same snapshot-row
+lock before checking whether a run exists. This shared lock order closes the
+race in which a count set and first derivation could otherwise both commit.
+After the first run, the complete count set is frozen.
+
+The analysis request and its authorization scope reject updates and deletes.
+Lifecycle changes are represented only by append-only status events, so a cascade
+cannot erase the derivation root or its access boundary.
+
+### Lifecycle state machine
+
+The parent run row serializes status appends. Events require contiguous
+ordinals, monotonic occurrence time, and these transitions:
+
+```text
+pending -> running | cancelled
+running -> succeeded | failed | cancelled
+succeeded | failed | cancelled -> terminal
+```
+
+The first event must be `pending`, requires an immutable scope, and cannot predate
+the run request. Failed events require a lowercase machine-code identifier; raw
+exception text is prohibited. `recorded_at` is overwritten with database system
+time on every insert and cannot precede `occurred_at`.
+`analysis_run_current_status` is a view, not a second mutable state authority.
+
+### Authorization scope
+
+`analysis_run_scope` stores one immutable all-visible, corporate-entity,
+process-unit, or thread-group scope. Its shape is database constrained and the
+first lifecycle event is rejected until it exists. The next repository/API slice
+must insert run, scope, and first status in one
+transaction and apply the existing RBAC/ABAC contract when listing or reading
+runs. This migration does not claim that an API or UI exists.
+
+### Service boundaries
+
+- **LineageWeave** owns product run identity, authorized scope, lifecycle,
+ aggregate reconciliation, and product-visible derivation references.
+- **TEPP** owns exact evidence spans, temporal/event measurement,
+ multilevel/multiple-membership psychometrics, calibration, and semantic-span
+ budgeting through a versioned import or REST contract.
+- **contextual-orchestrator** owns provider-neutral model routing and bounded
+ single-model versus multi-agent test-time compute allocation through its
+ reviewed API.
+- **fast-mlsirm** owns Rust psychometric arithmetic and calibration interfaces.
+- **Valkey** remains the event queue. Durable registry truth remains in
+ PostgreSQL; a later outbox slice bridges the two.
+
+No component reads another service's private application tables.
+
+## Alternatives considered
+
+### Merge the parallel experiment unchanged
+
+Rejected. It replaces reviewed product history, duplicates web and identity
+surfaces, and creates a second database authority.
+
+### Store one JSON document per run
+
+Rejected. Signed external manifests may be JSON artifacts, but relational
+identity, scope, counts, clocks, and lifecycle need independent constraints,
+authorization, and query plans.
+
+### Put the registry only in Valkey
+
+Rejected. Queue state is transient and replayable. Audit identity,
+idempotency, temporal eligibility, and retention evidence require PostgreSQL.
+
+### Store knowledge cutoff on the snapshot
+
+Rejected. One immutable capture can support multiple analysis requests with
+different historical cutoffs. Putting the cutoff on the snapshot violates the
+functional dependency and forces duplicate snapshots.
+
+## Security, privacy, and compliance consequences
+
+- Necessary PII remains in its authorized source/product tables rather than
+ being blanket-masked into operational uselessness.
+- This registry stores opaque UUIDs, digests, bounded machine codes, aggregate
+ counts, and clocks only.
+- Logs and public acceptance evidence must not include SQL, DSNs, raw source
+ text, images, secrets, provider payloads, or private source identifiers.
+- Artifact bodies remain in access-controlled deployment storage and are linked
+ later by content digest and policy-bound reference.
+- The design supports SOC 2 and CSAP evidence collection through explicit actor,
+ configuration, status, retention, and rollback contracts; it does not claim
+ certification.
+- Database RLS is deferred because the current API uses one pooled service
+ identity and application-level RBAC/ABAC. Adopting actor-bound RLS requires a
+ separate ADR and transaction-scoped identity propagation.
+
+## Failure and rollback
+
+Migration replay is idempotent and rejects lookup-category collisions. The
+rollback refuses to remove non-empty registry relations. Evidence must first be
+exported or explicitly deleted under an approved retention procedure. An empty
+rollback removes the view, tables, functions, and lookup rows and is itself
+replayable.
+
+## Verification
+
+Acceptance requires:
+
+- real-PostgreSQL migration and replay;
+- valid snapshot, aggregate, scope, and lifecycle persistence;
+- distinct cutoffs over one snapshot;
+- rejection of future-information leakage;
+- account-scoped idempotency;
+- snapshot, count, run, and authorization-scope immutability;
+- deletion resistance for request and scope audit evidence;
+- scope-required lifecycle, request-time ordering, and database-owned record time;
+- canonical idempotency and bounded machine-code failure identifiers;
+- count/run concurrency serialization;
+- pending-first, contiguous, monotonic, legal status transitions;
+- append-only status evidence;
+- fail-closed rollback;
+- two-or-more-word `snake_case` database-object names;
+- complete repository, security, SAST, documentation, and public-content gates
+ on the exact merge head.
+
+## Follow-up sequence
+
+1. Add a transaction repository that creates snapshot, counts, run, scope, and
+ first status atomically and compares request digests on idempotent retries.
+ `POST /api/analysis-runs` now records that Pending write (ADR 0017);
+ reconstruction and live TEPP execution remain later slices.
+2. Add RBAC/ABAC-protected run list/detail endpoints and the DB-grounded
+ read-only administrator surface.
+3. Add a normalized PostgreSQL outbox and Valkey delivery worker.
+4. Add TEPP and contextual-orchestrator adapters only after their versioned
+ contracts are present on reviewed main branches. Seed now records a
+ Failed TEPP run through `tepp_client` on the shared Demo Corp snapshot;
+ a live transport remains a later slice. A missing or unused TEPP
+ envelope must stay Failed (`tepp_not_available` /
+ `tepp_result_not_persisted`) and must not write a local psychometric
+ substitute.
+5. Execute private actual-data analysis and store only signed aggregate and
+ reproducibility manifests outside public source control.
+6. Run browser E2E through real OIDC, product navigation, and evidence drill-down.
+
+## References — APA 7th
+
+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).
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation:
+5.5. Constraints*. https://www.postgresql.org/docs/current/ddl-constraints.html
+
+World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C
+Recommendation). https://www.w3.org/TR/prov-o/
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation).
+https://www.w3.org/TR/owl-time/
diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md
new file mode 100644
index 000000000..500c2bc2a
--- /dev/null
+++ b/docs/adr/0014-authorized-analysis-run-read.md
@@ -0,0 +1,67 @@
+# ADR 0014 — Analysis-run evidence is an authorized, source-redacting read
+
+**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
+**Refs:** Issue #79 (Milestone 2 parent); closed PR #77 is read-only evidence
+
+## Context
+
+PR #89 persists analysis-run identity, aggregate reconciliation, scope,
+and lifecycle without exposing a product API. Buyers still cannot see
+whether a lineage reconstruction ran, succeeded, or reconciled how many
+documents. Closed PR #77 exposed analysis records through a parallel
+application that also stored raw metadata payloads -- that shape cannot
+become protected product truth.
+
+## Decision
+
+LineageWeave owns a fail-closed read projection of the #89 registry:
+
+- `GET /api/analysis-runs` and `GET /api/analysis-runs/{id}` require
+ `post_read`.
+- Visibility is evaluated in SQL. A run is visible when the caller
+ requested it, or the scope is a corporate entity / process unit /
+ thread group the caller may already walk. `all_visible` stays
+ requester-only so it cannot broaden another tenant's evidence.
+- Hidden runs return 404, not 403, and never appear in the list.
+- The payload carries lookup labels and non-negative aggregate counts.
+ It does not carry source SQL, DSNs, raw records, image bytes, provider
+ payloads, credentials, or another service's table names.
+- `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.
+- 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
+ call a raw model API.
+
+## Consequences
+
+`make seed` writes one synthetic Demo Corp lineage run and one TEPP
+run on the same snapshot so the existing React home page can show both
+kinds without a second application. The TEPP run is Failed /
+`tepp_not_available` when the default transport is missing -- the list
+keeps that machine code off the caption (this decision) and instead
+tells the operator to open the TEPP run, then connect the measurement
+service. A failed lineage row tells the operator to retry
+reconstruction, not to connect TEPP. A failed period-report row
+tells the operator to rebuild the report from a current snapshot.
+A pending or running TEPP row must not claim a calibrated
+measurement. A pending lineage row says reconstruction has not
+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.
+
+## References
+
+American Educational Research Association, American Psychological
+Association, & National Council on Measurement in Education. (2014).
+*Standards for educational and psychological testing*. American
+Educational Research Association.
+
+Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV
+ontology* (W3C Recommendation). World Wide Web Consortium.
+https://www.w3.org/TR/2013/REC-prov-o-20130430/
diff --git a/docs/adr/0013-adaptive-contextual-orchestrator-default.md b/docs/adr/0015-adaptive-contextual-orchestrator-default.md
similarity index 96%
rename from docs/adr/0013-adaptive-contextual-orchestrator-default.md
rename to docs/adr/0015-adaptive-contextual-orchestrator-default.md
index ee0402075..433432fbb 100644
--- a/docs/adr/0013-adaptive-contextual-orchestrator-default.md
+++ b/docs/adr/0015-adaptive-contextual-orchestrator-default.md
@@ -1,4 +1,4 @@
-# ADR-0013: Adaptive contextual-orchestrator mode is the default
+# ADR-0015: Adaptive contextual-orchestrator mode is the default
- Status: Accepted
- Date: 2026-08-16
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..089443374
--- /dev/null
+++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
@@ -0,0 +1,67 @@
+# ADR 0016 — Analysis-run visible posts honor the run knowledge cutoff
+
+**Decision status:** Accepted
+**Date:** 2026-08-16
+
+## Context
+
+ADR 0013 stores `analysis_run.knowledge_cutoff` as the analysis clock:
+what that run was allowed to know. The registry trigger already refuses
+a cutoff earlier than `analysis_source_snapshot.maximum_available_time`.
+The home-page detail, however, listed every ABAC-visible title in the
+run's scope from live `source_post` rows. Fixture and seed posts that
+defaulted to `created_at = now()` therefore appeared inside a January
+2026 run, including a later own-corp follow-up the buyer would treat as
+part of that reconstruction.
+
+W3C Time Ontology in OWL (Hobbs & Pan, 2017) and ISO 8601-1:2019 keep
+distinct clocks from collapsing. A knowledge cutoff is not "posts the
+account can see today."
+
+## Decision
+
+`fetch_visible_scope_posts` filters `created_at <= knowledge_cutoff` on
+every scope branch (corporate entity, process unit, thread group, and
+all-visible). ABAC visibility is applied after that temporal gate.
+Click-through still opens the live post body -- post versioning is a
+later slice -- but the run list itself must not advertise a post the
+run was not allowed to know. The detail must say that next action
+plainly: compare the opened body with this cutoff before treating it
+as reconstructed evidence.
+
+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
+run.
+
+## Consequences
+
+- After `make seed`, the Demo Corp lineage run lists Demo public post
+ and other in-cutoff Demo Corp titles. The later fixture account-review
+ post (2026-02-10) does not appear.
+- Open the run, read the live-body warning, then open a listed post
+ and compare it with the cutoff date.
+- 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.
+- Thread-group *run list* visibility now uses the same cutoff
+ (ADR 0018). A later public post cannot surface a previously hidden
+ thread-group run.
+
+## References
+
+International Organization for Standardization. (2019). *ISO 8601-1:2019:
+Date and time—Representations for information interchange—Part 1: Basic
+rules* (confirmed 2024; Amendment 1:2022).
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
+
+World Wide Web Consortium. (2018). *Accessible name and description
+computation 1.1* (W3C Recommendation).
+https://www.w3.org/TR/accname-1.1/
diff --git a/docs/adr/0017-authorized-analysis-run-create.md b/docs/adr/0017-authorized-analysis-run-create.md
new file mode 100644
index 000000000..e3a535a18
--- /dev/null
+++ b/docs/adr/0017-authorized-analysis-run-create.md
@@ -0,0 +1,56 @@
+# ADR 0017 — Operators request an analysis run through the product API
+
+**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; ADR 0016 knowledge-cutoff posts
+**Refs:** Issue #79 (Milestone 2 parent); ADR 0013 follow-up 1
+
+## Context
+
+Home Analysis runs could list seeded lineage and TEPP rows, but a buyer
+could not request a new run. Seed-only evidence is a demo, not a product.
+ADR 0013 already required a transaction that creates snapshot, counts,
+run, scope, and the first status atomically. Follow-up 3 (outbox / worker)
+still owns reconstruction and live TEPP execution.
+
+## Decision
+
+`POST /api/analysis-runs` is the authorized write:
+
+- `post_read` is enough. The caller may only cover a corporate entity
+ they already walk. An unaffiliated corp is 404, not 403.
+- The capture digest hashes scope, entity, cutoff, and authorized post
+ ids — never a post body, DSN, or source SQL.
+- The write inserts snapshot, aggregate counts, `analysis_run`,
+ `analysis_run_scope`, and `analysis_status_pending` in one transaction.
+- The first status is Pending. This slice does not reconstruct lineage
+ and does not call TEPP. A missing measurement stays Failed only on the
+ seed path that already goes through `tepp_client`.
+- Account-scoped idempotency compares `configuration_sha256`. An omitted
+ cutoff is hashed as `unspecified` so a retry of the same client key
+ does not conflict because the clock moved.
+- The response is the same authorized detail as `GET /api/analysis-runs/{id}`.
+
+## Consequences
+
+The home panel's **Request a lineage reconstruction** button records a
+Pending row the operator can open immediately. Reconstruction, TEPP
+transport, and the outbox worker remain later slices. Do not stamp
+Succeeded or invent a theta from this write.
+
+## References — APA 7th
+
+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
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md
new file mode 100644
index 000000000..ae0a1c331
--- /dev/null
+++ b/docs/adr/0018-related-nodes-team-org-walk.md
@@ -0,0 +1,68 @@
+# ADR 0018 — Related-node walks include team and organization mention edges
+
+**Decision status:** Accepted
+**Date:** 2026-08-16
+
+## Context
+
+ADR 0009 persists `edge_mention_team`, `edge_team_affiliation`, and
+`edge_mention_organization` so a cataloged team or organization can
+become a cross-post Knowledge Graph clue. The buyer-visible related-node
+walk (`load_visible_subgraph` + Tong et al., 2006 random walk with
+restart) still loaded only person mention, co-mention, and affiliation
+edges, and returned an empty graph when a visible post had no people.
+A team-only follow-up therefore never appeared as a related node, and
+clicking an R&R team name had no catalog id to start a walk.
+
+The same temporal honesty ADR 0016 applied to run *detail* posts was
+still missing from thread-group *run list* visibility: a later public
+post in that thread group could surface a run the account was not
+allowed to know at `knowledge_cutoff`.
+
+ADR 0017 already records an authorized Pending analysis-run write.
+This decision is the related-node walk, not that create path.
+
+## Decision
+
+`load_visible_subgraph` loads person, team, and organization mention
+channels independently. Empty person evidence is not a reason to drop
+team or organization edges. `hydrate_related_nodes` labels
+`cataloged_team` rows. `GET /api/teams/{team_id}/related` starts the
+same RWR walk Keyman and corporate-entity related already use.
+`visible_affiliation_post_ids` unions direct `post_organization_mention`
+rows with person-affiliation posts so an org-only mention can start a
+walk.
+
+The summary payload exposes `catalog_node_id` / `catalog_node_type_code`
+from the catalog foreign keys stored on `post_summary_role` (ADR 0019).
+The popup turns that name into a related-node button. Do not reconstruct
+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`.
+
+## Consequences
+
+- Open a post whose R&R names 설계팀, then click the team. Sibling posts
+ that mention the same cataloged team appear as related nodes.
+- Click a related team chip the same way you already click a person or
+ organization chip.
+- A later public post in a thread group no longer lists a January run
+ that could not have known that post.
+
+## References
+
+International Organization for Standardization. (2019). *ISO 8601-1:2019:
+Date and time—Representations for information interchange—Part 1: Basic
+rules* (confirmed 2024; Amendment 1:2022).
+
+Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web
+Consortium. https://www.w3.org/TR/vocab-org/
+
+Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with
+restart and its applications. *Proceedings of the Sixth International
+Conference on Data Mining (ICDM'06)*, 613–622.
+https://doi.org/10.1109/ICDM.2006.70
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
diff --git a/docs/adr/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md
new file mode 100644
index 000000000..32b5d0a09
--- /dev/null
+++ b/docs/adr/0019-role-catalog-identity.md
@@ -0,0 +1,65 @@
+# ADR 0019 — R&R catalog identity lives on the role row
+
+**Decision status:** Accepted
+**Date:** 2026-08-16
+
+## Context
+
+ADR 0009 writes `post_team_mention` and `post_organization_mention` so a
+cataloged team or organization can start a related-node walk. ADR 0018
+exposes `catalog_node_id` on the summary payload by joining those
+mentions back to `post_summary_role` through `actor_name`.
+
+`corporate_entity.entity_name` is not unique. Two catalog rows can share
+a display name (different `corporate_entity_code`, different parents).
+A fetch join on name therefore:
+
+- attaches a homonym that this post never resolved, or
+- duplicates the role when more than one same-named row exists.
+
+Mention tables are post-scoped, not role-scoped. They cannot reconstruct
+which catalog id was chosen for a specific R&R row. That reconstruction
+is a transitive dependency on a non-key attribute, so it is not third
+normal form (Codd, 1970; Date, 2019).
+
+Team identity is already unique on
+`(team_name, affiliated_organization_name)`. Organization identity is
+not.
+
+## Decision
+
+`post_summary_role` stores the resolved catalog foreign keys
+(`cataloged_team_id`, `cataloged_corporate_entity_id`) written during
+`persist_post_summary`. `fetch_persisted_summary` reads those columns.
+It does not join `corporate_entity` by `entity_name`.
+
+Migration `0019_role_catalog_identity.sql` backfills existing rows from
+a post-scoped mention only when the name match is unique on that post.
+Two same-named mentions stay unbound rather than guessing.
+
+## Consequences
+
+- Open a post whose R&R names an organization that shares a display
+ name with another catalog row. The button walks the resolved id, not
+ the homonym.
+- Clicking that name still uses `GET /api/corporate-entities/{id}/related`
+ or `GET /api/teams/{id}/related`. Authz stays person/entity-parity:
+ a team mentioned only on another corp's private post is 403; an
+ unknown UUID is 404.
+
+## References
+
+Codd, E. F. (1970). A relational model of data for large shared data
+banks. *Communications of the ACM, 13*(6), 377–387.
+https://doi.org/10.1145/362384.362685
+
+Date, C. J. (2019). *Database design and relational theory: Normal forms
+and all that jazz* (2nd ed.). Apress.
+https://doi.org/10.1007/978-1-4842-5540-7
+
+International Organization for Standardization. (2023). *ISO/IEC
+11179-1:2023: Information technology—Metadata registries (MDR)—Part 1:
+Framework*.
+
+Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web
+Consortium. https://www.w3.org/TR/vocab-org/
diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
new file mode 100644
index 000000000..b41b31c17
--- /dev/null
+++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
@@ -0,0 +1,106 @@
+# Analysis-run registry standards and research traceability
+
+**Status:** Active PR evidence; not protected-main truth until merge.
+**Scope:** Migration 0018, ADR 0013, rollback, and real-PostgreSQL contract tests.
+
+## Standards mapped to implementation
+
+| Source | Product implication | Implemented evidence |
+|---|---|---|
+| W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. |
+| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Opening a listed title warns that the live body may have changed after that cutoff. |
+| W3C 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. |
+| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows or implementation-specific payloads. | API intentionally deferred; ADR 0013 requires a source-redacting run list/detail contract before a product surface is claimed. |
+
+## Temporal reasoning
+
+The registry applies a bitemporal discipline without claiming a complete
+general-purpose bitemporal database:
+
+- `maximum_available_time` answers when the newest admitted evidence became
+ knowable;
+- `captured_at` answers when the immutable source snapshot was materialized;
+- `knowledge_cutoff` answers what a specific analysis was allowed to know;
+- `requested_at` answers when that analysis was requested;
+- `occurred_at` and `recorded_at` distinguish lifecycle occurrence from durable
+ database recording.
+
+The database requires the aggregate leakage boundary:
+
+```text
+maximum_available_time <= knowledge_cutoff <= requested_at
+captured_at <= requested_at
+```
+
+TEPP remains the authority for finer event/assertion/document/system/available
+clocks and temporal psychometrics. The registry does not duplicate TEPP
+measurement outputs.
+
+## Audit and privacy boundary
+
+The registry may store:
+
+- opaque product UUIDs;
+- authenticated account UUIDs;
+- SHA-256 digests;
+- bounded configuration/version identifiers;
+- aggregate counts;
+- bounded status/failure codes;
+- timezone-aware clocks.
+
+The registry must not store:
+
+- source SQL or source-table names;
+- DSNs, credentials, or provider secrets;
+- raw posts, HTML, images, base64 data, or attachments;
+- model prompts/responses or raw exceptions;
+- another service's application tables;
+- organization-specific source identifiers in public fixtures or documentation.
+
+Necessary PII remains available in its purpose-bound authorized product/source
+context. Auditability is achieved with actor identity, access control,
+provenance, retention, and immutable evidence rather than blanket masking.
+
+## Verification matrix
+
+| Claim | Falsifiable test |
+|---|---|
+| 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. A late own-corp post stays out of `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. |
+| Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. |
+| Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. |
+| Rollback does not erase audit data silently | Reject rollback with any registry rows and allow replay after explicit cleanup. |
+
+## APA 7th references
+
+International Organization for Standardization. (2019). *ISO 8601-1:2019: Date
+and time—Representations for information interchange—Part 1: Basic rules*
+(confirmed 2024; Amendment 1:2022).
+
+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
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
+
+OpenAPI Initiative. (2025). *OpenAPI specification, version 3.2.0*.
+https://spec.openapis.org/oas/v3.2.0.html
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation:
+5.5. Constraints*. https://www.postgresql.org/docs/current/ddl-constraints.html
+
+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/
diff --git a/docs/doctoring/IMAGE_CONTENT_REFERENCES.md b/docs/doctoring/IMAGE_CONTENT_REFERENCES.md
new file mode 100644
index 000000000..3aff83a9a
--- /dev/null
+++ b/docs/doctoring/IMAGE_CONTENT_REFERENCES.md
@@ -0,0 +1,30 @@
+# Embedded image content — doctoring
+
+These are the standards and papers that ground
+`docs/image-content-schema.md`, `lineageweave/image_content.py`, and the
+product popup in `frontend/src/PostBody.tsx`. Cite them in APA 7th when
+you extend OCR, captioning, tagging, or position-preserving storage.
+
+Li, M., Lv, T., Chen, J., Cui, L., Lu, Y., Florencio, D., Zhang, C., Li,
+Z., & Wei, F. (2023). TrOCR: Transformer-based optical character
+recognition with pre-trained models. *Proceedings of the AAAI Conference
+on Artificial Intelligence, 37*(11), 13094–13102.
+https://doi.org/10.1609/aaai.v37i11.26538
+
+Radford, A., Kim, J. W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S.,
+Sastry, G., Askell, A., Mishkin, P., Clark, J., Krueger, G., & Sutskever,
+I. (2021). Learning transferable visual models from natural language
+supervision. In M. Meila & T. Zhang (Eds.), *Proceedings of the 38th
+International Conference on Machine Learning* (pp. 8748–8763). PMLR.
+https://proceedings.mlr.press/v139/radford21a.html
+
+Masinter, L. (1998). *The "data" URL scheme* (RFC 2397). Internet
+Engineering Task Force. https://doi.org/10.17487/RFC2397
+
+Crockford, D. (2008). *The application/json media type for JavaScript
+Object Notation (JSON)* (RFC 4627; see also RFC 8259). Internet
+Engineering Task Force. https://doi.org/10.17487/RFC8259
+
+World Wide Web Consortium. (2014). *HTML5: A vocabulary and associated
+APIs for HTML and XHTML* (W3C Recommendation).
+https://www.w3.org/TR/html5/
diff --git a/docs/doctoring/PROV_O_REFERENCES.md b/docs/doctoring/PROV_O_REFERENCES.md
new file mode 100644
index 000000000..ed3167a0e
--- /dev/null
+++ b/docs/doctoring/PROV_O_REFERENCES.md
@@ -0,0 +1,22 @@
+# PROV-O references
+
+## Normative source — APA 7th
+
+Lebo, T., Sahoo, S., McGuinness, D., Belhajjame, K., Cheney, J., Corsar, D., Garijo, D., Soiland-Reyes, S., Zednik, S., & Zhao, J. (2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide Web Consortium. http://www.w3.org/TR/2013/REC-prov-o-20130430/
+
+## Related PROV family documents
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
+
+Cheney, J., Missier, P., & Moreau, L. (Eds.). (2013). *Constraints of the PROV data model* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/prov-constraints/
+
+## Implementation traceability
+
+| Source section | LineageWeave artifact |
+|---|---|
+| Section 4 term index | `PROV_CLASSES`, `PROV_RELATIONS` |
+| Tables 2 and 3 | `PROV_QUALIFICATIONS`, `provenance_qualification_definition` |
+| Class/property cross-reference | domain, range, hierarchy registries and normalized tables |
+| Appendix B | `PROV_RECOMMENDED_INVERSES`, `provenance_inverse_definition` |
+| Qualified form guidance | `ProvGraph.materialized_assertions()` |
+| OWL profile union domains | strict union-domain validation in Python and PostgreSQL |
diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md
new file mode 100644
index 000000000..fc7ded98f
--- /dev/null
+++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md
@@ -0,0 +1,31 @@
+# Related-node team and organization walk — doctoring
+
+These are the standards and papers that ground ADR 0018. Cite them in
+APA 7th when you extend the walk or the catalog identity layer.
+
+Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web
+Consortium. https://www.w3.org/TR/vocab-org/
+
+Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with
+restart and its applications. *Proceedings of the Sixth International
+Conference on Data Mining (ICDM'06)*, 613–622.
+https://doi.org/10.1109/ICDM.2006.70
+
+International Organization for Standardization. (2019). *ISO 8601-1:2019:
+Date and time—Representations for information interchange—Part 1: Basic
+rules* (confirmed 2024; Amendment 1:2022).
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
+
+Codd, E. F. (1970). A relational model of data for large shared data
+banks. *Communications of the ACM, 13*(6), 377–387.
+https://doi.org/10.1145/362384.362685
+
+Date, C. J. (2019). *Database design and relational theory: Normal forms
+and all that jazz* (2nd ed.). Apress.
+https://doi.org/10.1007/978-1-4842-5540-7
+
+International Organization for Standardization. (2023). *ISO/IEC
+11179-1:2023: Information technology—Metadata registries (MDR)—Part 1:
+Framework*.
diff --git a/docs/image-content-schema.md b/docs/image-content-schema.md
index f023d56a0..57ac3e5a1 100644
--- a/docs/image-content-schema.md
+++ b/docs/image-content-schema.md
@@ -82,6 +82,23 @@ picture sat relative to the surrounding paragraphs."
| `chunk_position` | `integer not null` | 0-based index among ALL of this document's chunks (text and image together) -- matches `Chunk.index` from `chunk_by_dom` |
| primary key | `(source_document_id, chunk_position)` | one image slot per position per document |
+## Viewer contract (before persistence exists)
+
+The demo popup does not yet read these tables. It splits the live
+`post_body` the same way `extract_base64_images` does: each
+`data:image/...;base64,...` payload becomes an ` Quote attached. Please confirm. Source quote. {post.post_body}` at its original
+character offset, and the surrounding HTML is shown as text. Quoted or
+unquoted `src` and optional data-URI parameters such as `charset=utf-8`
+are accepted so a real export still shows the picture. A buyer who
+opens the post sees the picture that sat between the paragraphs, not the
+base64 wall. Remote `src="https://..."` tags are stripped, never fetched;
+a remote-only body tells the operator to re-export with the picture
+embedded. Undecodable payloads say the same. This screen does not read
+text inside the picture. OCR, caption, and tag search still require the
+vision client on extract / Ask (Li et al., 2023; Radford et al., 2021)
+and, in a real deployment, the tables below. See
+`docs/doctoring/IMAGE_CONTENT_REFERENCES.md`.
+
## Query shapes this supports
- **"Find images whose extracted text or tags match a search query, then
@@ -105,3 +122,17 @@ picture sat relative to the surrounding paragraphs."
ON CONFLICT DO NOTHING` before the provider call, or a short-lived
lease row) to close that race; this schema documents the storage
guarantee, not that concurrency control.
+
+## References
+
+Li, M., Lv, T., Chen, J., Cui, L., Lu, Y., Florencio, D., Zhang, C., Li, Z.,
+& Wei, F. (2023). TrOCR: Transformer-based optical character recognition
+with pre-trained models. *Proceedings of the AAAI Conference on Artificial
+Intelligence, 37*(11), 13094–13102. https://doi.org/10.1609/aaai.v37i11.26538
+
+Radford, A., Kim, J. W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S.,
+Sastry, G., Askell, A., Mishkin, P., Clark, J., Krueger, G., & Sutskever, I.
+(2021). Learning transferable visual models from natural language
+supervision. In M. Meila & T. Zhang (Eds.), *Proceedings of the 38th
+International Conference on Machine Learning* (pp. 8748–8763). PMLR.
+https://proceedings.mlr.press/v139/radford21a.html
diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl
index c892d609f..04e8d1b61 100644
--- a/docs/ontology/lineageweave-kg.ttl
+++ b/docs/ontology/lineageweave-kg.ttl
@@ -4,23 +4,27 @@
@prefix rdfs:
`,
+ });
+ render(
{post.post_title}
-
{related.map((node) => {
- const caption = `${node.label ?? node.node_id} (${node.ontology_label ?? node.node_type_code})`;
- if (node.node_type_code === NODE_POST && onSelectPost) {
- return (
-
)}
@@ -1106,6 +1184,7 @@ function PostDetailPopup({
const [evaluation, setEvaluation] = useState
{post.post_body}
++ + Hover a prefix to read the full digest for verification.{" "} + + {codeRevisionSha ? ( + {`Code ${analysisRunDigestPrefix(codeRevisionSha)}`} + ) : null} + {codeRevisionSha && configurationSha256 ? " · " : null} + {configurationSha256 ? ( + + {`Config ${analysisRunDigestPrefix(configurationSha256)}`} + + ) : null} +
+{error}
; + if (runs === null) returnLoading analysis runs...
; + + const corpusHint = selected ? analysisRunCorpusHint(selected) : null; + const selectedNextAction = selected ? analysisRunNextAction(selected) : null; + + return ( +{error}
} + {runs.length === 0 ? ( ++ No analysis runs visible to this account yet. Request a lineage + reconstruction, or ask an administrator to run make seed. +
+ ) : ( +{selectedNextAction}
} ++ Cutoff {selected.knowledge_cutoff.slice(0, 10)} + {" · "} + Requested {selected.requested_at.slice(0, 10)} +
+{corpusHint}
} +{analysisRunLivePostWarning(selected.knowledge_cutoff)}
+{analysisRunEmptyPostsHint(selected)}
+ )} +{UNDECODEABLE_IMAGE}
; + } + return ( ++ {segment.text} +
+ ); + case "image": + return ( +Quote attached.
Please confirm.
`; + const segments = splitPostBody(html); + + expect(segments).toEqual([ + { kind: "text", text: "Quote attached." }, + { + kind: "image", + src: `data:image/png;base64,${TINY_PNG_B64}`, + mimeType: "image/png", + position: html.indexOf("between
` + + `See

end
'; + const segments = splitPostBody(html); + expect(segments.every((segment) => segment.kind === "text")).toBe(true); + expect(segments.map((segment) => (segment.kind === "text" ? segment.text : "")).join(" ")).toContain( + "See", + ); + expect(JSON.stringify(segments)).not.toContain("https://example.test"); + }); + + it("tells the operator to re-export a remote-only image instead of leaking the URL", () => { + const html = '
';
+ expect(splitPostBody(html)).toEqual([{ kind: "text", text: REMOTE_IMAGE_SKIPPED }]);
+ expect(JSON.stringify(splitPostBody(html))).not.toContain("https://example.test");
+ });
+});
diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts
new file mode 100644
index 000000000..d91674965
--- /dev/null
+++ b/frontend/src/postBodyDisplay.ts
@@ -0,0 +1,157 @@
+/**
+ * Split a raw `post_body` into text and in-place data-URI images.
+ *
+ * The popup used to dump the source string, so a buyer who opened a post
+ * with an embedded invoice saw a base64 wall instead of the picture.
+ * Only `data:image/...;base64,...` payloads are turned into images —
+ * remote `http(s)` img tags are stripped, never fetched. A tag-only body
+ * never falls back to the raw source string.
+ */
+
+export type PostBodySegment =
+ | { kind: "text"; text: string }
+ | { kind: "image"; src: string; mimeType: string; position: number };
+
+export const UNDECODEABLE_IMAGE =
+ "Embedded image could not be decoded. Re-export the source post and open it again.";
+
+export const REMOTE_IMAGE_SKIPPED =
+ "This post linked a remote image that was not loaded. Re-export the source with the picture embedded and open it again.";
+
+export const IMAGE_NOT_READ_HERE =
+ "Image from this post. Text inside the picture is not read on this screen.";
+
+const IMG_TAG = /