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..1cad1f17c 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
@@ -75,3 +90,7 @@ jobs:
- name: Build
working-directory: frontend
run: pnpm run build
+
+ - name: Build Storybook
+ working-directory: frontend
+ run: pnpm run build-storybook
diff --git a/AGENTS.md b/AGENTS.md
index 735988f09..47a71c8c3 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
@@ -80,11 +83,27 @@ floating Node version):
```bash
cd frontend && pnpm install
pnpm run lint && pnpm run test && pnpm run build
+# Storybook inventory (ADR 0020 tokens): pnpm run build-storybook
```
+A run-bearing analysis-run registry empties only after an unrevoked
+`analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`
+(ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not
+expose purge on a public HTTP route.
+
## CI gates
`.github/workflows/tests.yml` runs the full suite on every PR to `main`.
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..f8b734bf5 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; 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,11 @@ 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. The Keyman list button accessible name
+keeps that side (`Related nodes for Ada West (Our side)`). 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 +460,54 @@ 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`.
+A run-bearing registry is emptied only after an unrevoked
+`analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`,
+then `purge_analysis_run_registry('approved-retention-purge')`
+(ADR 0020); a raw `DELETE` and a runtime role that only knows the
+public phrase stay rejected. Repeated chip and close controls use
+`frontend/src/styles/tokens.css` and the Storybook inventory.
+
## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only)
First of three staged slices toward the brief's weekly/monthly
@@ -682,3 +740,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.87.0-retention-purge-grant-admin.md b/CHANGELOG.d/0.87.0-retention-purge-grant-admin.md
new file mode 100644
index 000000000..a2bcb7d8d
--- /dev/null
+++ b/CHANGELOG.d/0.87.0-retention-purge-grant-admin.md
@@ -0,0 +1,13 @@
+# 0.87.0 analysis-run retention purge
+
+Operators empty a run-bearing analysis-run registry only after an
+unrevoked `analysis_run_retention_grant` and
+`GRANT analysis_run_retention_admin`. Then
+`select purge_analysis_run_registry('approved-retention-purge')`.
+Export `analysis_run_retention_event`, delete those rows, then roll
+back 0020 and 0018. A raw DELETE, a published token without a grant,
+and a runtime role that is not the admin role still fail (ADR 0020).
+
+Designers can change chip and close-button appearance in
+`frontend/src/styles/tokens.css` and preview the next click in Storybook
+(`cd frontend && pnpm run storybook`).
diff --git a/CHANGELOG.d/0.87.1-keyman-accname-purge-counts.md b/CHANGELOG.d/0.87.1-keyman-accname-purge-counts.md
new file mode 100644
index 000000000..66fe11c58
--- /dev/null
+++ b/CHANGELOG.d/0.87.1-keyman-accname-purge-counts.md
@@ -0,0 +1,9 @@
+# 0.87.1 Keyman AccName and purge audit counts
+
+Open a post, tab to Ada West, and hear `Related nodes for Ada West
+(Our side)` — the same side the chip already shows. A name-only
+accessible name hid whether that person is our side or a counterparty.
+
+Retention purge now counts runs and snapshots after the immutability
+triggers are disabled. A table-DML runtime role still cannot insert a
+retention grant. A forced delete failure leaves the registry immutable.
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..b8c34160d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,321 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.87.1] - 2026-08-16
+
+### Fixed
+
+- Opening a post and tabbing to Ada West now announces
+ `Related nodes for Ada West (Our side)` — the same side the chip
+ already shows. A name-only accessible name hid whether that person
+ is our side or a counterparty.
+- Retention purge now counts runs and snapshots after the immutability
+ triggers are disabled, so the audit row cannot record a stale
+ `purged_run_count`. A table-DML runtime role still cannot insert a
+ retention grant. A forced delete failure leaves
+ `analysis_run_request_is_immutable` in place.
+
+## [0.87.0] - 2026-08-16
+
+### Added
+
+- Operators can empty a run-bearing analysis-run registry without a
+ superuser trigger disable. Insert an unrevoked
+ `analysis_run_retention_grant` for `session_user`, grant
+ `analysis_run_retention_admin`, then
+ `select purge_analysis_run_registry('approved-retention-purge')`
+ (ADR 0020). Export `analysis_run_retention_event`, delete those
+ rows, then roll back 0020 and 0018. A raw `DELETE`, a published
+ token without a grant, and a runtime role that is not the admin
+ role still fail.
+- Repeated citation chips and close buttons use named design tokens
+ in `frontend/src/styles/tokens.css`. Preview them in Storybook
+ (`cd frontend && pnpm run storybook`).
+
+## [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; Extract Keyman
+ or Ask still runs OCR on that image for search.
+
+### 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 +329,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 +1467,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..b888eea83
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,36 @@
+# 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 retention (v0.87.0)
+
+To empty a run-bearing registry, insert an unrevoked
+`analysis_run_retention_grant` for `session_user` and
+`GRANT analysis_run_retention_admin` (ADR 0020). Then
+`select purge_analysis_run_registry('approved-retention-purge')`.
+The function counts runs after the immutability triggers are
+disabled. Export `analysis_run_retention_event`, delete those rows,
+and roll back 0020 then 0018. The published phrase is not a secret. Do not
+`DISABLE TRIGGER` as superuser. Do not grant the admin role or a
+retention grant to the application `DATABASE_URL` login. ADR 0019
+is the R&R catalog-id bind, not this purge.
+
+## 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/README.md b/README.md
index 2f963c7f0..b6a881a3a 100644
--- a/README.md
+++ b/README.md
@@ -167,6 +167,12 @@ FastAPI backend over real `fetch()` with the token Keycloak issued.
make up
make seed
cd frontend && cp .env.example .env.local && pnpm install && pnpm run dev
+# Repeated chip/close controls: pnpm run storybook
+# (Node 24 via frontend/mise.toml; pnpm only)
+# Empty a run-bearing registry: insert analysis_run_retention_grant
+# for session_user, GRANT analysis_run_retention_admin, then
+# select purge_analysis_run_registry('approved-retention-purge').
+# The published token is not a grant (ADR 0020).
# -> http://localhost:5173, click "Log in", redirects through the real
# Keycloak login page for demo.analyst / lineageweave-demo-only
```
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..3b74c22a3 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,8 @@
_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"
+_RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql"
def _postgres_available() -> bool:
@@ -112,10 +115,13 @@ 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(_RETENTION_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 +131,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 +152,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 +192,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 +322,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 +414,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 +444,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 +587,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 +626,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 +642,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 +675,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 +737,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 +1154,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 +1175,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 +1203,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 +1261,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..ce2f0e6b5 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,15 @@ 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
+COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.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..d13994bc5
--- /dev/null
+++ b/docs/adr/0013-normalized-analysis-run-registry.md
@@ -0,0 +1,274 @@
+# 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, then emptied with `purge_analysis_run_registry` after an unrevoked
+`analysis_run_retention_grant` and `analysis_run_retention_admin` membership
+(ADR 0020). 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/adr/0020-analysis-run-retention-purge.md b/docs/adr/0020-analysis-run-retention-purge.md
new file mode 100644
index 000000000..d54143d3b
--- /dev/null
+++ b/docs/adr/0020-analysis-run-retention-purge.md
@@ -0,0 +1,103 @@
+# ADR 0020 — Approved retention purge requires a session grant and admin role
+
+**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
+
+## Context
+
+ADR 0013 and migration `0018` make `analysis_run`, `analysis_run_scope`, and
+`analysis_run_status_event` immutable. The 0018 rollback refuses to drop
+non-empty registry relations and tells operators to export or delete evidence
+under an approved retention procedure.
+
+After the first `analysis_run` insert, a raw `DELETE` is rejected. Snapshot
+delete is then blocked by the foreign key. Operators following the documented
+procedure cannot satisfy `analysis_run_registry_not_empty` without a superuser
+`DISABLE TRIGGER`. That is not a supported product path (ISO 15489-1:2016
+disposition; NIST SP 800-92 protected audit records).
+
+A `SECURITY DEFINER` function that `PUBLIC` can execute, or that accepts only
+a documented phrase, lets any SQL session wipe analysis-run evidence
+(NIST SP 800-53 Rev. 5 AC-3; CWE-250). The phrase is a procedure name, not
+an authorization secret. The write API is a separate slice; SQL operators
+still need a grant that is independent of application `user_account` rows.
+
+Landed #122 occupies ADR 0018 / package 0.86.0 for the team and organization
+related-node walk. ADR 0019 binds `cataloged_team_id` /
+`cataloged_corporate_entity_id` on `post_summary_role` and must not be
+reused here. This decision is the next free slot.
+
+## Decision
+
+Migration `0020_analysis_run_retention_purge.sql` adds a conjunctive
+fail-closed purge:
+
+- `analysis_run_retention_grant` — one unrevoked row per
+ `database_role_name`; history of revoked grants is allowed;
+- `analysis_run_retention_admin` — `NOLOGIN` role that receives
+ `EXECUTE`; `PUBLIC` does not;
+- `purge_analysis_run_registry(approval_token text)` — `SECURITY DEFINER`,
+ checks the unrevoked grant, then `pg_has_role(..., 'member')` on the
+ admin role, then accepts only `approved-retention-purge`, disables the
+ three immutability delete triggers inside that call (ACCESS
+ EXCLUSIVE), counts runs and snapshots under that lock, deletes in FK
+ order, re-enables the triggers, and writes one
+ `analysis_run_retention_event`. Counting before the lock can record a
+ stale `purged_run_count` while still deleting the later row;
+- `analysis_run_retention_event` — purged run/snapshot counts, the SHA-256
+ of the approval token, `invoking_session_role`, `invoking_current_role`,
+ and optional `client_network_address`. The raw phrase is never stored.
+
+A session `SET` cannot authorize a raw `DELETE`. A table-DML runtime role
+that only knows the public phrase cannot call the function. A member of
+the admin role without a grant cannot purge. A grant without admin
+membership cannot purge. After purge, export the retention event, delete
+those rows, roll back 0020, then roll back 0018.
+
+This migration does not insert a grant or grant the admin role to the
+migrator. Production `DATABASE_URL` must not be a superuser and must not
+hold either privilege.
+
+## Consequences
+
+- A run-bearing registry can be emptied without superuser trigger disable.
+- Retention remains an explicit, audited operator action, not a silent
+ downgrade.
+- 0018 rollback stays fail-closed until the registry tables are empty;
+ 0020 rollback stays fail-closed until retention events are exported
+ and deleted.
+- Repeated citation-chip and close-button appearance lives in
+ `frontend/src/styles/tokens.css` and the Storybook inventory.
+
+## Follow-up
+
+When the authorized write API exists, bind an administrator
+`user_account` to the same grant table. Keep the SQL-role grant for
+operators who purge from `psql`. Do not expose purge on a public HTTP
+route. Split the application login from the migration owner so the
+product role cannot execute the function even as table owner.
+
+## References — APA 7th
+
+American Institute of Certified Public Accountants. (2017). *SOC 2®: SOC
+for Service Organizations: Trust Services Criteria*.
+
+International Organization for Standardization. (2016). *ISO 15489-1:2016:
+Information and documentation—Records management—Part 1: Concepts and
+principles*.
+
+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
+
+MITRE. (2026). *CWE-250: Execution with unnecessary privileges*.
+https://cwe.mitre.org/data/definitions/250.html
+
+National Institute of Standards and Technology. (2020). *Security and
+privacy controls for information systems and organizations* (NIST Special
+Publication 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation:
+5.8. Privileges*.
+https://www.postgresql.org/docs/current/ddl-priv.html
diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
new file mode 100644
index 000000000..caba4147a
--- /dev/null
+++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
@@ -0,0 +1,125 @@
+# Analysis-run registry standards and research traceability
+
+**Status:** Active PR evidence; not protected-main truth until merge.
+**Scope:** Migrations 0018 and 0020, ADR 0013 / 0020, 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. Keyman list chips announce `Related nodes for Ada West (Our side)` so the accessible name keeps the visible business side. |
+| W3C WCAG 2.2 SC 4.1.2 | Name, role, and value of a control must include the same next-action information the visible text already shows. | `relatedPersonAccessibleName` keeps the localized side on the Keyman list button. Home-list analysis-run AccName that also includes next-action copy stays a later slice (#149). |
+| 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, `invoking_session_role` on each retention event, and exclusion of raw source/provider payloads. |
+| NIST SP 800-53 Rev. 5 AC-3 | Enforce least privilege on privileged procedures; a well-known procedure name is not an authorization secret. | `REVOKE ALL` on `purge_analysis_run_registry` from `PUBLIC`; `GRANT EXECUTE` only to `analysis_run_retention_admin`; unrevoked `analysis_run_retention_grant` required (ADR 0020). |
+| 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 0018 rollback with any registry rows. A run-bearing registry empties only through an unrevoked `analysis_run_retention_grant` plus `analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')`; a wrong token, a raw `DELETE`, and a runtime role that only knows the public phrase stay rejected. Export then delete `analysis_run_retention_event` before 0020 rollback. |
+
+## APA 7th references
+
+American Institute of Certified Public Accountants. (2017). *SOC 2®: SOC
+for Service Organizations: Trust Services Criteria*.
+
+International Organization for Standardization. (2016). *ISO 15489-1:2016:
+Information and documentation—Records management—Part 1: Concepts and
+principles*.
+
+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
+
+National Institute of Standards and Technology. (2020). *Security and
+privacy controls for information systems and organizations* (NIST Special
+Publication 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5
+
+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
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation:
+5.8. Privileges*. https://www.postgresql.org/docs/current/ddl-priv.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. (2023). *Web content accessibility guidelines
+(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation).
+https://www.w3.org/TR/owl-time/
diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
new file mode 100644
index 000000000..2f0647dca
--- /dev/null
+++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
@@ -0,0 +1,20 @@
+# Design-token and Storybook traceability
+
+**Status:** Active PR evidence; not protected-main truth until merge.
+**Scope:** `frontend/src/styles/tokens.css`, repeated chip/close modules, and
+the Storybook inventory.
+
+## Standards mapped to implementation
+
+| Source | Product implication | Implemented evidence |
+|---|---|---|
+| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--radius-chip`, and `--font-*`. `CitationChip` and `PopupCloseButton` read those names through `App.css`. |
+| Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. |
+
+## APA 7th references
+
+Design Tokens Community Group. (2025). *Design Tokens Format Module 1.0*
+(W3C Community Group Draft Report). https://tr.designtokens.org/format/
+
+Storybook. (2026). *Storybook for React & Vite*.
+https://storybook.js.org/docs/get-started/frameworks/react-vite
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..4dae03dcf 100644
--- a/docs/image-content-schema.md
+++ b/docs/image-content-schema.md
@@ -82,6 +82,18 @@ 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 `` at its original
+character offset, and the surrounding HTML is shown as text. 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.
+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.
+
## Query shapes this supports
- **"Find images whose extracted text or tags match a search query, then
@@ -105,3 +117,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: .
@prefix skos: .
@prefix xsd: .
+@prefix prov: .
+@prefix org: .
#################################################################
# LineageWeave Knowledge Graph Ontology
#
# The formal OWL 2 / RDFS / SKOS vocabulary for the
-# `knowledge_graph_edge` table's node/edge types and the
+# `knowledge_graph_edge` table's node/edge types, the
# `entity_relationship_type` / `person_side` / `corporate_entity_level`
-# controlled vocabularies in migrations/0001_initial_schema.sql.
+# controlled vocabularies in migrations/0001_initial_schema.sql, and
+# `post_summary_role.actor_type_code` (migrations/0012).
#
# `knowledge_graph_edge` (source_node_type_code, source_node_id) --
# [edge_type_code] --> (target_node_type_code, target_node_id) is
# already an RDF triple in shape (Cyganiak, Wood, & Lanthaler, 2014);
# this file is the formal semantic layer over it -- PostgreSQL stays
# the source of record. See docs/adr/0004-knowledge-graph-ontology.md
-# for the full design rationale, and tests/test_ontology.py for the
-# round-trip check that every code below actually exists as a
-# common_lookup_value row, and vice versa.
+# for the KG design rationale, docs/adr/0006-role-responsibility-agent-ontology.md
+# for the R&R actor-type rationale (grounded in W3C PROV-O), and
+# tests/test_ontology.py for the round-trip check that every code below
+# actually exists as a common_lookup_value row, and vice versa.
#
# Every custom term carries a :lookupCode annotation naming the exact
# `common_lookup_value.lookup_code` it corresponds to -- that literal
@@ -29,7 +33,7 @@
a owl:Ontology ;
rdfs:label "LineageWeave Knowledge Graph Ontology" ;
- rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, and corporate_entity_level controlled vocabularies." .
+ rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, and post_summary_role.actor_type_code controlled vocabularies." .
:lookupCode a owl:AnnotationProperty ;
rdfs:label "lookup code" ;
@@ -65,6 +69,12 @@
rdfs:comment "A corporate_entity row. Also a skos:Concept so the self-referencing parent_entity_id hierarchy (e.g. Group -> Company -> Plant) is expressible with skos:broader/skos:narrower on instances." ;
:lookupCode "node_corporate_entity" .
+:Team a owl:Class ;
+ rdfs:subClassOf org:OrganizationalUnit ;
+ rdfs:label "Team" ;
+ rdfs:comment "A cataloged_team row: a named company sub-unit (ADR 0009) with a stable team_id, distinct from :RoleActorTeam (ADR 0007's per-row actor_type_code classification) the same way :Person is distinct from :RoleActorPerson." ;
+ :lookupCode "node_team" .
+
#################################################################
# Object properties -- edge_type (knowledge_graph_edge.edge_type_code)
#################################################################
@@ -90,6 +100,36 @@
rdfs:comment "Two people named in the same post -- symmetric by construction." ;
:lookupCode "edge_co_mention" .
+#################################################################
+# Object properties -- ADR 0009 cross-post identity resolution edges.
+# Kept distinct from :mentions/:affiliatedWith (not reused with a
+# broadened domain/range) so an edge_type_code alone always tells you
+# which node types it connects -- stating rdfs:domain for the same
+# property twice (once :Person, once :Team) would make RDFS entail
+# every :mentions subject is BOTH a :Person and a :Team, which is false.
+#################################################################
+
+:mentionsTeam a owl:ObjectProperty ;
+ rdfs:domain :Team ;
+ rdfs:range :Post ;
+ rdfs:label "mentioned in post" ;
+ rdfs:comment "A cataloged team is named by a post (post_team_mention)." ;
+ :lookupCode "edge_mention_team" .
+
+:teamAffiliatedWith a owl:ObjectProperty ;
+ rdfs:domain :Team ;
+ rdfs:range :CorporateEntity ;
+ rdfs:label "team affiliated with" ;
+ rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ;
+ :lookupCode "edge_team_affiliation" .
+
+:mentionsOrganization a owl:ObjectProperty ;
+ rdfs:domain :CorporateEntity ;
+ rdfs:range :Post ;
+ rdfs:label "mentioned in post" ;
+ rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ;
+ :lookupCode "edge_mention_organization" .
+
#################################################################
# Object properties -- entity_relationship_type
# (post_counterparty_entity.relationship_type_code)
@@ -152,3 +192,61 @@
:GroupLevel skos:narrower :CompanyLevel .
:CompanyLevel skos:narrower :PlantLevel .
+
+#################################################################
+# Classes -- prov_agent_type (post_summary_role.actor_type_code)
+#
+# A post's R&R (roles & responsibilities) actor is not always a person
+# -- business correspondence routinely names an organization acting
+# in its own name ("당사" [our company], "Demo Corp"). Grounded
+# directly in W3C PROV-O (Lebo, Sahoo, & McGuinness,
+# 2013): prov:Agent is the general acting-party class, with prov:Person
+# and prov:Organization its two recognized subclasses. These are
+# distinct from :Person / :OurSidePerson / :CounterpartyPerson above:
+# node_type's :Person is a cataloged_person row with a stable person_id
+# a Keyman panel links to; an R&R actor is a free-text name with no
+# cataloged identity of its own (it may not even resolve to a Keyman).
+#
+# A third, meso-level case real data surfaced: a named sub-unit of a
+# company ("설계팀" [design team]) is neither prov:Person nor the
+# prov:Organization itself -- it is the company's own internal
+# structure. PROV-O has no such class; the W3C Organization Ontology
+# (Reynolds, 2014) does: org:OrganizationalUnit, "used to represent
+# division of a particular organization into sub-organizational units,"
+# linked to its parent via org:unitOf. See docs/adr/0007-team-actor-type.md.
+#################################################################
+
+:RoleActorPerson a owl:Class ;
+ rdfs:subClassOf prov:Person ;
+ rdfs:label "Role actor (person)" ;
+ rdfs:comment "An R&R actor that is a named individual, per prov:Person." ;
+ :lookupCode "prov_person" .
+
+:RoleActorOrganization a owl:Class ;
+ rdfs:subClassOf prov:Organization ;
+ rdfs:label "Role actor (organization)" ;
+ rdfs:comment "An R&R actor that is an organization acting in its own name, per prov:Organization." ;
+ :lookupCode "prov_organization" .
+
+:RoleActorTeam a owl:Class ;
+ rdfs:subClassOf org:OrganizationalUnit ;
+ rdfs:label "Role actor (team)" ;
+ rdfs:comment "An R&R actor that is a named sub-unit of a company (e.g. 설계팀), per org:OrganizationalUnit -- not the company itself." ;
+ :lookupCode "prov_team" .
+
+#################################################################
+# organization_name_resolution (raw/canonical organization-name pairs)
+#
+# ADR 0008: an abbreviated/slang organization mention (e.g. "AGP")
+# is resolved to its full canonical name ("Aurora Grid Power") and
+# cross-verified via external search before being trusted. This is not
+# a new KG node/edge type -- no new :lookupCode term is declared here,
+# since organization_name_resolution's columns are not a
+# common_lookup_value category (there is nothing for
+# tests/test_ontology.py's round-trip check to enforce). Documented
+# here for the Ontology/Semantic-Layer grounding itself:
+# `organization_name_resolution.raw_organization_name` corresponds to
+# SKOS `skos:altLabel` (an alternative label -- an abbreviation is
+# exactly this) and `resolved_organization_name` to `skos:prefLabel`
+# (the single preferred/canonical label), per Miles & Bechhofer (2009).
+#################################################################
diff --git a/docs/ontology/prov-o-support-profile.ttl b/docs/ontology/prov-o-support-profile.ttl
new file mode 100644
index 000000000..0175dd70b
--- /dev/null
+++ b/docs/ontology/prov-o-support-profile.ttl
@@ -0,0 +1,18 @@
+@prefix : .
+@prefix dcterms: .
+@prefix org: .
+@prefix owl: .
+@prefix prov: .
+@prefix rdfs: .
+
+
+ a owl:Ontology ;
+ dcterms:title "LineageWeave PROV-O support profile"@en ;
+ dcterms:conformsTo ;
+ owl:imports ;
+ rdfs:comment "The runtime supports all 30 PROV-O classes, all 50 normative properties, both qualification tables, and Appendix B inverse names without redefining the W3C vocabulary."@en .
+
+:Post rdfs:subClassOf prov:Entity .
+:Person rdfs:subClassOf prov:Person .
+:CorporateEntity rdfs:subClassOf prov:Organization .
+:Team rdfs:subClassOf prov:Organization, org:OrganizationalUnit .
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
new file mode 100644
index 000000000..282e3515e
--- /dev/null
+++ b/docs/storybook-inventory.md
@@ -0,0 +1,21 @@
+# Storybook inventory
+
+Open the catalog after `cd frontend && pnpm run storybook`. Each story is a
+buyer-facing control you can click before changing product CSS.
+
+| Story | Buyer next action | Token / module |
+|---|---|---|
+| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
+| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
+
+Repeated web objects must use `frontend/src/styles/tokens.css` and a module
+under `frontend/src/components/`. Do not add a second Node package manager;
+Storybook is installed with the existing pnpm pin on Node 24.
+
+## References — APA 7th
+
+Design Tokens Community Group. (2025). *Design Tokens Format Module 1.0*
+(W3C Community Group Draft Report). https://tr.designtokens.org/format/
+
+Storybook. (2026). *Storybook for React & Vite*.
+https://storybook.js.org/docs/get-started/frameworks/react-vite
diff --git a/docs/superpowers/plans/2026-08-14-prov-o-standard-relations.md b/docs/superpowers/plans/2026-08-14-prov-o-standard-relations.md
new file mode 100644
index 000000000..d3a112453
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-14-prov-o-standard-relations.md
@@ -0,0 +1,23 @@
+# PROV-O standard relations implementation plan
+
+## Completed TDD sequence
+
+1. Write failing tests for exact class/property inventories, datatype-property set, qualification tables, inverse-name table, graph validation, inference, RDF serialization, SQL seed coverage, naming rules, and support-profile mappings.
+2. Confirm test collection fails before `lineageweave.prov_o` exists.
+3. Implement the complete immutable registry and validated graph API.
+4. Implement deterministic fixed-point materialization.
+5. Generate the normalized PostgreSQL migration from the same registry and verify every IRI/code is present.
+6. Add the ontology support profile and product-class mappings.
+7. Add ADR, implementation architecture, complete matrix, and APA 7th doctoring references.
+8. Run focused tests, branch coverage, compile checks, exact-head CI/security review, then return the PR to Ready.
+
+## Merge gates
+
+- 30/30 classes and 50/50 properties present.
+- 14/14 qualification implications pass.
+- 44/44 object-property inverse names present.
+- Focused production statement and branch coverage 100%.
+- Public callable docstrings 100%.
+- Migration executes on PostgreSQL 16 in CI and rejects wrong object kinds/domains/ranges.
+- Exact-head Tests, Security Scan, and SAST succeed.
+- No valid unresolved review thread.
diff --git a/docs/superpowers/plans/2026-08-15-analysis-run-registry.md b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md
new file mode 100644
index 000000000..a3ed77d27
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md
@@ -0,0 +1,83 @@
+# Analysis-run registry implementation plan
+
+> Execute test-first. Preserve the reviewed LineageWeave product and keep
+> private actual-data evidence outside public source control.
+
+**Goal:** Establish one normalized, temporally truthful, actor-scoped registry
+for Milestone 2 analysis requests and lifecycle evidence.
+
+## Task 1 — RED: database contract
+
+**File:** `tests/test_analysis_run_registry_schema.py`
+
+1. Require the five normalized relations, current-status view, rollback, and
+ fresh-install wiring.
+2. Reject the retained experiment's denormalized table and JSON metadata.
+3. Require evidence-owned availability/capture clocks and a run-owned cutoff.
+4. Require non-null requester identity and account-scoped idempotency.
+5. Require immutable snapshot, count, and run request rows.
+6. Require shared row locking between count mutation and first run creation.
+7. Require pending-first, contiguous, monotonic, legal status transitions and
+ append-only status rows.
+8. Require fail-closed rollback and descriptive database-object names.
+
+## Task 2 — GREEN: normalized migration and rollback
+
+**Files:**
+
+- `migrations/0018_analysis_run_registry.sql`
+- `migrations/rollback/0018_analysis_run_registry.sql`
+- `docker/postgres-init/Dockerfile`
+
+1. Insert category-checked lookup values idempotently.
+2. Add snapshot, count, run, scope, and status-event relations in 3NF.
+3. Keep `maximum_available_time` on the snapshot and `knowledge_cutoff` on the
+ run.
+4. Serialize count freeze and run creation through the same snapshot row lock.
+5. Reject mutation of immutable evidence and request configuration.
+6. Implement the lifecycle state machine as a serialized insert trigger.
+7. Add the current-status read view.
+8. Refuse rollback while any audit evidence exists.
+9. Apply migration 0018 after the PROV-O migration on fresh PostgreSQL images.
+
+## Task 3 — Documentation and evidence
+
+**Files:**
+
+- `docs/adr/0013-normalized-analysis-run-registry.md`
+- `docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md`
+- `CHANGELOG.d/milestone2-analysis-run-registry.md`
+
+1. Record product/service ownership and deferred API/UI claims.
+2. Trace temporal, provenance, audit, privacy, concurrency, and rollback
+ decisions to current authoritative sources in APA 7th form.
+3. Mark active-PR decisions as non-main truth.
+4. Keep public fixtures synthetic and exclude private source identifiers.
+
+## Task 4 — Exact-head verification
+
+1. Run the static test without PostgreSQL and prove it fails before migration.
+2. Run all registry cases against real PostgreSQL after implementation.
+3. Replay the migration and rollback.
+4. Run the complete Python product suite against PostgreSQL.
+5. Run frontend lint, complete tests, and production build.
+6. Run `compileall`, security, SAST, documentation hygiene, and public-content
+ scans.
+7. Inspect the exact final diff for temporary workflows/scripts.
+8. Obtain independent exact-head review and merge only after the parent PR is on
+ protected `main` and base-sensitive evidence is regenerated.
+
+## Task 5 — Next bounded vertical slice
+
+After this registry reaches protected main:
+
+1. Write failing repository tests for atomic run + scope + pending-event
+ creation and idempotent request comparison.
+2. Implement the async PostgreSQL repository with no cross-service SQL.
+3. Add RBAC/ABAC-protected source-redacting list/detail endpoints.
+4. Add the DB-grounded read-only administrator surface and Storybook states.
+5. Add normalized outbox + Valkey delivery.
+6. Integrate TEPP and contextual-orchestrator only through reviewed versioned
+ contracts.
+7. Execute private actual-data analysis and retain signed aggregate acceptance
+ artifacts outside public Git history.
diff --git a/docs/superpowers/specs/2026-08-14-prov-o-standard-relations-design.md b/docs/superpowers/specs/2026-08-14-prov-o-standard-relations-design.md
new file mode 100644
index 000000000..9406d7114
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-14-prov-o-standard-relations-design.md
@@ -0,0 +1,30 @@
+# PROV-O standard relations design
+
+## Goal
+
+Extend PR #74 from three actor categories to complete, interoperable W3C PROV-O relation support while preserving the current product navigation graph.
+
+## Considered approaches
+
+### A. Widen `knowledge_graph_edge`
+
+Rejected. It has one UUID object and cannot represent RDF literals, qualified influence resources, or multiple classes per resource.
+
+### B. Store opaque RDF only
+
+Rejected. It would support interchange but provide no fail-closed domain/range, datatype, or relational-integrity contract.
+
+### C. Standards layer plus explicit product projection — selected
+
+A complete PROV-O registry, validator, inference engine, normalized relational store, RDF serializer, and support profile sit beside the compact product graph. Existing nodes may be bound to standard resources, and only an explicit projector creates navigation edges.
+
+## Invariants
+
+1. Exact W3C namespace and local names are preserved.
+2. The registry count is exactly 30 classes and 50 properties for this Recommendation version.
+3. A property is object or datatype, never both.
+4. Qualified forms imply unqualified forms.
+5. Reserved inverse names never create ad hoc vocabulary.
+6. Existing product data is not silently retyped or projected.
+7. SQL and Python reject invalid assertion shape/domain/range.
+8. Definitions and observations remain normalized and independently versionable.
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a547bf36d..87b58f06f 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
+storybook-static
*.local
# Editor directories and files
diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
new file mode 100644
index 000000000..d123813dc
--- /dev/null
+++ b/frontend/.storybook/main.ts
@@ -0,0 +1,12 @@
+import type { StorybookConfig } from "@storybook/react-vite";
+
+const config: StorybookConfig = {
+ stories: ["../src/**/*.stories.@(ts|tsx)"],
+ addons: [],
+ framework: {
+ name: "@storybook/react-vite",
+ options: {},
+ },
+};
+
+export default config;
diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts
new file mode 100644
index 000000000..4907f91df
--- /dev/null
+++ b/frontend/.storybook/preview.ts
@@ -0,0 +1,11 @@
+import type { Preview } from "@storybook/react-vite";
+import "../src/index.css";
+import "../src/App.css";
+
+const preview: Preview = {
+ parameters: {
+ controls: { matchers: { color: /(background|color)$/i } },
+ },
+};
+
+export default preview;
diff --git a/frontend/package.json b/frontend/package.json
index 9c84795d9..25956961a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,14 +1,16 @@
{
"name": "frontend",
"private": true,
- "version": "0.71.0",
+ "version": "0.87.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
- "test": "vitest run"
+ "test": "vitest run",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build"
},
"dependencies": {
"oidc-client-ts": "^3.5.0",
@@ -17,6 +19,7 @@
"react-oidc-context": "^3.3.1"
},
"devDependencies": {
+ "@storybook/react-vite": "^10.5.8",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.4",
@@ -26,6 +29,7 @@
"@vitejs/plugin-react": "^6.0.4",
"jsdom": "^30.0.1",
"oxlint": "^1.75.0",
+ "storybook": "^10.5.8",
"typescript": "~6.0.2",
"vite": "^8.2.0",
"vitest": "^4.1.10"
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index d82b64db1..a3f53c145 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -21,9 +21,12 @@ importers:
specifier: ^3.3.1
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.8)
devDependencies:
+ '@storybook/react-vite':
+ specifier: ^10.5.8
+ version: 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))
'@testing-library/jest-dom':
specifier: ^7.0.1
- version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)))
+ version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)))
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -41,22 +44,25 @@ importers:
version: 19.2.4(@types/react@19.2.18)
'@vitejs/plugin-react':
specifier: ^6.0.4
- version: 6.0.5(vite@8.2.1(@types/node@24.13.3))
+ version: 6.0.5(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))
jsdom:
specifier: ^30.0.1
version: 30.0.1
oxlint:
specifier: ^1.75.0
version: 1.78.0
+ storybook:
+ specifier: ^10.5.8
+ version: 10.5.8(@types/react@19.2.18)(react@19.2.8)
typescript:
specifier: ~6.0.2
version: 6.0.3
vite:
specifier: ^8.2.0
- version: 8.2.1(@types/node@24.13.3)
+ version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)
vitest:
specifier: ^4.1.10
- version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3))
+ version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))
packages:
@@ -75,14 +81,73 @@ packages:
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.8':
+ resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.8':
+ resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.8':
+ resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.8':
+ resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+ engines: {node: '>=6.9.0'}
+
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@@ -123,6 +188,180 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
+ '@emnapi/core@1.11.2':
+ resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
+
+ '@emnapi/core@1.9.2':
+ resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
+
+ '@emnapi/runtime@1.11.2':
+ resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
+
+ '@emnapi/runtime@1.9.2':
+ resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
+ '@emnapi/wasi-threads@1.2.2':
+ resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
+
+ '@esbuild/aix-ppc64@0.28.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.28.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.28.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.28.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.28.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.28.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.28.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.28.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.28.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.28.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.28.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.28.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.28.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.28.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.28.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.28.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.28.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.28.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.28.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.28.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.28.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
'@exodus/bytes@1.15.1':
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -132,219 +371,553 @@ packages:
'@noble/hashes':
optional: true
+ '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0':
+ resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==}
+ peerDependencies:
+ typescript: '>= 4.3.x'
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
- '@oxc-project/types@0.144.0':
- resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
- '@oxlint/binding-android-arm-eabi@1.78.0':
- resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==}
+ '@napi-rs/wasm-runtime@1.2.3':
+ resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
+ '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
+
+ '@oxc-parser/binding-android-arm-eabi@0.127.0':
+ resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
- '@oxlint/binding-android-arm64@1.78.0':
- resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==}
+ '@oxc-parser/binding-android-arm64@0.127.0':
+ resolution: {integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@oxlint/binding-darwin-arm64@1.78.0':
- resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==}
+ '@oxc-parser/binding-darwin-arm64@0.127.0':
+ resolution: {integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@oxlint/binding-darwin-x64@1.78.0':
- resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==}
+ '@oxc-parser/binding-darwin-x64@0.127.0':
+ resolution: {integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@oxlint/binding-freebsd-x64@1.78.0':
- resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==}
+ '@oxc-parser/binding-freebsd-x64@0.127.0':
+ resolution: {integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@oxlint/binding-linux-arm-gnueabihf@1.78.0':
- resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==}
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0':
+ resolution: {integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxlint/binding-linux-arm-musleabihf@1.78.0':
- resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==}
+ '@oxc-parser/binding-linux-arm-musleabihf@0.127.0':
+ resolution: {integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxlint/binding-linux-arm64-gnu@1.78.0':
- resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==}
+ '@oxc-parser/binding-linux-arm64-gnu@0.127.0':
+ resolution: {integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@oxlint/binding-linux-arm64-musl@1.78.0':
- resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==}
+ '@oxc-parser/binding-linux-arm64-musl@0.127.0':
+ resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@oxlint/binding-linux-ppc64-gnu@1.78.0':
- resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==}
+ '@oxc-parser/binding-linux-ppc64-gnu@0.127.0':
+ resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- '@oxlint/binding-linux-riscv64-gnu@1.78.0':
- resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==}
+ '@oxc-parser/binding-linux-riscv64-gnu@0.127.0':
+ resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- '@oxlint/binding-linux-riscv64-musl@1.78.0':
- resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==}
+ '@oxc-parser/binding-linux-riscv64-musl@0.127.0':
+ resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- '@oxlint/binding-linux-s390x-gnu@1.78.0':
- resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==}
+ '@oxc-parser/binding-linux-s390x-gnu@0.127.0':
+ resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- '@oxlint/binding-linux-x64-gnu@1.78.0':
- resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==}
+ '@oxc-parser/binding-linux-x64-gnu@0.127.0':
+ resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@oxlint/binding-linux-x64-musl@1.78.0':
- resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==}
+ '@oxc-parser/binding-linux-x64-musl@0.127.0':
+ resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@oxlint/binding-openharmony-arm64@1.78.0':
- resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==}
+ '@oxc-parser/binding-openharmony-arm64@0.127.0':
+ resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@oxlint/binding-win32-arm64-msvc@1.78.0':
- resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==}
+ '@oxc-parser/binding-wasm32-wasi@0.127.0':
+ resolution: {integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@oxc-parser/binding-win32-arm64-msvc@0.127.0':
+ resolution: {integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@oxlint/binding-win32-ia32-msvc@1.78.0':
- resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==}
+ '@oxc-parser/binding-win32-ia32-msvc@0.127.0':
+ resolution: {integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
- '@oxlint/binding-win32-x64-msvc@1.78.0':
- resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==}
+ '@oxc-parser/binding-win32-x64-msvc@0.127.0':
+ resolution: {integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
- '@rolldown/binding-android-arm64@1.2.4':
- resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-project/types@0.127.0':
+ resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
+
+ '@oxc-project/types@0.144.0':
+ resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
+
+ '@oxc-resolver/binding-android-arm-eabi@11.24.2':
+ resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==}
+ cpu: [arm]
+ os: [android]
+
+ '@oxc-resolver/binding-android-arm64@11.24.2':
+ resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==}
cpu: [arm64]
os: [android]
- '@rolldown/binding-darwin-arm64@1.2.4':
- resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-darwin-arm64@11.24.2':
+ resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==}
cpu: [arm64]
os: [darwin]
- '@rolldown/binding-darwin-x64@1.2.4':
- resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-darwin-x64@11.24.2':
+ resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==}
cpu: [x64]
os: [darwin]
- '@rolldown/binding-freebsd-x64@1.2.4':
- resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-freebsd-x64@11.24.2':
+ resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==}
cpu: [x64]
os: [freebsd]
- '@rolldown/binding-linux-arm-gnueabihf@1.2.4':
- resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
+ resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==}
cpu: [arm]
os: [linux]
- '@rolldown/binding-linux-arm64-gnu@1.2.4':
- resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
+ resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
+ resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==}
cpu: [arm64]
os: [linux]
- '@rolldown/binding-linux-arm64-musl@1.2.4':
- resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-linux-arm64-musl@11.24.2':
+ resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==}
cpu: [arm64]
os: [linux]
- '@rolldown/binding-linux-ppc64-gnu@1.2.4':
- resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
+ resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==}
cpu: [ppc64]
os: [linux]
- '@rolldown/binding-linux-s390x-gnu@1.2.4':
- resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
+ resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
+ resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
+ resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==}
cpu: [s390x]
os: [linux]
- '@rolldown/binding-linux-x64-gnu@1.2.4':
- resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-linux-x64-gnu@11.24.2':
+ resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==}
cpu: [x64]
os: [linux]
- '@rolldown/binding-linux-x64-musl@1.2.4':
- resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-linux-x64-musl@11.24.2':
+ resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==}
cpu: [x64]
os: [linux]
- '@rolldown/binding-openharmony-arm64@1.2.4':
- resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-openharmony-arm64@11.24.2':
+ resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==}
cpu: [arm64]
os: [openharmony]
- '@rolldown/binding-win32-arm64-msvc@1.2.4':
- resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-wasm32-wasi@11.24.2':
+ resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ '@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
+ resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==}
cpu: [arm64]
os: [win32]
- '@rolldown/binding-win32-x64-msvc@1.2.4':
- resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@oxc-resolver/binding-win32-x64-msvc@11.24.2':
+ resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==}
cpu: [x64]
os: [win32]
- '@rolldown/pluginutils@1.0.1':
- resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
-
- '@standard-schema/spec@1.1.0':
- resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+ '@oxlint/binding-android-arm-eabi@1.78.0':
+ resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [android]
- '@testing-library/dom@10.4.1':
- resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
- engines: {node: '>=18'}
+ '@oxlint/binding-android-arm64@1.78.0':
+ resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@oxlint/binding-darwin-arm64@1.78.0':
+ resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@oxlint/binding-darwin-x64@1.78.0':
+ resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@oxlint/binding-freebsd-x64@1.78.0':
+ resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@oxlint/binding-linux-arm-gnueabihf@1.78.0':
+ resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxlint/binding-linux-arm-musleabihf@1.78.0':
+ resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxlint/binding-linux-arm64-gnu@1.78.0':
+ resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oxlint/binding-linux-arm64-musl@1.78.0':
+ resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oxlint/binding-linux-ppc64-gnu@1.78.0':
+ resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@oxlint/binding-linux-riscv64-gnu@1.78.0':
+ resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxlint/binding-linux-riscv64-musl@1.78.0':
+ resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxlint/binding-linux-s390x-gnu@1.78.0':
+ resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@oxlint/binding-linux-x64-gnu@1.78.0':
+ resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@oxlint/binding-linux-x64-musl@1.78.0':
+ resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@oxlint/binding-openharmony-arm64@1.78.0':
+ resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@oxlint/binding-win32-arm64-msvc@1.78.0':
+ resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@oxlint/binding-win32-ia32-msvc@1.78.0':
+ resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@oxlint/binding-win32-x64-msvc@1.78.0':
+ resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/binding-android-arm64@1.2.4':
+ resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.2.4':
+ resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.2.4':
+ resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.2.4':
+ resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-musl@1.2.4':
+ resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.2.4':
+ resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rolldown/binding-linux-s390x-gnu@1.2.4':
+ resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-gnu@1.2.4':
+ resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-musl@1.2.4':
+ resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-openharmony-arm64@1.2.4':
+ resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-win32-arm64-msvc@1.2.4':
+ resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.2.4':
+ resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
+ '@rollup/pluginutils@5.4.0':
+ resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
+ '@storybook/builder-vite@10.5.8':
+ resolution: {integrity: sha512-UeRnn7yT55WmBlHNOQzLrvN7vsHEvVgIukhKDO+4cMbGXN87wZkbxhx6NstpuXRH8OxGqwKS0SZNVp+SC1ftLQ==}
+ peerDependencies:
+ storybook: ^10.5.8
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ '@storybook/csf-plugin@10.5.8':
+ resolution: {integrity: sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==}
+ peerDependencies:
+ esbuild: '*'
+ rollup: '*'
+ storybook: ^10.5.8
+ vite: '*'
+ webpack: '*'
+ peerDependenciesMeta:
+ esbuild:
+ optional: true
+ rollup:
+ optional: true
+ vite:
+ optional: true
+ webpack:
+ optional: true
+
+ '@storybook/global@5.0.0':
+ resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==}
+
+ '@storybook/icons@2.1.0':
+ resolution: {integrity: sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@storybook/react-dom-shim@10.5.8':
+ resolution: {integrity: sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==}
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.5.8
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@storybook/react-vite@10.5.8':
+ resolution: {integrity: sha512-ioMJGi4YzueGsJBlYio+2+UhfCFB9QV5Bs1lOilkek+a4BZgKJl0D1mVSJl6k96stQBPZmLgI9/l0hLVcUL6Kg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.5.8
+ typescript: '>= 4.9.x'
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@storybook/react@10.5.8':
+ resolution: {integrity: sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==}
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.5.8
+ typescript: '>= 4.9.x'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ typescript:
+ optional: true
+
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@testing-library/jest-dom@7.0.1':
resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==}
@@ -377,15 +950,33 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+ '@types/doctrine@0.0.9':
+ resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==}
+
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -400,6 +991,9 @@ packages:
'@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
+ '@types/resolve@1.20.6':
+ resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
+
'@vitejs/plugin-react@6.0.5':
resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -413,6 +1007,9 @@ packages:
babel-plugin-react-compiler:
optional: true
+ '@vitest/expect@3.2.4':
+ resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
+
'@vitest/expect@4.1.10':
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
@@ -427,6 +1024,9 @@ packages:
vite:
optional: true
+ '@vitest/pretty-format@3.2.4':
+ resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
+
'@vitest/pretty-format@4.1.10':
resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
@@ -436,12 +1036,26 @@ packages:
'@vitest/snapshot@4.1.10':
resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
+ '@vitest/spy@3.2.4':
+ resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
+
'@vitest/spy@4.1.10':
resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
+ '@vitest/utils@3.2.4':
+ resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
+
'@vitest/utils@4.1.10':
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
+ '@webcontainer/env@1.1.1':
+ resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==}
+
+ acorn@8.18.0:
+ resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -461,13 +1075,50 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ ast-types@0.16.1:
+ resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
+ engines: {node: '>=4'}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ baseline-browser-mapping@2.11.14:
+ resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+ brace-expansion@5.0.9:
+ resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
+ engines: {node: 20 || >=22}
+
+ browserslist@4.28.8:
+ resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ bundle-name@4.1.0:
+ resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
+ engines: {node: '>=18'}
+
+ caniuse-lite@1.0.30001809:
+ resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
+
+ chai@5.3.3:
+ resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+ engines: {node: '>=18'}
+
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
+ check-error@2.1.3:
+ resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+ engines: {node: '>= 16'}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -485,9 +1136,34 @@ packages:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+ deep-eql@5.0.2:
+ resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+ engines: {node: '>=6'}
+
+ default-browser-id@5.0.1:
+ resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
+ engines: {node: '>=18'}
+
+ default-browser@5.5.0:
+ resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
+ engines: {node: '>=18'}
+
+ define-lazy-prop@3.0.0:
+ resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
+ engines: {node: '>=12'}
+
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -496,22 +1172,58 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ doctrine@3.0.0:
+ resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
+ engines: {node: '>=6.0.0'}
+
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+ electron-to-chromium@1.5.407:
+ resolution: {integrity: sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==}
+
+ empathic@2.0.1:
+ resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==}
+ engines: {node: '>=14'}
+
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
es-module-lexer@2.3.1:
resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
+ esbuild@0.28.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ esprima@4.0.1:
+ resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@@ -530,6 +1242,21 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ glob@13.0.6:
+ resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
+ engines: {node: 18 || 20 || >=22}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -538,9 +1265,27 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
+ is-core-module@2.16.2:
+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
+ engines: {node: '>= 0.4'}
+
+ is-docker@3.0.0:
+ resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ hasBin: true
+
+ is-inside-container@1.0.0:
+ resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
+ engines: {node: '>=14.16'}
+ hasBin: true
+
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+ is-wsl@3.1.1:
+ resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
+ engines: {node: '>=16'}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -553,6 +1298,19 @@ packages:
canvas:
optional: true
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ jsonc-parser@3.3.1:
+ resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
+
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
@@ -627,10 +1385,16 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
+ loupe@3.2.1:
+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -645,11 +1409,29 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
+ minimatch@10.2.6:
+ resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
+ engines: {node: 18 || 20 || >=22}
+
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ minipass@7.1.3:
+ resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ node-releases@2.0.53:
+ resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
+ engines: {node: '>=18'}
+
obug@2.1.4:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'}
@@ -658,6 +1440,17 @@ packages:
resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==}
engines: {node: '>=18'}
+ open@10.2.0:
+ resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
+ engines: {node: '>=18'}
+
+ oxc-parser@0.127.0:
+ resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+
+ oxc-resolver@11.24.2:
+ resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==}
+
oxlint@1.78.0:
resolution: {integrity: sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -674,9 +1467,20 @@ packages:
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ path-scurry@2.0.2:
+ resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
+ engines: {node: 18 || 20 || >=22}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ pathval@2.0.1:
+ resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+ engines: {node: '>= 14.16'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -696,6 +1500,15 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ react-docgen-typescript@2.4.0:
+ resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
+ peerDependencies:
+ typescript: '>= 4.3.x'
+
+ react-docgen@8.0.3:
+ resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==}
+ engines: {node: ^20.9.0 || >=22}
+
react-dom@19.2.8:
resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
peerDependencies:
@@ -715,6 +1528,10 @@ packages:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
+ recast@0.23.21:
+ resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==}
+ engines: {node: '>= 4'}
+
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@@ -723,11 +1540,20 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ resolve@1.22.12:
+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
rolldown@1.2.4:
resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ run-applescript@7.1.0:
+ resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
+ engines: {node: '>=18'}
+
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@@ -735,6 +1561,15 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -742,19 +1577,53 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ source-map@0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
+ storybook@10.5.8:
+ resolution: {integrity: sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==}
+ hasBin: true
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ prettier: ^2 || ^3
+ vite-plus: ^0.1.15 || ^0.2.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ prettier:
+ optional: true
+ vite-plus:
+ optional: true
+
+ strip-bom@3.0.0:
+ resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
+ engines: {node: '>=4'}
+
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
+ strip-indent@4.1.1:
+ resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
+ engines: {node: '>=12'}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -766,10 +1635,18 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@2.0.0:
+ resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
+ engines: {node: '>=14.0.0'}
+
tinyrainbow@3.1.1:
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
engines: {node: '>=14.0.0'}
+ tinyspy@4.0.4:
+ resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
+ engines: {node: '>=14.0.0'}
+
tldts-core@7.4.10:
resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
@@ -785,6 +1662,17 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
+ ts-dedent@2.3.0:
+ resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
+ engines: {node: '>=6.10'}
+
+ tsconfig-paths@4.2.0:
+ resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
+ engines: {node: '>=6'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
@@ -797,6 +1685,21 @@ packages:
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
engines: {node: '>=22.19.0'}
+ unplugin@2.3.11:
+ resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
+ engines: {node: '>=18.12.0'}
+
+ update-browserslist-db@1.3.1:
+ resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
vite@8.2.1:
resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -889,6 +1792,9 @@ packages:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
+ webpack-virtual-modules@0.6.2:
+ resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
@@ -897,84 +1803,471 @@ packages:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
- whatwg-url@17.1.0:
- resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
- engines: {node: ^22.14.0 || >=24.0.0}
+ whatwg-url@17.1.0:
+ resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
+ engines: {node: ^22.14.0 || >=24.0.0}
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ ws@8.21.3:
+ resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ wsl-utils@0.1.0:
+ resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
+ engines: {node: '>=18'}
+
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+snapshots:
+
+ '@adobe/css-tools@4.5.0': {}
+
+ '@asamuzakjp/css-color@6.0.7':
+ dependencies:
+ '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+ lru-cache: 11.5.2
+
+ '@asamuzakjp/dom-selector@8.3.2':
+ dependencies:
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.2
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.8':
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.28.8
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+
+ '@babel/parser@7.29.8':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+
+ '@babel/traverse@7.29.8':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.8':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/color-helpers@6.1.0': {}
+
+ '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.1.0
+ '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
+ '@emnapi/core@1.11.2':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.2
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/core@1.9.2':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.11.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.9.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.2':
+ optional: true
+
+ '@exodus/bytes@1.15.1': {}
+
+ '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))':
+ dependencies:
+ glob: 13.0.6
+ react-docgen-typescript: 2.4.0(typescript@6.0.3)
+ vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)
+ optionalDependencies:
+ typescript: 6.0.3
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
+ dependencies:
+ '@emnapi/core': 1.11.2
+ '@emnapi/runtime': 1.11.2
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
+ '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
+ dependencies:
+ '@emnapi/core': 1.9.2
+ '@emnapi/runtime': 1.9.2
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
+ '@oxc-parser/binding-android-arm-eabi@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-android-arm64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-darwin-arm64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-darwin-x64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-freebsd-x64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm-musleabihf@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm64-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm64-musl@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-ppc64-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-riscv64-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-riscv64-musl@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-s390x-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-x64-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-x64-musl@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-openharmony-arm64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-wasm32-wasi@0.127.0':
+ dependencies:
+ '@emnapi/core': 1.9.2
+ '@emnapi/runtime': 1.9.2
+ '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ optional: true
+
+ '@oxc-parser/binding-win32-arm64-msvc@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-win32-ia32-msvc@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-win32-x64-msvc@0.127.0':
+ optional: true
+
+ '@oxc-project/types@0.127.0': {}
- why-is-node-running@2.3.0:
- resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
- engines: {node: '>=8'}
- hasBin: true
+ '@oxc-project/types@0.144.0': {}
- xml-name-validator@5.0.0:
- resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
- engines: {node: '>=18'}
+ '@oxc-resolver/binding-android-arm-eabi@11.24.2':
+ optional: true
- xmlchars@2.2.0:
- resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ '@oxc-resolver/binding-android-arm64@11.24.2':
+ optional: true
-snapshots:
+ '@oxc-resolver/binding-darwin-arm64@11.24.2':
+ optional: true
- '@adobe/css-tools@4.5.0': {}
+ '@oxc-resolver/binding-darwin-x64@11.24.2':
+ optional: true
- '@asamuzakjp/css-color@6.0.7':
- dependencies:
- '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
- '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
- '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
- '@csstools/css-tokenizer': 4.0.0
- lru-cache: 11.5.2
+ '@oxc-resolver/binding-freebsd-x64@11.24.2':
+ optional: true
- '@asamuzakjp/dom-selector@8.3.2':
- dependencies:
- bidi-js: 1.0.3
- css-tree: 3.2.1
- is-potential-custom-element-name: 1.0.1
- lru-cache: 11.5.2
+ '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
+ optional: true
- '@babel/code-frame@7.29.7':
- dependencies:
- '@babel/helper-validator-identifier': 7.29.7
- js-tokens: 4.0.0
- picocolors: 1.1.1
+ '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
+ optional: true
- '@babel/helper-validator-identifier@7.29.7': {}
+ '@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
+ optional: true
- '@babel/runtime@7.29.7': {}
+ '@oxc-resolver/binding-linux-arm64-musl@11.24.2':
+ optional: true
- '@bramus/specificity@2.4.2':
- dependencies:
- css-tree: 3.2.1
+ '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
+ optional: true
- '@csstools/color-helpers@6.1.0': {}
+ '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
+ optional: true
- '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
- dependencies:
- '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
- '@csstools/css-tokenizer': 4.0.0
+ '@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
+ optional: true
- '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
- dependencies:
- '@csstools/color-helpers': 6.1.0
- '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
- '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
- '@csstools/css-tokenizer': 4.0.0
+ '@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
+ optional: true
- '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
- dependencies:
- '@csstools/css-tokenizer': 4.0.0
+ '@oxc-resolver/binding-linux-x64-gnu@11.24.2':
+ optional: true
- '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)':
- optionalDependencies:
- css-tree: 3.2.1
+ '@oxc-resolver/binding-linux-x64-musl@11.24.2':
+ optional: true
- '@csstools/css-tokenizer@4.0.0': {}
+ '@oxc-resolver/binding-openharmony-arm64@11.24.2':
+ optional: true
- '@exodus/bytes@1.15.1': {}
+ '@oxc-resolver/binding-wasm32-wasi@11.24.2':
+ dependencies:
+ '@emnapi/core': 1.11.2
+ '@emnapi/runtime': 1.11.2
+ '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
+ optional: true
- '@jridgewell/sourcemap-codec@1.5.5': {}
+ '@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
+ optional: true
- '@oxc-project/types@0.144.0': {}
+ '@oxc-resolver/binding-win32-x64-msvc@11.24.2':
+ optional: true
'@oxlint/binding-android-arm-eabi@1.78.0':
optional: true
@@ -1077,8 +2370,89 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
+ '@rollup/pluginutils@5.4.0':
+ dependencies:
+ '@types/estree': 1.0.9
+ estree-walker: 2.0.2
+ picomatch: 4.0.5
+
'@standard-schema/spec@1.1.0': {}
+ '@storybook/builder-vite@10.5.8(esbuild@0.28.2)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))':
+ dependencies:
+ '@storybook/csf-plugin': 10.5.8(esbuild@0.28.2)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))
+ storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8)
+ ts-dedent: 2.3.0
+ vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)
+ transitivePeerDependencies:
+ - esbuild
+ - rollup
+ - webpack
+
+ '@storybook/csf-plugin@10.5.8(esbuild@0.28.2)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))':
+ dependencies:
+ storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8)
+ unplugin: 2.3.11
+ optionalDependencies:
+ esbuild: 0.28.2
+ vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)
+
+ '@storybook/global@5.0.0': {}
+
+ '@storybook/icons@2.1.0(react@19.2.8)':
+ dependencies:
+ react: 19.2.8
+
+ '@storybook/react-dom-shim@10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))':
+ dependencies:
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.2.18
+ '@types/react-dom': 19.2.4(@types/react@19.2.18)
+
+ '@storybook/react-vite@10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))':
+ dependencies:
+ '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))
+ '@rollup/pluginutils': 5.4.0
+ '@storybook/builder-vite': 10.5.8(esbuild@0.28.2)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))
+ '@storybook/react': 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3)
+ empathic: 2.0.1
+ magic-string: 0.30.21
+ react: 19.2.8
+ react-docgen: 8.0.3
+ react-dom: 19.2.8(react@19.2.8)
+ resolve: 1.22.12
+ storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8)
+ tsconfig-paths: 4.2.0
+ vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)
+ optionalDependencies:
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+ - esbuild
+ - rollup
+ - supports-color
+ - webpack
+
+ '@storybook/react@10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3)':
+ dependencies:
+ '@storybook/global': 5.0.0
+ '@storybook/react-dom-shim': 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))
+ react: 19.2.8
+ react-docgen: 8.0.3
+ react-docgen-typescript: 2.4.0(typescript@6.0.3)
+ react-dom: 19.2.8(react@19.2.8)
+ storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.2.18
+ '@types/react-dom': 19.2.4(@types/react@19.2.18)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
@@ -1090,7 +2464,16 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
- '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)))':
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.5.0
+ aria-query: 5.3.2
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)))':
dependencies:
'@adobe/css-tools': 4.5.0
'@testing-library/dom': 10.4.1
@@ -1100,7 +2483,7 @@ snapshots:
picocolors: 1.1.1
redent: 3.0.0
optionalDependencies:
- vitest: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3))
+ vitest: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
@@ -1116,8 +2499,34 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.1
+ '@tybys/wasm-util@0.10.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@types/aria-query@5.0.4': {}
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.8
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -1125,6 +2534,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
+ '@types/doctrine@0.0.9': {}
+
'@types/estree@1.0.9': {}
'@types/node@24.13.3':
@@ -1139,10 +2550,20 @@ snapshots:
dependencies:
csstype: 3.2.3
- '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@24.13.3))':
+ '@types/resolve@1.20.6': {}
+
+ '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.2.1(@types/node@24.13.3)
+ vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)
+
+ '@vitest/expect@3.2.4':
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/spy': 3.2.4
+ '@vitest/utils': 3.2.4
+ chai: 5.3.3
+ tinyrainbow: 2.0.0
'@vitest/expect@4.1.10':
dependencies:
@@ -1153,13 +2574,17 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
- '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3))':
+ '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.2.1(@types/node@24.13.3)
+ vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)
+
+ '@vitest/pretty-format@3.2.4':
+ dependencies:
+ tinyrainbow: 2.0.0
'@vitest/pretty-format@4.1.10':
dependencies:
@@ -1177,14 +2602,28 @@ snapshots:
magic-string: 0.30.21
pathe: 2.0.3
+ '@vitest/spy@3.2.4':
+ dependencies:
+ tinyspy: 4.0.4
+
'@vitest/spy@4.1.10': {}
+ '@vitest/utils@3.2.4':
+ dependencies:
+ '@vitest/pretty-format': 3.2.4
+ loupe: 3.2.1
+ tinyrainbow: 2.0.0
+
'@vitest/utils@4.1.10':
dependencies:
'@vitest/pretty-format': 4.1.10
convert-source-map: 2.0.0
tinyrainbow: 3.1.1
+ '@webcontainer/env@1.1.1': {}
+
+ acorn@8.18.0: {}
+
ansi-regex@5.0.1: {}
ansi-styles@5.2.0: {}
@@ -1197,12 +2636,48 @@ snapshots:
assertion-error@2.0.1: {}
+ ast-types@0.16.1:
+ dependencies:
+ tslib: 2.8.1
+
+ balanced-match@4.0.4: {}
+
+ baseline-browser-mapping@2.11.14: {}
+
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
+ brace-expansion@5.0.9:
+ dependencies:
+ balanced-match: 4.0.4
+
+ browserslist@4.28.8:
+ dependencies:
+ baseline-browser-mapping: 2.11.14
+ caniuse-lite: 1.0.30001809
+ electron-to-chromium: 1.5.407
+ node-releases: 2.0.53
+ update-browserslist-db: 1.3.1(browserslist@4.28.8)
+
+ bundle-name@4.1.0:
+ dependencies:
+ run-applescript: 7.1.0
+
+ caniuse-lite@1.0.30001809: {}
+
+ chai@5.3.3:
+ dependencies:
+ assertion-error: 2.0.1
+ check-error: 2.1.3
+ deep-eql: 5.0.2
+ loupe: 3.2.1
+ pathval: 2.0.1
+
chai@6.2.2: {}
+ check-error@2.1.3: {}
+
convert-source-map@2.0.0: {}
css-tree@3.2.1:
@@ -1221,24 +2696,86 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
decimal.js@10.6.0: {}
+ deep-eql@5.0.2: {}
+
+ default-browser-id@5.0.1: {}
+
+ default-browser@5.5.0:
+ dependencies:
+ bundle-name: 4.1.0
+ default-browser-id: 5.0.1
+
+ define-lazy-prop@3.0.0: {}
+
dequal@2.0.3: {}
detect-libc@2.1.2: {}
+ doctrine@3.0.0:
+ dependencies:
+ esutils: 2.0.3
+
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
+ electron-to-chromium@1.5.407: {}
+
+ empathic@2.0.1: {}
+
entities@8.0.0: {}
+ es-errors@1.3.0: {}
+
es-module-lexer@2.3.1: {}
+ esbuild@0.28.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
+
+ escalade@3.2.0: {}
+
+ esprima@4.0.1: {}
+
+ estree-walker@2.0.2: {}
+
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
+ esutils@2.0.3: {}
+
expect-type@1.4.0: {}
fdir@6.5.0(picomatch@4.0.5):
@@ -1248,6 +2785,20 @@ snapshots:
fsevents@2.3.3:
optional: true
+ function-bind@1.1.2: {}
+
+ gensync@1.0.0-beta.2: {}
+
+ glob@13.0.6:
+ dependencies:
+ minimatch: 10.2.6
+ minipass: 7.1.3
+ path-scurry: 2.0.2
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.1
@@ -1256,8 +2807,22 @@ snapshots:
indent-string@4.0.0: {}
+ is-core-module@2.16.2:
+ dependencies:
+ hasown: 2.0.4
+
+ is-docker@3.0.0: {}
+
+ is-inside-container@1.0.0:
+ dependencies:
+ is-docker: 3.0.0
+
is-potential-custom-element-name@1.0.1: {}
+ is-wsl@3.1.1:
+ dependencies:
+ is-inside-container: 1.0.0
+
js-tokens@4.0.0: {}
jsdom@30.0.1:
@@ -1286,6 +2851,12 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ jsesc@3.1.0: {}
+
+ json5@2.2.3: {}
+
+ jsonc-parser@3.3.1: {}
+
jwt-decode@4.0.0: {}
lightningcss-android-arm64@1.33.0:
@@ -1337,8 +2908,14 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
+ loupe@3.2.1: {}
+
lru-cache@11.5.2: {}
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
lz-string@1.5.0: {}
magic-string@0.30.21:
@@ -1349,14 +2926,80 @@ snapshots:
min-indent@1.0.1: {}
+ minimatch@10.2.6:
+ dependencies:
+ brace-expansion: 5.0.9
+
+ minimist@1.2.8: {}
+
+ minipass@7.1.3: {}
+
+ ms@2.1.3: {}
+
nanoid@3.3.18: {}
+ node-releases@2.0.53: {}
+
obug@2.1.4: {}
oidc-client-ts@3.5.0:
dependencies:
jwt-decode: 4.0.0
+ open@10.2.0:
+ dependencies:
+ default-browser: 5.5.0
+ define-lazy-prop: 3.0.0
+ is-inside-container: 1.0.0
+ wsl-utils: 0.1.0
+
+ oxc-parser@0.127.0:
+ dependencies:
+ '@oxc-project/types': 0.127.0
+ optionalDependencies:
+ '@oxc-parser/binding-android-arm-eabi': 0.127.0
+ '@oxc-parser/binding-android-arm64': 0.127.0
+ '@oxc-parser/binding-darwin-arm64': 0.127.0
+ '@oxc-parser/binding-darwin-x64': 0.127.0
+ '@oxc-parser/binding-freebsd-x64': 0.127.0
+ '@oxc-parser/binding-linux-arm-gnueabihf': 0.127.0
+ '@oxc-parser/binding-linux-arm-musleabihf': 0.127.0
+ '@oxc-parser/binding-linux-arm64-gnu': 0.127.0
+ '@oxc-parser/binding-linux-arm64-musl': 0.127.0
+ '@oxc-parser/binding-linux-ppc64-gnu': 0.127.0
+ '@oxc-parser/binding-linux-riscv64-gnu': 0.127.0
+ '@oxc-parser/binding-linux-riscv64-musl': 0.127.0
+ '@oxc-parser/binding-linux-s390x-gnu': 0.127.0
+ '@oxc-parser/binding-linux-x64-gnu': 0.127.0
+ '@oxc-parser/binding-linux-x64-musl': 0.127.0
+ '@oxc-parser/binding-openharmony-arm64': 0.127.0
+ '@oxc-parser/binding-wasm32-wasi': 0.127.0
+ '@oxc-parser/binding-win32-arm64-msvc': 0.127.0
+ '@oxc-parser/binding-win32-ia32-msvc': 0.127.0
+ '@oxc-parser/binding-win32-x64-msvc': 0.127.0
+
+ oxc-resolver@11.24.2:
+ optionalDependencies:
+ '@oxc-resolver/binding-android-arm-eabi': 11.24.2
+ '@oxc-resolver/binding-android-arm64': 11.24.2
+ '@oxc-resolver/binding-darwin-arm64': 11.24.2
+ '@oxc-resolver/binding-darwin-x64': 11.24.2
+ '@oxc-resolver/binding-freebsd-x64': 11.24.2
+ '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2
+ '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2
+ '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-arm64-musl': 11.24.2
+ '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2
+ '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-x64-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-x64-musl': 11.24.2
+ '@oxc-resolver/binding-openharmony-arm64': 11.24.2
+ '@oxc-resolver/binding-wasm32-wasi': 11.24.2
+ '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2
+ '@oxc-resolver/binding-win32-x64-msvc': 11.24.2
+
oxlint@1.78.0:
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.78.0
@@ -1383,8 +3026,17 @@ snapshots:
dependencies:
entities: 8.0.0
+ path-parse@1.0.7: {}
+
+ path-scurry@2.0.2:
+ dependencies:
+ lru-cache: 11.5.2
+ minipass: 7.1.3
+
pathe@2.0.3: {}
+ pathval@2.0.1: {}
+
picocolors@1.1.1: {}
picomatch@4.0.5: {}
@@ -1403,6 +3055,25 @@ snapshots:
punycode@2.3.1: {}
+ react-docgen-typescript@2.4.0(typescript@6.0.3):
+ dependencies:
+ typescript: 6.0.3
+
+ react-docgen@8.0.3:
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ '@types/babel__core': 7.20.5
+ '@types/babel__traverse': 7.28.0
+ '@types/doctrine': 0.0.9
+ '@types/resolve': 1.20.6
+ doctrine: 3.0.0
+ resolve: 1.22.12
+ strip-indent: 4.1.1
+ transitivePeerDependencies:
+ - supports-color
+
react-dom@19.2.8(react@19.2.8):
dependencies:
react: 19.2.8
@@ -1417,6 +3088,14 @@ snapshots:
react@19.2.8: {}
+ recast@0.23.21:
+ dependencies:
+ ast-types: 0.16.1
+ esprima: 4.0.1
+ source-map: 0.6.1
+ tiny-invariant: 1.3.3
+ tslib: 2.8.1
+
redent@3.0.0:
dependencies:
indent-string: 4.0.0
@@ -1424,6 +3103,13 @@ snapshots:
require-from-string@2.0.2: {}
+ resolve@1.22.12:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
rolldown@1.2.4:
dependencies:
'@oxc-project/types': 0.144.0
@@ -1444,26 +3130,68 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.4
'@rolldown/binding-win32-x64-msvc': 1.2.4
+ run-applescript@7.1.0: {}
+
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {}
+ semver@6.3.1: {}
+
+ semver@7.8.5: {}
+
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
+ source-map@0.6.1: {}
+
stackback@0.0.2: {}
std-env@4.2.0: {}
+ storybook@10.5.8(@types/react@19.2.18)(react@19.2.8):
+ dependencies:
+ '@storybook/global': 5.0.0
+ '@storybook/icons': 2.1.0(react@19.2.8)
+ '@testing-library/dom': 10.4.1
+ '@testing-library/jest-dom': 6.9.1
+ '@testing-library/user-event': 14.6.4(@testing-library/dom@10.4.1)
+ '@vitest/expect': 3.2.4
+ '@vitest/spy': 3.2.4
+ '@webcontainer/env': 1.1.1
+ esbuild: 0.28.2
+ jsonc-parser: 3.3.1
+ open: 10.2.0
+ oxc-parser: 0.127.0
+ oxc-resolver: 11.24.2
+ recast: 0.23.21
+ semver: 7.8.5
+ use-sync-external-store: 1.6.0(react@19.2.8)
+ ws: 8.21.3
+ optionalDependencies:
+ '@types/react': 19.2.18
+ transitivePeerDependencies:
+ - bufferutil
+ - react
+ - utf-8-validate
+
+ strip-bom@3.0.0: {}
+
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
+ strip-indent@4.1.1: {}
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
symbol-tree@3.2.4: {}
+ tiny-invariant@1.3.3: {}
+
tinybench@2.9.0: {}
tinyexec@1.3.0: {}
@@ -1473,8 +3201,12 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
+ tinyrainbow@2.0.0: {}
+
tinyrainbow@3.1.1: {}
+ tinyspy@4.0.4: {}
+
tldts-core@7.4.10: {}
tldts@7.4.10:
@@ -1489,13 +3221,40 @@ snapshots:
dependencies:
punycode: 2.3.1
+ ts-dedent@2.3.0: {}
+
+ tsconfig-paths@4.2.0:
+ dependencies:
+ json5: 2.2.3
+ minimist: 1.2.8
+ strip-bom: 3.0.0
+
+ tslib@2.8.1: {}
+
typescript@6.0.3: {}
undici-types@7.18.2: {}
undici@8.10.0: {}
- vite@8.2.1(@types/node@24.13.3):
+ unplugin@2.3.11:
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ acorn: 8.18.0
+ picomatch: 4.0.5
+ webpack-virtual-modules: 0.6.2
+
+ update-browserslist-db@1.3.1(browserslist@4.28.8):
+ dependencies:
+ browserslist: 4.28.8
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ use-sync-external-store@1.6.0(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+
+ vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -1504,12 +3263,13 @@ snapshots:
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 24.13.3
+ esbuild: 0.28.2
fsevents: 2.3.3
- vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)):
+ vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)):
dependencies:
'@vitest/expect': 4.1.10
- '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3))
+ '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -1526,7 +3286,7 @@ snapshots:
tinyexec: 1.3.0
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
- vite: 8.2.1(@types/node@24.13.3)
+ vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
@@ -1540,6 +3300,8 @@ snapshots:
webidl-conversions@8.0.1: {}
+ webpack-virtual-modules@0.6.2: {}
+
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
@@ -1563,6 +3325,14 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ ws@8.21.3: {}
+
+ wsl-utils@0.1.0:
+ dependencies:
+ is-wsl: 3.1.1
+
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
+
+ yallist@3.1.1: {}
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 76cf3665c..5251e69f8 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -77,23 +77,67 @@
.popup-close {
position: absolute;
- top: 0.75rem;
- right: 0.75rem;
+ top: var(--space-close-inset);
+ right: var(--space-close-inset);
background: none;
border: none;
- font-size: 1.5rem;
+ font-size: var(--font-size-close);
cursor: pointer;
}
+:root {
+ --lw-opacity-meta: 0.7;
+ --lw-font-size-meta: 0.85rem;
+}
+
.post-meta {
- opacity: 0.7;
- font-size: 0.85rem;
+ opacity: var(--lw-opacity-meta);
+ font-size: var(--lw-font-size-meta);
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
}
.post-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--post-body-gap);
+}
+
+.post-body-text {
+ margin: 0;
white-space: pre-wrap;
}
+.post-embedded-image {
+ margin: 0;
+ padding: var(--post-image-padding);
+ border: 1px solid var(--post-image-border);
+ border-radius: var(--post-image-radius);
+ background: var(--post-image-bg);
+}
+
+.post-embedded-image img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+.post-embedded-image figcaption {
+ margin-top: 0.4rem;
+ font-size: 0.85rem;
+ color: var(--text);
+}
+
.popup-placeholder {
margin-top: 1.5rem;
padding: 1rem;
@@ -239,12 +283,46 @@
font-size: 0.85rem;
}
+.keyman-role-title {
+ opacity: 0.6;
+ font-size: 0.8rem;
+ font-style: italic;
+}
+
.verification-badge {
font-size: 0.8rem;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
}
+.actor-type-badge {
+ font-size: 0.7rem;
+ padding: 0.05rem 0.4rem;
+ border-radius: 0.3rem;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.actor-type-prov_person {
+ background: #e8eaf6;
+ color: #303f9f;
+}
+
+.actor-type-prov_organization {
+ background: #fff3e0;
+ color: #9a3412;
+}
+
+.actor-type-prov_team {
+ background: #e0f2f1;
+ color: #00695c;
+}
+
+.rr-affiliation {
+ opacity: 0.7;
+ font-size: 0.9rem;
+}
+
.verification-verify_pending {
background: #e0e0e0;
color: #444;
@@ -416,13 +494,13 @@
}
.citation-chip {
- border: 1px solid #3335;
- border-radius: 999px;
- padding: 0.1rem 0.6rem;
- margin-right: 0.3rem;
+ border: 1px solid var(--color-chip-border);
+ border-radius: var(--radius-chip);
+ padding: var(--space-chip-block) var(--space-chip-inline);
+ margin-right: var(--space-chip-gap);
background: none;
cursor: pointer;
- font-family: monospace;
+ font-family: var(--font-family-chip);
}
.evidence-panel {
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 415e1419f..04d1593de 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -59,6 +59,11 @@ describe("App, authenticated", () => {
chatUnavailable?: boolean;
searchUnavailable?: boolean;
verificationEvidenceUrl?: string | null;
+ failedLineageRun?: boolean;
+ failedReportRun?: boolean;
+ succeededTeppRun?: boolean;
+ pendingTeppRun?: boolean;
+ postBody?: string;
}) {
const statusLabel: Record = {
open: "Open",
@@ -80,6 +85,7 @@ describe("App, authenticated", () => {
let nextTicketId = 1;
const events: { event_id: string; event_type: string; actor_account_id: string; summary: string }[] = [];
let nextEventId = 1;
+ let createdPendingLineage: Record | null = null;
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
@@ -169,6 +175,269 @@ describe("App, authenticated", () => {
jsonResponse({ post_id: "post-1", has_commitment: true, ticket }),
);
}
+ if (url.endsWith("/api/analysis-runs/run-demo-report")) {
+ return Promise.resolve(
+ jsonResponse({
+ analysis_run_id: "run-demo-report",
+ run_kind_code: "analysis_run_report",
+ run_kind_label: "Period report",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: "analysis_status_failed",
+ status_label: "Failed",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:38:00Z",
+ source_counts: [
+ {
+ count_type_code: "analysis_count_document",
+ count_type_label: "Documents",
+ count_value: 3,
+ },
+ ],
+ visible_posts: [],
+ status_history: [
+ {
+ status_ordinal: 1,
+ status_code: "analysis_status_pending",
+ status_label: "Pending",
+ occurred_at: "2026-01-12T12:39:00Z",
+ },
+ {
+ status_ordinal: 2,
+ status_code: "analysis_status_failed",
+ status_label: "Failed",
+ occurred_at: "2026-01-12T12:40:00Z",
+ failure_code: "period_report_rebuild_failed",
+ },
+ ],
+ }),
+ );
+ }
+ if (url.endsWith("/api/analysis-runs/run-demo-tepp")) {
+ const teppStatus = options?.succeededTeppRun
+ ? "analysis_status_succeeded"
+ : options?.pendingTeppRun
+ ? "analysis_status_pending"
+ : "analysis_status_failed";
+ const teppLabel = options?.succeededTeppRun
+ ? "Succeeded"
+ : options?.pendingTeppRun
+ ? "Pending"
+ : "Failed";
+ return Promise.resolve(
+ jsonResponse({
+ analysis_run_id: "run-demo-tepp",
+ run_kind_code: "analysis_run_tepp",
+ run_kind_label: "TEPP measurement",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: teppStatus,
+ status_label: teppLabel,
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:34:00Z",
+ source_counts: [
+ {
+ count_type_code: "analysis_count_document",
+ count_type_label: "Documents",
+ count_value: 3,
+ },
+ ],
+ visible_posts: [{ post_id: "post-1", post_title: "Public post" }],
+ status_history: options?.pendingTeppRun
+ ? [
+ {
+ status_ordinal: 1,
+ status_code: "analysis_status_pending",
+ status_label: "Pending",
+ occurred_at: "2026-01-12T12:35:00Z",
+ },
+ ]
+ : [
+ {
+ status_ordinal: 1,
+ status_code: "analysis_status_pending",
+ status_label: "Pending",
+ occurred_at: "2026-01-12T12:35:00Z",
+ },
+ {
+ status_ordinal: 2,
+ status_code: "analysis_status_running",
+ status_label: "Running",
+ occurred_at: "2026-01-12T12:36:00Z",
+ },
+ {
+ status_ordinal: 3,
+ status_code: options?.succeededTeppRun
+ ? "analysis_status_succeeded"
+ : "analysis_status_failed",
+ status_label: options?.succeededTeppRun ? "Succeeded" : "Failed",
+ occurred_at: "2026-01-12T12:37:00Z",
+ ...(options?.succeededTeppRun
+ ? {}
+ : { failure_code: "tepp_not_available" }),
+ },
+ ],
+ }),
+ );
+ }
+ if (url.endsWith("/api/analysis-runs/run-demo-lineage")) {
+ return Promise.resolve(
+ jsonResponse({
+ analysis_run_id: "run-demo-lineage",
+ run_kind_code: "analysis_run_lineage",
+ run_kind_label: "Lineage reconstruction",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: "analysis_status_succeeded",
+ status_label: "Succeeded",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:30:00Z",
+ source_counts: [
+ {
+ count_type_code: "analysis_count_document",
+ count_type_label: "Documents",
+ count_value: 3,
+ },
+ ],
+ visible_posts: [{ post_id: "post-1", post_title: "Public post" }],
+ code_revision_sha: "abcdef0123456789deadbeefcafebabe",
+ configuration_sha256:
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ status_history: [
+ {
+ status_ordinal: 1,
+ status_code: "analysis_status_pending",
+ status_label: "Pending",
+ occurred_at: "2026-01-12T12:31:00Z",
+ },
+ {
+ status_ordinal: 2,
+ status_code: "analysis_status_running",
+ status_label: "Running",
+ occurred_at: "2026-01-12T12:32:00Z",
+ },
+ {
+ status_ordinal: 3,
+ status_code: "analysis_status_succeeded",
+ status_label: "Succeeded",
+ occurred_at: "2026-01-12T12:33:00Z",
+ },
+ ],
+ }),
+ );
+ }
+ if (url.endsWith("/api/analysis-runs") && method === "POST") {
+ const created = {
+ analysis_run_id: "run-demo-lineage-pending",
+ run_kind_code: "analysis_run_lineage",
+ run_kind_label: "Lineage reconstruction",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: "analysis_status_pending",
+ status_label: "Pending",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:35:00Z",
+ source_counts: [],
+ visible_posts: [{ post_id: "post-1", post_title: "Public post" }],
+ status_history: [
+ {
+ status_ordinal: 1,
+ status_code: "analysis_status_pending",
+ status_label: "Pending",
+ occurred_at: "2026-01-12T12:35:00Z",
+ },
+ ],
+ };
+ createdPendingLineage = created;
+ return Promise.resolve(new Response(JSON.stringify(created), { status: 201 }));
+ }
+ if (url.endsWith("/api/analysis-runs")) {
+ return Promise.resolve(
+ jsonResponse({
+ analysis_runs: [
+ ...(createdPendingLineage ? [createdPendingLineage] : []),
+ {
+ analysis_run_id: "run-demo-lineage",
+ run_kind_code: "analysis_run_lineage",
+ run_kind_label: "Lineage reconstruction",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: options?.failedLineageRun
+ ? "analysis_status_failed"
+ : "analysis_status_succeeded",
+ status_label: options?.failedLineageRun ? "Failed" : "Succeeded",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:30:00Z",
+ source_counts: [
+ {
+ count_type_code: "analysis_count_document",
+ count_type_label: "Documents",
+ count_value: 3,
+ },
+ ],
+ code_revision_sha: "abcdef0123456789deadbeefcafebabe",
+ configuration_sha256:
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ },
+ {
+ analysis_run_id: "run-demo-tepp",
+ run_kind_code: "analysis_run_tepp",
+ run_kind_label: "TEPP measurement",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: options?.succeededTeppRun
+ ? "analysis_status_succeeded"
+ : options?.pendingTeppRun
+ ? "analysis_status_pending"
+ : "analysis_status_failed",
+ status_label: options?.succeededTeppRun
+ ? "Succeeded"
+ : options?.pendingTeppRun
+ ? "Pending"
+ : "Failed",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:34:00Z",
+ source_counts: [
+ {
+ count_type_code: "analysis_count_document",
+ count_type_label: "Documents",
+ count_value: 3,
+ },
+ ],
+ },
+ ...(options?.failedReportRun
+ ? [
+ {
+ analysis_run_id: "run-demo-report",
+ run_kind_code: "analysis_run_report" as const,
+ run_kind_label: "Period report",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: "analysis_status_failed" as const,
+ status_label: "Failed",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:38:00Z",
+ source_counts: [
+ {
+ count_type_code: "analysis_count_document",
+ count_type_label: "Documents",
+ count_value: 3,
+ },
+ ],
+ },
+ ]
+ : []),
+ ],
+ }),
+ );
+ }
if (url.endsWith("/api/calendar")) {
return Promise.resolve(
jsonResponse({
@@ -403,7 +672,7 @@ describe("App, authenticated", () => {
jsonResponse({
post_id: "post-1",
post_title: "Public post",
- post_body: "The full body text.",
+ post_body: options?.postBody ?? "The full body text.",
voc_type_code: "voc",
voc_type_label: "Voice of Customer",
visibility_code: "public",
@@ -453,8 +722,32 @@ describe("App, authenticated", () => {
korean_summary: "이것은 요약입니다.",
key_events: ["첫 번째 이벤트"],
roles_and_responsibilities: [
- { person_name: "Ada West", responsibility: "우리 측 후속" },
- { person_name: "Priya Nair", responsibility: "고객 측 수신" },
+ {
+ actor_name: "Ada West",
+ responsibility: "우리 측 후속",
+ actor_type_code: "prov_person",
+ affiliated_organization_name: "Demo Corp",
+ },
+ {
+ actor_name: "Priya Nair",
+ responsibility: "고객 측 수신",
+ actor_type_code: "prov_person",
+ affiliated_organization_name: "Northridge Grid",
+ },
+ {
+ actor_name: "당사",
+ responsibility: "출하 일정 확정",
+ actor_type_code: "prov_organization",
+ affiliated_organization_name: null,
+ },
+ {
+ actor_name: "설계팀",
+ responsibility: "도면 검토",
+ actor_type_code: "prov_team",
+ affiliated_organization_name: "Demo Corp",
+ catalog_node_id: "team-1",
+ catalog_node_type_code: "node_team",
+ },
],
}),
);
@@ -468,6 +761,7 @@ describe("App, authenticated", () => {
person_name: "Ada West",
person_side_code: "our_side",
person_side_label: "Our side",
+ last_known_job_title: "Account manager",
mention_context: null,
affiliations: [{ organization_name: "Demo Corp", corporate_entity_id: "corp-1", role_title: null }],
},
@@ -514,6 +808,8 @@ describe("App, authenticated", () => {
ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person",
ontology_label: "Person",
label: "Ada West",
+ person_side_code: "our_side",
+ person_side_label: "Our side",
relevance: 0.4,
},
],
@@ -533,6 +829,8 @@ describe("App, authenticated", () => {
ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person",
ontology_label: "Person",
label: "Priya Nair",
+ person_side_code: "counterparty",
+ person_side_label: "Counterparty",
relevance: 0.4,
},
{
@@ -551,6 +849,32 @@ describe("App, authenticated", () => {
label: "Demo Corp",
relevance: 0.2,
},
+ {
+ node_id: "team-1",
+ node_type_code: "node_team",
+ ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Team",
+ ontology_label: "Team",
+ label: "설계팀",
+ relevance: 0.15,
+ },
+ ],
+ }),
+ );
+ }
+ if (url.endsWith("/api/teams/team-1/related")) {
+ return Promise.resolve(
+ jsonResponse({
+ team_id: "team-1",
+ team_name: "설계팀",
+ related: [
+ {
+ node_id: "post-2",
+ node_type_code: "node_post",
+ ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post",
+ ontology_label: "Post",
+ label: "Linked post",
+ relevance: 0.6,
+ },
],
}),
);
@@ -567,6 +891,8 @@ describe("App, authenticated", () => {
ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person",
ontology_label: "Person",
label: "Ada West",
+ person_side_code: "our_side",
+ person_side_label: "Our side",
relevance: 0.5,
},
],
@@ -766,6 +1092,23 @@ describe("App, authenticated", () => {
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});
+ it("shows an embedded invoice image instead of the raw base64 string", async () => {
+ const tinyPng =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
+ stubBackend({
+ postBody: `
Quote attached.
Please confirm.
`,
+ });
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+
+ const image = await screen.findByRole("img", { name: /embedded image at character offset/i });
+ expect(image).toHaveAttribute("src", `data:image/png;base64,${tinyPng}`);
+ expect(screen.getByText("Quote attached.")).toBeInTheDocument();
+ expect(screen.getByText("Please confirm.")).toBeInTheDocument();
+ expect(screen.getByText(/Extract Keyman or ask a question/)).toBeInTheDocument();
+ expect(screen.queryByText(new RegExp(tinyPng))).not.toBeInTheDocument();
+ });
+
it("fetches and renders the post list, then opens a detail popup on click", async () => {
const fetchMock = stubBackend();
@@ -810,6 +1153,8 @@ describe("App, authenticated", () => {
expect(screen.getByText("첫 번째 이벤트")).toBeInTheDocument();
expect(screen.getByText(/우리 측 후속/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "R&R Keyman: Ada West" })).toBeInTheDocument();
+ expect(screen.getByText("당사").closest("li")).toHaveTextContent("Organization");
+ expect(screen.queryByRole("button", { name: "R&R Keyman: 당사" })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByText("간접")).toBeInTheDocument());
expect(screen.getByText("간접").closest("li")).toHaveTextContent("Linked post");
// The popup Event Lineage is the same A-100 reconstruct DAG as the home
@@ -956,6 +1301,7 @@ describe("App, authenticated", () => {
expect(screen.getByRole("button", { name: "Keyman affiliation: Demo Corp" })).toBeInTheDocument();
expect(screen.getByText("(Company)")).toBeInTheDocument();
expect(screen.getAllByText(/Ada West \(Our side\)/).length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText("Account manager")).toBeInTheDocument();
expect(screen.queryByText(/our_side/)).not.toBeInTheDocument();
expect(screen.getByText("unresolved")).toBeInTheDocument();
expect(screen.getByText(/Voice of Customer\s*\(voc\)/)).toBeInTheDocument();
@@ -967,9 +1313,27 @@ describe("App, authenticated", () => {
),
).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" }));
+ expect(
+ screen.getByRole("button", { name: /^Related nodes for Ada West \(Our side\)$/ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /^Related nodes for Ada West$/ }),
+ ).not.toBeInTheDocument();
+ await userEvent.click(
+ screen.getByRole("button", { name: /^Related nodes for Ada West \(Our side\)$/ }),
+ );
await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument());
- expect(screen.getByText("Priya Nair (Person)")).toBeInTheDocument();
+ expect(screen.getByText("Related to Ada West").closest(".related-keymen")).toHaveTextContent(
+ "Priya Nair (Counterparty)",
+ );
+ expect(screen.getByText("Related to Ada West").closest(".related-keymen")).not.toHaveTextContent(
+ "Priya Nair (Person)",
+ );
+ expect(
+ screen.getByRole("button", {
+ name: "Related nodes for Priya Nair (Counterparty)",
+ }),
+ ).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" }));
await waitFor(() =>
expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(),
@@ -982,18 +1346,50 @@ describe("App, authenticated", () => {
await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
await userEvent.click(await screen.findByRole("button", { name: "R&R Keyman: Ada West" }));
await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument());
- expect(screen.getByText("Priya Nair (Person)")).toBeInTheDocument();
+ expect(screen.getByText("Related to Ada West").closest(".related-keymen")).toHaveTextContent(
+ "Priya Nair (Counterparty)",
+ );
+ });
+
+ it("opens related nodes from an R&R team", async () => {
+ stubBackend();
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ await userEvent.click(await screen.findByRole("button", { name: "R&R team: 설계팀" }));
+ await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument());
+ expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent(
+ "Linked post",
+ );
+ });
+
+ it("opens related nodes from a related team chip", async () => {
+ stubBackend();
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ await userEvent.click(
+ screen.getByRole("button", { name: /^Related nodes for Ada West \(Our side\)$/ }),
+ );
+ await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument());
+ await userEvent.click(screen.getByRole("button", { name: "Related nodes for 설계팀" }));
+ await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument());
+ expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent(
+ "Linked post",
+ );
});
it("opens related nodes from a related corporate entity", async () => {
stubBackend();
render();
await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
- await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" }));
+ await userEvent.click(
+ screen.getByRole("button", { name: /^Related nodes for Ada West \(Our side\)$/ }),
+ );
await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: "Related nodes for Demo Corp" }));
await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument());
- expect(screen.getByText("Ada West (Person)")).toBeInTheDocument();
+ expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent(
+ "Ada West (Our side)",
+ );
});
it("shows the VOC excerpt under its counterparty, not a detached list", async () => {
@@ -1022,7 +1418,9 @@ describe("App, authenticated", () => {
await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
await userEvent.click(await screen.findByRole("button", { name: "VOC Keyman: Northridge Grid" }));
await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument());
- expect(screen.getByText("Ada West (Person)")).toBeInTheDocument();
+ expect(screen.getByText("Related to Priya Nair").closest(".related-keymen")).toHaveTextContent(
+ "Ada West (Our side)",
+ );
});
it("opens related Keyman nodes from an affiliate-tree person", async () => {
@@ -1031,7 +1429,9 @@ describe("App, authenticated", () => {
await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
await userEvent.click(await screen.findByRole("button", { name: "Affiliate Keyman: Priya Nair" }));
await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument());
- expect(screen.getByText("Ada West (Person)")).toBeInTheDocument();
+ expect(screen.getByText("Related to Priya Nair").closest(".related-keymen")).toHaveTextContent(
+ "Ada West (Our side)",
+ );
});
it("opens related nodes from a Keyman affiliation organization", async () => {
@@ -1040,7 +1440,9 @@ describe("App, authenticated", () => {
await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
await userEvent.click(await screen.findByRole("button", { name: "Keyman affiliation: Demo Corp" }));
await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument());
- expect(screen.getByText("Ada West (Person)")).toBeInTheDocument();
+ expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent(
+ "Ada West (Our side)",
+ );
});
it("opens related nodes from an affiliate-tree organization", async () => {
@@ -1049,7 +1451,9 @@ describe("App, authenticated", () => {
await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
await userEvent.click(await screen.findByRole("button", { name: "Affiliate org: Demo Corp" }));
await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument());
- expect(screen.getByText("Ada West (Person)")).toBeInTheDocument();
+ expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent(
+ "Ada West (Our side)",
+ );
expect(screen.queryByRole("button", { name: "Affiliate org: Northridge Grid" })).not.toBeInTheDocument();
});
@@ -1059,7 +1463,9 @@ describe("App, authenticated", () => {
await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
await userEvent.click(await screen.findByRole("button", { name: "Counterparty org: Demo Corp" }));
await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument());
- expect(screen.getByText("Ada West (Person)")).toBeInTheDocument();
+ expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent(
+ "Ada West (Our side)",
+ );
expect(screen.queryByRole("button", { name: "Counterparty org: Northridge Grid" })).not.toBeInTheDocument();
});
@@ -1246,6 +1652,190 @@ describe("App, authenticated", () => {
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});
+ it("shows the seeded analysis run on the home page", async () => {
+ stubBackend();
+ render();
+
+ expect(await screen.findByRole("heading", { name: "Analysis runs" })).toBeInTheDocument();
+ const list = screen.getByRole("list", { name: "Analysis runs" });
+ expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp");
+ expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp");
+ expect(list).toHaveTextContent(
+ "Open this run to see why it failed, then connect the measurement service and re-run.",
+ );
+ expect(list).toHaveTextContent("3 documents");
+ expect(list).not.toHaveTextContent("postgresql://");
+ expect(list).not.toHaveTextContent("select ");
+ expect(list).not.toHaveTextContent("Code abcdef012345");
+ expect(list).not.toHaveTextContent("Config 0123456789ab");
+ expect(list).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe");
+ expect(list).not.toHaveTextContent(
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ );
+
+ await userEvent.click(
+ screen.getByRole("button", {
+ name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp",
+ }),
+ );
+ expect(await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" })).toBeInTheDocument();
+ expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument();
+ expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument();
+ const digests = screen.getByLabelText("Analysis run reproducibility digests");
+ expect(digests).toHaveTextContent("Hover a prefix to read the full digest for verification.");
+ expect(digests).toHaveTextContent("Code abcdef012345");
+ expect(digests).toHaveTextContent("Config 0123456789ab");
+ expect(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe");
+ expect(digests).not.toHaveTextContent(
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ );
+ expect(screen.getByTitle("abcdef0123456789deadbeefcafebabe")).toHaveTextContent("Code abcdef012345");
+ expect(
+ screen.getByTitle("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
+ ).toHaveTextContent("Config 0123456789ab");
+ const history = screen.getByRole("list", { name: "Analysis run status history" });
+ expect(history).toHaveTextContent("Pending 2026-01-12 12:31");
+ expect(history).toHaveTextContent("Running 2026-01-12 12:32");
+ expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33");
+ expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Opening a title shows the live post. Compare it with cutoff 2026-01-12 before you treat the body as reconstructed evidence — it may have changed after this run.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", {
+ name: "Open live post (may have changed after cutoff): Public post",
+ }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument();
+
+ await userEvent.click(
+ screen.getByRole("button", {
+ name: "Open live post (may have changed after cutoff): Public post",
+ }),
+ );
+ await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
+
+ await userEvent.click(
+ screen.getByRole("button", {
+ name: "Open analysis run: TEPP measurement · Failed · Demo Corp",
+ }),
+ );
+ expect(
+ await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }),
+ ).toBeInTheDocument();
+ const teppHistory = screen.getByRole("list", { name: "Analysis run status history" });
+ expect(teppHistory).toHaveTextContent("Failed 2026-01-12 12:37 · tepp_not_available");
+ expect(screen.getByText(/cutoff corpus TEPP would measure/i)).toBeInTheDocument();
+ expect(teppHistory).not.toHaveTextContent("Succeeded");
+ });
+
+ it("does not tell a failed lineage run to connect the measurement service", async () => {
+ stubBackend({ failedLineageRun: true });
+ render();
+
+ await screen.findByRole("list", { name: "Analysis runs" });
+ const lineageButton = screen.getByRole("button", {
+ name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp",
+ });
+ const teppButton = screen.getByRole("button", {
+ name: "Open analysis run: TEPP measurement · Failed · Demo Corp",
+ });
+ expect(lineageButton).toHaveTextContent(
+ "Open this run to see why it failed, then retry reconstruction from a current snapshot.",
+ );
+ expect(lineageButton).not.toHaveTextContent("measurement service");
+ expect(teppButton).toHaveTextContent(
+ "Open this run to see why it failed, then connect the measurement service and re-run.",
+ );
+ expect(teppButton).not.toHaveTextContent("reconstruction");
+ });
+
+ it("does not tell a failed period report to connect the measurement service", async () => {
+ stubBackend({ failedReportRun: true });
+ render();
+
+ const reportButton = await screen.findByRole("button", {
+ name: "Open analysis run: Period report · Failed · Demo Corp",
+ });
+ expect(reportButton).toHaveTextContent(
+ "Open this run to see why it failed, then rebuild the period report from a current snapshot.",
+ );
+ expect(reportButton).not.toHaveTextContent("measurement service");
+ expect(reportButton).not.toHaveTextContent("reconstruction");
+
+ await userEvent.click(reportButton);
+ expect(
+ await screen.findByText(
+ "No posts were available at this cutoff for the period report. Open a later run, or ask an administrator to capture a newer snapshot.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("does not tell a pending TEPP run that it already measured", async () => {
+ stubBackend({ pendingTeppRun: true });
+ render();
+
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: "Open analysis run: TEPP measurement · Pending · Demo Corp",
+ }),
+ );
+ expect(
+ await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument();
+ });
+
+ it("does not tell a succeeded TEPP run to replace Failed", async () => {
+ stubBackend({ succeededTeppRun: true });
+ render();
+
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp",
+ }),
+ );
+ expect(
+ await screen.findByText("These posts are the cutoff corpus this TEPP run measured."),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument();
+ });
+
+ it("records a pending lineage run and opens the authorized detail", async () => {
+ const fetchMock = stubBackend();
+ render();
+
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Request a lineage reconstruction" }),
+ );
+ expect(
+ await screen.findByRole("heading", { name: "Lineage reconstruction · Pending · Demo Corp" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", {
+ name: "Open analysis run: Lineage reconstruction · Pending · Demo Corp",
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByText(
+ "Open this run to confirm which posts it will use. Reconstruction has not started yet.",
+ ),
+ ).toHaveLength(2);
+ const postCall = fetchMock.mock.calls.find(
+ (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST",
+ );
+ expect(postCall).toBeDefined();
+ const body = JSON.parse(String(postCall?.[1]?.body));
+ expect(body.run_kind_code).toBe("analysis_run_lineage");
+ expect(body.idempotency_key).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
+ );
+ });
+
it("shows the calibrated period-report mean theta on the home page", async () => {
stubBackend();
render();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 1e39a9253..f80dcf38d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,12 +1,15 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState, type ReactNode } from "react";
import { useAuth } from "react-oidc-context";
import {
askPostChat,
BackendError,
+ createAnalysisRun,
createPostTicket,
deriveCommitment,
evaluatePost,
extractPostKeymen,
+ fetchAnalysisRun,
+ fetchAnalysisRuns,
fetchCalendar,
fetchLineageGraph,
fetchMe,
@@ -27,12 +30,14 @@ import {
fetchPosts,
fetchRelatedEntity,
fetchRelatedKeymen,
+ fetchRelatedTeam,
rebuildLineage,
rebuildPeriodReports,
updateTicketStatus,
verifyPostRelations,
type ActivityEvent,
type AffiliateNode,
+ type AnalysisRun,
type CalendarEntry,
type ChatAnswer,
type ChatExchange,
@@ -50,9 +55,13 @@ import {
type PostLineage,
type PostSummary,
type RelatedNode,
+ type RelatedNodeType,
type VocEvidence,
} from "./api";
+import { CitationChip } from "./components/CitationChip";
+import { PopupCloseButton } from "./components/PopupCloseButton";
import { LineageDag } from "./LineageDag";
+import { PostBody } from "./PostBody";
import { subgraphForPost } from "./lineageLayout";
import "./App.css";
@@ -105,15 +114,13 @@ function EvidencePanel({
return (
@@ -1234,6 +1382,7 @@ function PostDetailPopup({
affiliateTrees={affiliateTrees}
onSelectPerson={(personId, personName) => {
setFocusEntity(null);
+ setFocusTeam(null);
setFocusPerson({ personId, personName });
}}
/>
@@ -1262,10 +1411,12 @@ function PostDetailPopup({
node={node}
onSelectPerson={(personId, personName) => {
setFocusEntity(null);
+ setFocusTeam(null);
setFocusPerson({ personId, personName });
}}
onSelectEntity={(entityId, entityName) => {
setFocusPerson(null);
+ setFocusTeam(null);
setFocusEntity({ entityId, entityName });
}}
/>
@@ -1283,6 +1434,7 @@ function PostDetailPopup({
onSelectPost={onSelectPost}
focusPerson={focusPerson}
focusEntity={focusEntity}
+ focusTeam={focusTeam}
/>
{counterparties && counterparties.length > 0 && (
@@ -1294,6 +1446,7 @@ function PostDetailPopup({
onVerified={reloadCounterparties}
onSelectEntity={(entityId, entityName) => {
setFocusPerson(null);
+ setFocusTeam(null);
setFocusEntity({ entityId, entityName });
}}
/>
@@ -1311,6 +1464,336 @@ function PostDetailPopup({
);
}
+function analysisRunCaption(run: AnalysisRun): string {
+ return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label]
+ .filter(Boolean)
+ .join(" · ");
+}
+
+/**
+ * Next action for a pending or failed run on the home list and detail.
+ *
+ * The machine `failure_code` stays on detail history (ADR 0014). Copy
+ * is pinned to registered kinds so a pending TEPP row is not mistaken
+ * for reconstruction, and a failed lineage row is not mistaken for a
+ * missing TEPP transport.
+ */
+function analysisRunNextAction(run: AnalysisRun): string | null {
+ switch (run.status_code) {
+ case "analysis_status_pending":
+ switch (run.run_kind_code) {
+ case "analysis_run_lineage":
+ return "Open this run to confirm which posts it will use. Reconstruction has not started yet.";
+ case "analysis_run_tepp":
+ return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.";
+ case "analysis_run_report":
+ return "Open this run to confirm which posts the period report will use. The report has not been built yet.";
+ default: {
+ const unexpected: never = run.run_kind_code;
+ return unexpected;
+ }
+ }
+ case "analysis_status_failed":
+ switch (run.run_kind_code) {
+ case "analysis_run_tepp":
+ return "Open this run to see why it failed, then connect the measurement service and re-run.";
+ case "analysis_run_lineage":
+ return "Open this run to see why it failed, then retry reconstruction from a current snapshot.";
+ case "analysis_run_report":
+ return "Open this run to see why it failed, then rebuild the period report from a current snapshot.";
+ default: {
+ const unexpected: never = run.run_kind_code;
+ return unexpected;
+ }
+ }
+ case "analysis_status_running":
+ case "analysis_status_succeeded":
+ case "analysis_status_cancelled":
+ case null:
+ return null;
+ default: {
+ const unexpected: never = run.status_code;
+ return unexpected;
+ }
+ }
+}
+
+/**
+ * Empty-corpus copy that tells the operator what to do next.
+ */
+function analysisRunEmptyPostsHint(run: AnalysisRun): string {
+ switch (run.run_kind_code) {
+ case "analysis_run_tepp":
+ return (
+ "No posts were available at this cutoff for TEPP to measure. " +
+ "Open a later run, or ask an administrator to capture a newer snapshot."
+ );
+ case "analysis_run_lineage":
+ return (
+ "No posts were available at this cutoff for reconstruction. " +
+ "Open a later run, or ask an administrator to capture a newer snapshot."
+ );
+ case "analysis_run_report":
+ return (
+ "No posts were available at this cutoff for the period report. " +
+ "Open a later run, or ask an administrator to capture a newer snapshot."
+ );
+ default: {
+ const unexpected: never = run.run_kind_code;
+ return unexpected;
+ }
+ }
+}
+
+/**
+ * Corpus copy for a TEPP run that already has cutoff posts.
+ *
+ * Those titles are the measurement bag, not a reconstruction result.
+ * Pending or running must not claim a calibrated measurement.
+ */
+function analysisRunCorpusHint(run: AnalysisRun): string | null {
+ if (run.run_kind_code !== "analysis_run_tepp") return null;
+ switch (run.status_code) {
+ case "analysis_status_failed":
+ return (
+ "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " +
+ "transport, then re-run, to replace Failed with a calibrated result."
+ );
+ case "analysis_status_succeeded":
+ return "These posts are the cutoff corpus this TEPP run measured.";
+ case "analysis_status_pending":
+ case "analysis_status_running":
+ return "These posts are the cutoff corpus TEPP will measure once this run finishes.";
+ case "analysis_status_cancelled":
+ return (
+ "These posts are the cutoff corpus this TEPP run would have measured. " +
+ "The run was cancelled before a calibrated result."
+ );
+ case null:
+ return "These posts are the cutoff corpus attached to this TEPP run.";
+ default: {
+ const unexpected: never = run.status_code;
+ return unexpected;
+ }
+ }
+}
+
+/** Git-style prefix. The full digest stays on `title` for verification. */
+const ANALYSIS_RUN_DIGEST_PREFIX_LENGTH = 12;
+
+function analysisRunDigestPrefix(digest: string): string {
+ return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH);
+}
+
+/**
+ * Next action when a cutoff title opens the live post (ADR 0016).
+ *
+ * Post-body versioning is a later slice. Until then the operator must
+ * compare the opened body with this run's cutoff instead of treating
+ * today's text as reconstructed evidence.
+ */
+function analysisRunLivePostWarning(cutoffIso: string): string {
+ const cutoffDate = cutoffIso.slice(0, 10);
+ return (
+ `Opening a title shows the live post. Compare it with cutoff ${cutoffDate} ` +
+ "before you treat the body as reconstructed evidence — it may have changed after this run."
+ );
+}
+
+function analysisRunLivePostButtonLabel(postTitle: string): string {
+ return `Open live post (may have changed after cutoff): ${postTitle}`;
+}
+
+function AnalysisRunReproducibilityDigests({
+ codeRevisionSha,
+ configurationSha256,
+}: {
+ codeRevisionSha?: string;
+ configurationSha256?: string;
+}) {
+ if (!codeRevisionSha && !configurationSha256) {
+ return null;
+ }
+ return (
+
+
+
+ Hover a prefix to read the full digest for verification.{" "}
+
+ {codeRevisionSha ? (
+ {`Code ${analysisRunDigestPrefix(codeRevisionSha)}`}
+ ) : null}
+ {codeRevisionSha && configurationSha256 ? " · " : null}
+ {configurationSha256 ? (
+
+ {`Config ${analysisRunDigestPrefix(configurationSha256)}`}
+
+ ) : null}
+
diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx
new file mode 100644
index 000000000..3ff77b537
--- /dev/null
+++ b/frontend/src/PostBody.tsx
@@ -0,0 +1,33 @@
+import { splitPostBody, type PostBodySegment } from "./postBodyDisplay";
+
+function renderSegment(segment: PostBodySegment, index: number) {
+ switch (segment.kind) {
+ case "text":
+ return (
+
+ {segment.text}
+
+ );
+ case "image":
+ return (
+
+
+
+ Image from this post. Extract Keyman or ask a question to read text
+ inside it.
+
+
+ );
+ default: {
+ const _exhaustive: never = segment;
+ throw new Error(`unexpected post body segment: ${JSON.stringify(_exhaustive)}`);
+ }
+ }
+}
+
+export function PostBody({ body }: { body: string }) {
+ return