From bd4e60c024222e90a152767e04e2530ce1c470e9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:13:45 +0000 Subject: [PATCH 1/6] feat: add granted retention purge and Storybook tokens (v0.87.0) Operators empty a run-bearing analysis-run registry only after an unrevoked analysis_run_retention_grant and analysis_run_retention_admin membership (ADR 0020). PUBLIC cannot execute the definer function. Repeated citation chips and close buttons use named design tokens and a Storybook catalog on Node 24. ADR 0019 stays the R&R catalog-id bind. Do not reuse that number. Co-authored-by: Seongho Bae --- .github/workflows/tests.yml | 4 + AGENTS.md | 6 + ARCHITECTURE.md | 6 + .../0.87.0-retention-purge-grant-admin.md | 13 + CHANGELOG.md | 17 + CLAUDE.md | 12 + README.md | 6 + backend/tests/test_api.py | 2 + docker/postgres-init/Dockerfile | 1 + .../0013-normalized-analysis-run-registry.md | 7 +- docs/adr/0020-analysis-run-retention-purge.md | 101 + .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 21 +- docs/doctoring/DESIGN_TOKEN_REFERENCES.md | 20 + docs/storybook-inventory.md | 21 + frontend/.gitignore | 1 + frontend/.storybook/main.ts | 12 + frontend/.storybook/preview.ts | 11 + frontend/package.json | 8 +- frontend/pnpm-lock.yaml | 2094 +++++++++++++++-- frontend/src/App.css | 16 +- frontend/src/App.tsx | 22 +- .../src/components/CitationChip.stories.tsx | 24 + frontend/src/components/CitationChip.test.tsx | 21 + frontend/src/components/CitationChip.tsx | 27 + .../components/PopupCloseButton.stories.tsx | 23 + .../src/components/PopupCloseButton.test.tsx | 17 + frontend/src/components/PopupCloseButton.tsx | 22 + frontend/src/index.css | 34 +- frontend/src/styles/tokens.css | 32 + frontend/tsconfig.app.json | 3 +- lineageweave/__init__.py | 2 +- .../0020_analysis_run_retention_purge.sql | 180 ++ .../rollback/0018_analysis_run_registry.sql | 6 +- .../0020_analysis_run_retention_purge.sql | 33 + pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_analysis_run_authorization.py | 2 + tests/test_analysis_run_registry_schema.py | 424 ++++ uv.lock | 2 +- 39 files changed, 3043 insertions(+), 213 deletions(-) create mode 100644 CHANGELOG.d/0.87.0-retention-purge-grant-admin.md create mode 100644 docs/adr/0020-analysis-run-retention-purge.md create mode 100644 docs/doctoring/DESIGN_TOKEN_REFERENCES.md create mode 100644 docs/storybook-inventory.md create mode 100644 frontend/.storybook/main.ts create mode 100644 frontend/.storybook/preview.ts create mode 100644 frontend/src/components/CitationChip.stories.tsx create mode 100644 frontend/src/components/CitationChip.test.tsx create mode 100644 frontend/src/components/CitationChip.tsx create mode 100644 frontend/src/components/PopupCloseButton.stories.tsx create mode 100644 frontend/src/components/PopupCloseButton.test.tsx create mode 100644 frontend/src/components/PopupCloseButton.tsx create mode 100644 frontend/src/styles/tokens.css create mode 100644 migrations/0020_analysis_run_retention_purge.sql create mode 100644 migrations/rollback/0020_analysis_run_retention_purge.sql diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e78d36254..1cad1f17c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -90,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 dba1c4b42..47a71c8c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,14 @@ 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`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a4d0ac4a..bf19c5f77 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -499,6 +499,12 @@ 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) 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.md b/CHANGELOG.md index d30ae14aa..00a19fe92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.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 diff --git a/CLAUDE.md b/CLAUDE.md index c5a1828f2..870c77f87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,18 @@ 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')`, +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 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/tests/test_api.py b/backend/tests/test_api.py index 21c71bc9a..3b74c22a3 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -32,6 +32,7 @@ _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: @@ -115,6 +116,7 @@ def seeded_db(demo_analyst_token): 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'), " diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index e394d376e..ce2f0e6b5 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -25,6 +25,7 @@ COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/1 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/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index b60164dc6..d13994bc5 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -209,9 +209,10 @@ functional dependency and forces duplicate snapshots. Migration replay is idempotent and rejects lookup-category collisions. The rollback refuses to remove non-empty registry relations. Evidence must first be -exported or explicitly deleted under an approved retention procedure. An empty -rollback removes the view, tables, functions, and lookup rows and is itself -replayable. +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 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..928ba87c6 --- /dev/null +++ b/docs/adr/0020-analysis-run-retention-purge.md @@ -0,0 +1,101 @@ +# 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, deletes in FK + order, re-enables the triggers, and writes one + `analysis_run_retention_event`; +- `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 index b41b31c17..c776053b1 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -1,7 +1,7 @@ # Analysis-run registry standards and research traceability **Status:** Active PR evidence; not protected-main truth until merge. -**Scope:** Migration 0018, ADR 0013, rollback, and real-PostgreSQL contract tests. +**Scope:** Migrations 0018 and 0020, ADR 0013 / 0020, rollback, and real-PostgreSQL contract tests. ## Standards mapped to implementation @@ -12,7 +12,8 @@ | W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | -| NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, and exclusion of raw source/provider payloads. | +| 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 @@ -75,10 +76,17 @@ provenance, retention, and immutable evidence rather than blanket masking. | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | | Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. | | Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. | -| Rollback does not erase audit data silently | Reject rollback with any registry rows and allow replay after explicit cleanup. | +| 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). @@ -87,6 +95,10 @@ 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/ @@ -96,6 +108,9 @@ 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/ 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/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/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 fb52f7948..0d43d9fa2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,14 +1,16 @@ { "name": "frontend", "private": true, - "version": "0.86.2", + "version": "0.87.0", "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 b3fab25d1..5251e69f8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -77,11 +77,11 @@ .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; } @@ -494,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.tsx b/frontend/src/App.tsx index d589a3644..07088e9d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,6 +58,8 @@ import { 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"; @@ -112,9 +114,7 @@ function EvidencePanel({ return (
- +

Evidence

{!post &&

Loading source post...

} {post && ( @@ -143,14 +143,12 @@ function ChatCitations({
Sources: {chips.map((cited) => ( - + postId={cited.post_id} + postTitle={cited.post_title} + onOpenEvidence={onOpenEvidence} + /> ))}
); @@ -1233,9 +1231,7 @@ function PostDetailPopup({ return (
event.stopPropagation()}> - + {error &&

{error}

} {!post && !error &&

Loading...

} {post && ( diff --git a/frontend/src/components/CitationChip.stories.tsx b/frontend/src/components/CitationChip.stories.tsx new file mode 100644 index 000000000..2cce5cc22 --- /dev/null +++ b/frontend/src/components/CitationChip.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CitationChip } from "./CitationChip"; + +const meta = { + title: "Evidence/CitationChip", + component: CitationChip, + args: { + postId: "post-demo-public", + postTitle: "Demo public post", + onOpenEvidence: () => undefined, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const LongTitle: Story = { + args: { + postTitle: "Demo Corp January cutoff reconstruction notes", + }, +}; diff --git a/frontend/src/components/CitationChip.test.tsx b/frontend/src/components/CitationChip.test.tsx new file mode 100644 index 000000000..ff7b948a0 --- /dev/null +++ b/frontend/src/components/CitationChip.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { CitationChip } from "./CitationChip"; + +describe("CitationChip", () => { + it("opens the cited post when the buyer clicks the chip", async () => { + const onOpenEvidence = vi.fn(); + render( + , + ); + await userEvent.click( + screen.getByRole("button", { name: "Open evidence: Demo public post" }), + ); + expect(onOpenEvidence).toHaveBeenCalledWith("post-demo-public"); + }); +}); diff --git a/frontend/src/components/CitationChip.tsx b/frontend/src/components/CitationChip.tsx new file mode 100644 index 000000000..8ac3f4908 --- /dev/null +++ b/frontend/src/components/CitationChip.tsx @@ -0,0 +1,27 @@ +export type CitationChipProps = { + postId: string; + postTitle: string; + onOpenEvidence: (postId: string) => void; +}; + +/** + * Opens the cited source post from a reconstruction caption. + * + * Next action: click the chip to read the evidence that grounded the claim. + */ +export function CitationChip({ + postId, + postTitle, + onOpenEvidence, +}: CitationChipProps) { + return ( + + ); +} diff --git a/frontend/src/components/PopupCloseButton.stories.tsx b/frontend/src/components/PopupCloseButton.stories.tsx new file mode 100644 index 000000000..c697987a4 --- /dev/null +++ b/frontend/src/components/PopupCloseButton.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PopupCloseButton } from "./PopupCloseButton"; + +const meta = { + title: "Chrome/PopupCloseButton", + component: PopupCloseButton, + args: { + label: "Close evidence panel", + onClose: () => undefined, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const EvidencePanel: Story = {}; + +export const PostPopup: Story = { + args: { + label: "Close", + }, +}; diff --git a/frontend/src/components/PopupCloseButton.test.tsx b/frontend/src/components/PopupCloseButton.test.tsx new file mode 100644 index 000000000..b72e0b91d --- /dev/null +++ b/frontend/src/components/PopupCloseButton.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { PopupCloseButton } from "./PopupCloseButton"; + +describe("PopupCloseButton", () => { + it("closes the evidence panel when the buyer clicks close", async () => { + const onClose = vi.fn(); + render( + , + ); + await userEvent.click( + screen.getByRole("button", { name: "Close evidence panel" }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/PopupCloseButton.tsx b/frontend/src/components/PopupCloseButton.tsx new file mode 100644 index 000000000..129b07297 --- /dev/null +++ b/frontend/src/components/PopupCloseButton.tsx @@ -0,0 +1,22 @@ +export type PopupCloseButtonProps = { + onClose: () => void; + label: string; +}; + +/** + * Closes the evidence panel or post popup. + * + * Next action: click to return to the list or the reconstruction view. + */ +export function PopupCloseButton({ onClose, label }: PopupCloseButtonProps) { + return ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 53f4db2ac..011ad42fd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,12 +1,14 @@ +@import "./styles/tokens.css"; + :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); + --text: var(--color-text); + --text-h: var(--color-text-heading); + --bg: var(--color-background); + --border: var(--color-border); + --code-bg: var(--color-code-background); + --accent: var(--color-accent); + --accent-bg: var(--color-accent-background); + --accent-border: var(--color-accent-border); --social-bg: rgba(244, 243, 236, 0.5); --post-body-gap: 0.75rem; --post-image-padding: 0.75rem; @@ -37,14 +39,14 @@ @media (prefers-color-scheme: dark) { :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); + --text: var(--color-text); + --text-h: var(--color-text-heading); + --bg: var(--color-background); + --border: var(--color-border); + --code-bg: var(--color-code-background); + --accent: var(--color-accent); + --accent-bg: var(--color-accent-background); + --accent-border: var(--color-accent-border); --social-bg: rgba(47, 48, 58, 0.5); --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 000000000..e3510b83c --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,32 @@ +:root { + --color-text: #6b6375; + --color-text-heading: #08060d; + --color-background: #fff; + --color-border: #e5e4e7; + --color-code-background: #f4f3ec; + --color-accent: #aa3bff; + --color-accent-background: rgba(170, 59, 255, 0.1); + --color-accent-border: rgba(170, 59, 255, 0.5); + --color-chip-border: #3335; + --space-chip-inline: 0.6rem; + --space-chip-block: 0.1rem; + --space-chip-gap: 0.3rem; + --space-close-inset: 0.75rem; + --radius-chip: 999px; + --font-size-close: 1.5rem; + --font-family-chip: ui-monospace, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-text: #9ca3af; + --color-text-heading: #f3f4f6; + --color-background: #16171d; + --color-border: #2e303a; + --color-code-background: #1f2028; + --color-accent: #c084fc; + --color-accent-background: rgba(192, 132, 252, 0.15); + --color-accent-border: rgba(192, 132, 252, 0.5); + --color-chip-border: #9ca3af; + } +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 6830b6f75..d054398da 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -22,5 +22,6 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.stories.tsx"] } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index efe84890b..1950c39f8 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.2" +__version__ = "0.87.0" diff --git a/migrations/0020_analysis_run_retention_purge.sql b/migrations/0020_analysis_run_retention_purge.sql new file mode 100644 index 000000000..056d80216 --- /dev/null +++ b/migrations/0020_analysis_run_retention_purge.sql @@ -0,0 +1,180 @@ +-- Privileged retention purge for the Milestone 2 analysis-run registry. +-- +-- Migration 0018 makes analysis_run / scope / status immutable, so a +-- documented "export or delete under an approved retention procedure" +-- cannot empty a run-bearing registry. This slice adds that procedure: +-- an audited SECURITY DEFINER purge that disables the immutability +-- triggers only inside the approved call, then records one retention +-- event. +-- +-- Fail-closed authorization is conjunctive (ADR 0020): +-- 1. session_user holds an unrevoked analysis_run_retention_grant; +-- 2. session_user is a member of analysis_run_retention_admin; +-- 3. the documented approval phrase is supplied. +-- PUBLIC cannot execute the function. The phrase is a procedure name, +-- not an authorization secret. A session SET cannot authorize a raw +-- DELETE. Do not grant the admin role to the application DATABASE_URL +-- login, and do not insert a grant for that login. +-- +-- ADR 0019 / migration 0019 belong to the R&R catalog-id bind +-- (cataloged_team_id / cataloged_corporate_entity_id). Do not reuse +-- that number for this purge. + +begin; + +do $$ +begin + if not exists ( + select 1 from pg_roles where rolname = 'analysis_run_retention_admin' + ) then + create role analysis_run_retention_admin nologin nosuperuser inherit; + end if; +end +$$; + +comment on role analysis_run_retention_admin is + 'Least-privilege role that may call purge_analysis_run_registry. ' + 'Grant this role to an operator session, then insert an unrevoked ' + 'analysis_run_retention_grant for session_user. Do not grant it to ' + 'the application DATABASE_URL role.'; + +create table if not exists analysis_run_retention_grant ( + analysis_run_retention_grant_id uuid primary key default gen_random_uuid(), + database_role_name text not null + check (char_length(database_role_name) >= 1), + granted_at timestamptz not null default clock_timestamp(), + revoked_at timestamptz, + check (revoked_at is null or revoked_at >= granted_at) +); + +comment on table analysis_run_retention_grant is + 'Unrevoked row authorizes session_user to call ' + 'purge_analysis_run_registry. Insert one grant for the operator ' + 'role and grant analysis_run_retention_admin before the first purge.'; + +comment on column analysis_run_retention_grant.database_role_name is + 'PostgreSQL session_user that may purge; not an application account.'; + +create unique index if not exists analysis_run_retention_grant_active + on analysis_run_retention_grant (database_role_name) + where revoked_at is null; + +create table if not exists analysis_run_retention_event ( + analysis_run_retention_event_id uuid primary key default gen_random_uuid(), + approved_at timestamptz not null default clock_timestamp(), + purged_run_count bigint not null check (purged_run_count >= 0), + purged_snapshot_count bigint not null check (purged_snapshot_count >= 0), + approval_token_digest text not null + check (approval_token_digest ~ '^[0-9a-f]{64}$'), + invoking_session_role name not null, + invoking_current_role name not null, + client_network_address inet +); + +comment on table analysis_run_retention_event is + 'One audit row per approved registry purge; export then delete before ' + 'rolling back migration 0020.'; + +comment on column analysis_run_retention_event.approval_token_digest is + 'SHA-256 hex of the approval token; the raw phrase is never stored.'; + +comment on column analysis_run_retention_event.invoking_session_role is + 'session_user at purge time: the login role that held the grant.'; + +comment on column analysis_run_retention_event.invoking_current_role is + 'current_user at purge time: the SECURITY DEFINER owner while the ' + 'function runs.'; + +comment on column analysis_run_retention_event.client_network_address is + 'inet_client_addr() when the caller is remote; NULL for local sockets.'; + +create or replace function purge_analysis_run_registry(approval_token text) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + run_count bigint; + snapshot_count bigint; +begin + if not exists ( + select 1 + from analysis_run_retention_grant + where database_role_name = session_user + and revoked_at is null + ) then + raise exception 'analysis_run_retention_not_granted'; + end if; + + if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then + raise exception 'analysis_run_retention_not_admin'; + end if; + + if approval_token is distinct from 'approved-retention-purge' then + raise exception 'analysis_run_retention_not_approved'; + end if; + + select count(*) into run_count from analysis_run; + select count(*) into snapshot_count from analysis_source_snapshot; + + alter table analysis_run_status_event + disable trigger analysis_run_status_event_delete_reject; + alter table analysis_run_scope + disable trigger analysis_run_scope_mutation_reject; + alter table analysis_run + disable trigger analysis_run_mutation_reject; + + begin + delete from analysis_run_status_event; + delete from analysis_run_scope; + delete from analysis_run; + delete from analysis_source_count; + delete from analysis_source_snapshot; + exception + when others then + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + raise; + end; + + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + + insert into analysis_run_retention_event ( + purged_run_count, + purged_snapshot_count, + approval_token_digest, + invoking_session_role, + invoking_current_role, + client_network_address + ) values ( + run_count, + snapshot_count, + encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'), + session_user, + current_user, + inet_client_addr() + ); +end +$$; + +comment on function purge_analysis_run_registry(text) is + 'Empties immutable registry relations after an unrevoked role grant, ' + 'analysis_run_retention_admin membership, and the documented approval ' + 'token; records one analysis_run_retention_event. Next action: export ' + 'that event, delete it, then roll back 0020 and 0018.'; + +revoke all on function purge_analysis_run_registry(text) from public; +grant execute on function purge_analysis_run_registry(text) + to analysis_run_retention_admin; + +commit; diff --git a/migrations/rollback/0018_analysis_run_registry.sql b/migrations/rollback/0018_analysis_run_registry.sql index f91abc47c..ff35011b2 100644 --- a/migrations/rollback/0018_analysis_run_registry.sql +++ b/migrations/rollback/0018_analysis_run_registry.sql @@ -1,7 +1,9 @@ -- Fail-closed rollback for migration 0018. -- --- Registry evidence must be exported or explicitly deleted under an approved --- retention procedure before these objects can be removed. Re-running this +-- Registry evidence must be exported, then emptied with +-- select purge_analysis_run_registry('approved-retention-purge') +-- (migration 0020 / ADR 0020), before these objects can be removed. A raw +-- DELETE of analysis_run / scope / status is rejected. Re-running this -- rollback after a successful empty rollback is safe. begin; diff --git a/migrations/rollback/0020_analysis_run_retention_purge.sql b/migrations/rollback/0020_analysis_run_retention_purge.sql new file mode 100644 index 000000000..fd89611f1 --- /dev/null +++ b/migrations/rollback/0020_analysis_run_retention_purge.sql @@ -0,0 +1,33 @@ +-- Fail-closed rollback for migration 0020. +-- +-- Export analysis_run_retention_event, then delete those rows, before +-- this script can drop the purge function, grant table, and audit +-- table. Grant rows are authorization config and drop with the table. +-- Re-running after a successful empty rollback is safe. + +begin; + +do $$ +declare + relation_has_rows boolean; +begin + if to_regclass('public.analysis_run_retention_event') is not null then + execute 'select exists (select 1 from analysis_run_retention_event)' + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_run_retention_event_not_empty'; + end if; + end if; +end +$$; + +drop function if exists purge_analysis_run_registry(text); +drop table if exists analysis_run_retention_grant; +drop table if exists analysis_run_retention_event; + +-- analysis_run_retention_admin is cluster-scoped. Leave it in place so a +-- parallel database that still has 0020 applied does not lose the role. +-- Revoke leftover memberships before dropping the role in a dedicated +-- cluster teardown. + +commit; diff --git a/pyproject.toml b/pyproject.toml index 6e41c7ca7..ecfe24877 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.2" +version = "0.87.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 9d246445d..2f3c66c45 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -121,6 +121,7 @@ def seed( cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text()) cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) cur.execute((migrations / "0019_role_catalog_identity.sql").read_text()) + cur.execute((migrations / "0020_analysis_run_retention_purge.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py index 730825c14..64a0504f7 100644 --- a/tests/test_analysis_run_authorization.py +++ b/tests/test_analysis_run_authorization.py @@ -14,6 +14,7 @@ _ROOT = Path(__file__).resolve().parents[1] _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" +_RETENTION_MIGRATION = _ROOT / "migrations" / "0020_analysis_run_retention_purge.sql" _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" ) @@ -53,6 +54,7 @@ def authz_db(): with connection.cursor() as cursor: cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8")) yield connection finally: connection.close() diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index f2b38badf..3d185dbed 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import os import re import uuid @@ -17,6 +18,10 @@ _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" _REGISTRY_ROLLBACK = _ROOT / "migrations" / "rollback" / "0018_analysis_run_registry.sql" +_RETENTION_MIGRATION = _ROOT / "migrations" / "0020_analysis_run_retention_purge.sql" +_RETENTION_ROLLBACK = ( + _ROOT / "migrations" / "rollback" / "0020_analysis_run_retention_purge.sql" +) _POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile" _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" @@ -98,6 +103,7 @@ def registry_db(): with connection.cursor() as cursor: cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8")) yield connection finally: connection.close() @@ -183,6 +189,74 @@ def _insert_run( return str(cursor.fetchone()[0]) +def _insert_run_bearing_registry( + cursor, + *, + digest: str, + idempotency_key: str, +) -> None: + """Insert one snapshot, count, run, scope, and pending event.""" + + account_id = _insert_account(cursor) + snapshot_id = _insert_snapshot(cursor, digest=digest) + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_document', 3)", + (snapshot_id,), + ) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key=idempotency_key, + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + + +def _authorize_session_for_purge(cursor) -> str: + """Grant the current session_user both retention locks and return it.""" + + cursor.execute("select session_user") + session_role = cursor.fetchone()[0] + cursor.execute( + "insert into analysis_run_retention_grant (database_role_name) " + "select %s " + "where not exists (" + " select 1 from analysis_run_retention_grant " + " where database_role_name = %s and revoked_at is null" + ")", + (session_role, session_role), + ) + cursor.execute( + sql.SQL("grant analysis_run_retention_admin to {}").format( + sql.Identifier(session_role) + ) + ) + return session_role + + +def _drop_role_if_exists(cursor, role_name: str) -> None: + """Drop a test role after releasing objects it owns.""" + + cursor.execute("select 1 from pg_roles where rolname = %s", (role_name,)) + if cursor.fetchone() is None: + return + cursor.execute(sql.SQL("drop owned by {}").format(sql.Identifier(role_name))) + cursor.execute(sql.SQL("drop role {}").format(sql.Identifier(role_name))) + + def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> None: """Static contract rejects the parallel prototype and duplicated clocks.""" @@ -200,7 +274,38 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non re.findall(r"'(analysis_[a-z0-9_]+)'", migration) ) assert "0018_analysis_run_registry.sql" in dockerfile + assert "0019_role_catalog_identity.sql" in dockerfile + assert "0020_analysis_run_retention_purge.sql" in dockerfile + seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8") + assert seed.index("0019_role_catalog_identity.sql") < seed.index( + "0020_analysis_run_retention_purge.sql" + ) assert "analysis_run_registry_not_empty" in rollback + retention = _RETENTION_MIGRATION.read_text(encoding="utf-8") + retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8") + assert "purge_analysis_run_registry" in retention + assert "analysis_run_retention_event" in retention + assert "analysis_run_retention_grant" in retention + assert "analysis_run_retention_admin" in retention + assert "invoking_session_role" in retention + assert "invoking_current_role" in retention + assert "security definer" in retention.casefold() + assert "revoke all" in retention.casefold() + assert "from public" in retention.casefold() + assert "analysis_run_retention_not_approved" in retention + assert "analysis_run_retention_not_granted" in retention + assert "analysis_run_retention_not_admin" in retention + assert "analysis_run_retention_event_not_empty" in retention_rollback + assert "jsonb" not in retention.casefold() + for object_name in re.findall( + r"create table if not exists\s+([a-z0-9_]+)" + r"|create or replace function\s+([a-z0-9_]+)" + r"|create role\s+([a-z0-9_]+)", + retention, + re.I, + ): + name = object_name[0] or object_name[1] or object_name[2] + assert len(name.split("_")) >= 2, name snapshot_definition = _table_definition(migration, "analysis_source_snapshot") run_definition = _table_definition(migration, "analysis_run") @@ -235,6 +340,7 @@ def test_registry_migration_is_idempotent(registry_db) -> None: with registry_db.cursor() as cursor: cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8")) cursor.execute( "select table_name from information_schema.tables " "where table_schema = 'public'" @@ -246,6 +352,8 @@ def test_registry_migration_is_idempotent(registry_db) -> None: ) views = {row[0] for row in cursor.fetchall()} assert _REQUIRED_TABLES <= tables + assert "analysis_run_retention_event" in tables + assert "analysis_run_retention_grant" in tables assert "analysis_run_current_status" in views @@ -714,3 +822,319 @@ def test_rollback_refuses_data_loss_then_removes_an_empty_registry(registry_db) cursor.execute("select to_regclass('public.analysis_run')") assert cursor.fetchone()[0] is None cursor.execute(rollback_sql) + + +def test_approved_retention_purge_empties_a_run_bearing_registry(registry_db) -> None: + """Grant plus admin empties expired registry rows; a raw DELETE still fails.""" + + rollback_sql = _REGISTRY_ROLLBACK.read_text(encoding="utf-8") + retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8") + with registry_db.cursor() as cursor: + _insert_run_bearing_registry( + cursor, + digest="a" * 64, + idempotency_key="retention-purge", + ) + cursor.execute("select analysis_run_id from analysis_run") + run_id = cursor.fetchone()[0] + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_request_is_immutable", + ): + cursor.execute( + "delete from analysis_run where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_registry_not_empty", + ): + cursor.execute(rollback_sql) + cursor.execute("rollback") + session_role = _authorize_session_for_purge(cursor) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_not_approved", + ): + cursor.execute("select purge_analysis_run_registry(%s)", ("wrong-token",)) + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("select count(*) from analysis_run") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_source_snapshot") + assert cursor.fetchone()[0] == 0 + cursor.execute( + "select purged_run_count, purged_snapshot_count, " + "approval_token_digest, invoking_session_role, " + "invoking_current_role from analysis_run_retention_event" + ) + ( + purged_run_count, + purged_snapshot_count, + token_digest, + invoking_session_role, + invoking_current_role, + ) = cursor.fetchone() + assert purged_run_count == 1 + assert purged_snapshot_count == 1 + assert token_digest == hashlib.sha256( + b"approved-retention-purge" + ).hexdigest() + assert invoking_session_role == session_role + assert invoking_current_role + cursor.execute(rollback_sql) + cursor.execute("select to_regclass('public.analysis_run')") + assert cursor.fetchone()[0] is None + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_event_not_empty", + ): + cursor.execute(retention_rollback) + cursor.execute("rollback") + cursor.execute("delete from analysis_run_retention_event") + cursor.execute(retention_rollback) + cursor.execute( + "select to_regclass('public.analysis_run_retention_event')" + ) + assert cursor.fetchone()[0] is None + + +def test_retention_purge_requires_unrevoked_session_grant(registry_db) -> None: + """Admin membership plus the published token cannot purge without a grant.""" + + role_name = f"retention_denied_{uuid.uuid4().hex[:8]}" + with registry_db.cursor() as cursor: + cursor.execute( + "select has_function_privilege(%s, %s, 'execute')", + ("public", "purge_analysis_run_registry(text)"), + ) + assert cursor.fetchone()[0] is False + _insert_run_bearing_registry( + cursor, + digest="d" * 64, + idempotency_key="retention-grant-deny", + ) + cursor.execute( + sql.SQL("create role {} nologin nosuperuser inherit").format( + sql.Identifier(role_name) + ) + ) + cursor.execute( + sql.SQL("grant analysis_run_retention_admin to {}").format( + sql.Identifier(role_name) + ) + ) + try: + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(role_name) + ) + ) + except psycopg2.errors.InsufficientPrivilege: + cursor.execute("reset session authorization") + _drop_role_if_exists(cursor, role_name) + pytest.skip("session authorization requires superuser") + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_not_granted", + ): + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + cursor.execute( + "insert into analysis_run_retention_grant (database_role_name) " + "values (%s)", + (role_name,), + ) + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(role_name) + ) + ) + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + cursor.execute( + "select invoking_session_role from analysis_run_retention_event" + ) + assert cursor.fetchone()[0] == role_name + cursor.execute( + "update analysis_run_retention_grant " + "set revoked_at = clock_timestamp() " + "where database_role_name = %s and revoked_at is null", + (role_name,), + ) + _insert_run_bearing_registry( + cursor, + digest="e" * 64, + idempotency_key="retention-grant-revoked", + ) + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(role_name) + ) + ) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_not_granted", + ): + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + _drop_role_if_exists(cursor, role_name) + + +def test_runtime_role_cannot_purge_with_only_the_public_token(registry_db) -> None: + """Table DML plus the documented phrase is not a retention grant.""" + + runtime_role = f"analysis_run_app_{uuid.uuid4().hex[:12]}" + operator_role = f"analysis_run_operator_{uuid.uuid4().hex[:12]}" + try: + with registry_db.cursor() as cursor: + _insert_run_bearing_registry( + cursor, + digest="f" * 64, + idempotency_key="runtime-denied-purge", + ) + cursor.execute("select analysis_run_id from analysis_run") + run_id = cursor.fetchone()[0] + cursor.execute( + sql.SQL( + "create role {} nologin nosuperuser inherit" + ).format(sql.Identifier(runtime_role)) + ) + cursor.execute( + sql.SQL( + "create role {} nologin nosuperuser inherit" + ).format(sql.Identifier(operator_role)) + ) + cursor.execute( + sql.SQL("grant usage on schema public to {}, {}").format( + sql.Identifier(runtime_role), + sql.Identifier(operator_role), + ) + ) + cursor.execute( + sql.SQL( + "grant select, insert, update, delete on " + "analysis_run, analysis_run_scope, " + "analysis_run_status_event, analysis_source_snapshot, " + "analysis_source_count to {}" + ).format(sql.Identifier(runtime_role)) + ) + cursor.execute( + sql.SQL("grant analysis_run_retention_admin to {}").format( + sql.Identifier(operator_role) + ) + ) + cursor.execute( + "insert into analysis_run_retention_grant (database_role_name) " + "values (%s)", + (operator_role,), + ) + cursor.execute( + sql.SQL("set role {}").format(sql.Identifier(runtime_role)) + ) + with pytest.raises(psycopg2.errors.InsufficientPrivilege): + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_request_is_immutable", + ): + cursor.execute( + "delete from analysis_run where analysis_run_id = %s", + (run_id,), + ) + cursor.execute("reset role") + try: + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(operator_role) + ) + ) + except psycopg2.errors.InsufficientPrivilege: + cursor.execute("reset session authorization") + pytest.skip("session authorization requires superuser") + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + cursor.execute("select count(*) from analysis_run") + assert cursor.fetchone()[0] == 0 + cursor.execute( + "select invoking_session_role, invoking_current_role " + "from analysis_run_retention_event" + ) + invoking_session_role, invoking_current_role = cursor.fetchone() + assert invoking_session_role + assert invoking_current_role + finally: + with registry_db.cursor() as cursor: + cursor.execute("reset role") + cursor.execute("reset session authorization") + for role_name in (runtime_role, operator_role): + _drop_role_if_exists(cursor, role_name) + + +def test_retention_purge_requires_admin_membership_even_with_a_grant( + registry_db, +) -> None: + """A grant without analysis_run_retention_admin cannot empty the registry.""" + + role_name = f"retention_grant_only_{uuid.uuid4().hex[:8]}" + with registry_db.cursor() as cursor: + _insert_run_bearing_registry( + cursor, + digest="c" * 64, + idempotency_key="retention-admin-deny", + ) + cursor.execute( + sql.SQL("create role {} nologin nosuperuser inherit").format( + sql.Identifier(role_name) + ) + ) + cursor.execute( + sql.SQL( + "grant execute on function purge_analysis_run_registry(text) " + "to {}" + ).format(sql.Identifier(role_name)) + ) + cursor.execute( + "insert into analysis_run_retention_grant (database_role_name) " + "values (%s)", + (role_name,), + ) + try: + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(role_name) + ) + ) + except psycopg2.errors.InsufficientPrivilege: + cursor.execute("reset session authorization") + _drop_role_if_exists(cursor, role_name) + pytest.skip("session authorization requires superuser") + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_not_admin", + ): + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + cursor.execute("select count(*) from analysis_run") + assert cursor.fetchone()[0] == 1 + _drop_role_if_exists(cursor, role_name) diff --git a/uv.lock b/uv.lock index e1d2860cf..6915a3531 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.2" +version = "0.87.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 77d8e1dba69e09e78e6f9af79518bfc4613ae560 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:17:54 +0000 Subject: [PATCH 2/6] test: list a created pending lineage run in the home stub After POST /api/analysis-runs the list refetch must include the new Pending row so the buyer-facing "has not started yet" next action is visible. The previous stub kept only the seed rows. Co-authored-by: Seongho Bae --- frontend/src/App.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 60d06c8de..07b0a7e8d 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -85,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); @@ -351,12 +352,14 @@ describe("App, authenticated", () => { }, ], }; + 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", From 7e651eb1a31288df3ee2665a91501a83c2d5db6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:30:20 +0000 Subject: [PATCH 3/6] test: expect pending lineage copy on list and detail After #148 the next-action phrase is pinned to registered kinds and shown on both the created list row and the selected detail. Assert both copies so getByText does not fail on the duplicate. Co-authored-by: Seongho Bae --- frontend/src/App.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 07b0a7e8d..fd8a15146 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1804,10 +1804,15 @@ describe("App, authenticated", () => { await screen.findByRole("heading", { name: "Lineage reconstruction · Pending · Demo Corp" }), ).toBeInTheDocument(); expect( - screen.getByText( + 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.", ), - ).toBeInTheDocument(); + ).toHaveLength(2); const postCall = fetchMock.mock.calls.find( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", ); From 450c9e9ac56fc0ea1a320fc09240319f6b6bb90b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:52:03 +0000 Subject: [PATCH 4/6] feat: start a pending lineage reconstruction (v0.88.0) Operators can open a Pending Demo Corp row and start ThreadWeave on the frozen cutoff bag (ADR 0021). TEPP start stays 422 so this path cannot invent a theta. A granted purge now also deletes reconstruction and snapshot-member rows. Confirm the designed A-100 fork, then hover the Result digest prefix. Does not steal ADR 0019 or 0020. Prefer this head over #142 and #152. Co-authored-by: Seongho Bae --- AGENTS.md | 4 +- ARCHITECTURE.md | 10 +- CHANGELOG.d/0.88.0-analysis-run-start.md | 7 + CHANGELOG.md | 17 + CLAUDE.md | 7 +- README.md | 2 + backend/app/analysis_run_ingestion.py | 120 ++++++- backend/app/analysis_run_start.py | 309 ++++++++++++++++++ backend/app/main.py | 34 +- backend/tests/test_api.py | 103 ++++++ docker/postgres-init/Dockerfile | 2 + .../0013-normalized-analysis-run-registry.md | 7 +- .../0017-authorized-analysis-run-create.md | 3 +- .../adr/0021-authorized-analysis-run-start.md | 113 +++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 9 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 86 ++++- frontend/src/App.tsx | 68 +++- frontend/src/api.ts | 19 ++ lineageweave/__init__.py | 2 +- .../0021_analysis_run_reconstruction.sql | 222 +++++++++++++ .../0022_analysis_source_snapshot_member.sql | 42 +++ .../0021_analysis_run_reconstruction.sql | 38 +++ .../0022_analysis_source_snapshot_member.sql | 28 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 33 ++ ...test_analysis_run_reconstruction_schema.py | 161 +++++++++ tests/test_analysis_run_start.py | 100 ++++++ uv.lock | 2 +- 29 files changed, 1521 insertions(+), 31 deletions(-) create mode 100644 CHANGELOG.d/0.88.0-analysis-run-start.md create mode 100644 backend/app/analysis_run_start.py create mode 100644 docs/adr/0021-authorized-analysis-run-start.md create mode 100644 migrations/0021_analysis_run_reconstruction.sql create mode 100644 migrations/0022_analysis_source_snapshot_member.sql create mode 100644 migrations/rollback/0021_analysis_run_reconstruction.sql create mode 100644 migrations/rollback/0022_analysis_source_snapshot_member.sql create mode 100644 tests/test_analysis_run_reconstruction_schema.py create mode 100644 tests/test_analysis_run_start.py diff --git a/AGENTS.md b/AGENTS.md index 47a71c8c3..937784959 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,9 @@ pnpm run lint && pnpm run test && pnpm run build 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. +expose purge on a public HTTP route. `POST /api/analysis-runs/{id}/start` +reconstructs a Pending lineage bag (ADR 0021 / v0.88.0) and does not +invent a theta. ## CI gates diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf19c5f77..0010e1342 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -472,10 +472,14 @@ run's scope whose `created_at` is at or before `knowledge_cutoff` 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 +cutoff capture (ADR 0017): snapshot, counts, frozen membership, run, +scope, and the first status in one transaction. +`POST /api/analysis-runs/{id}/start` then runs ThreadWeave on that +frozen bag and persists run-scoped edges (ADR 0021). It does not invent a TEPP score. Request a lineage reconstruction from the home -list, then open the Pending row to confirm the cutoff corpus. +list, open the Pending row, then start reconstruction. Hover the +Result digest prefix, then confirm the designed A-100 fork before +treating the live Event Lineage panel as that run's tree. `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. diff --git a/CHANGELOG.d/0.88.0-analysis-run-start.md b/CHANGELOG.d/0.88.0-analysis-run-start.md new file mode 100644 index 000000000..d27e147f5 --- /dev/null +++ b/CHANGELOG.d/0.88.0-analysis-run-start.md @@ -0,0 +1,7 @@ +# 0.88.0 start a pending lineage reconstruction + +Open a Pending Demo Corp lineage row, then start reconstruction. +Confirm the designed A-100 fork (revised quote and delivery question +under the pricing follow-up). Hover the Result digest prefix to read +the parent-choice hash. TEPP start stays 422 — connect the measurement +service from a Failed TEPP row instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a19fe92..8ef9cea33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.88.0] - 2026-08-16 + +### Added + +- `POST /api/analysis-runs/{id}/start` runs ThreadWeave on a visible + Pending lineage cutoff bag and persists run-scoped parent choices + (ADR 0021). Open the Pending run, then start reconstruction. The + designed A-100 fork (revised quote and delivery question under the + pricing follow-up) is the acceptance tree. TEPP start is 422 — this + path does not invent a theta. A Succeeded retry returns the stored + digest. Hover the Result digest prefix to verify the parent-choice + hash. Live Event Lineage stays a separate rebuild. +- Create freezes authorized post ids on + `analysis_source_snapshot_member`. Start reconstructs that bag + instead of a later backfill that shares the cutoff clock. A granted + purge (ADR 0020) now also deletes reconstruction and member rows. + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f87..265915abd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,9 +27,12 @@ theta or a local psychometric substitute. The home list caption stays 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. +lineage row says reconstruction has not started yet; open it and +start reconstruction (ADR 0021). 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. +cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start` +reconstructs that frozen cutoff bag (ADR 0021) and does not invent a +theta. Hover the Result prefix to read the parent-choice digest. diff --git a/README.md b/README.md index b6a881a3a..6a2a8774a 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,8 @@ cd frontend && cp .env.example .env.local && pnpm install && pnpm run dev # 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). +# Start a Pending lineage row: open it, click Start reconstruction, +# hover the Result digest, confirm the A-100 fork (ADR 0021). # -> http://localhost:5173, click "Log in", redirects through the real # Keycloak login page for demo.analyst / lineageweave-demo-only ``` diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index d26eb6f6e..feff629af 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -7,8 +7,9 @@ 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. +scope, and the first Pending event atomically. ``start_pending_analysis_run`` +(ADR 0021) later reconstructs lineage on that cutoff bag. Neither path +invents a TEPP score. """ from __future__ import annotations @@ -247,9 +248,123 @@ async def fetch_visible_analysis_run( affiliated_entity_ids, row["knowledge_cutoff"], ) + digest, edges = await fetch_reconstructed_edges( + conn, + analysis_run_id, + affiliated_entity_ids, + ) + if digest is not None: + detail["reconstruction_result_sha256"] = digest + detail["reconstructed_edges"] = edges return detail +def reconstructed_edge_is_visible( + *, + parent_visibility_code: str, + parent_corporate_entity_id: Any, + child_visibility_code: str, + child_corporate_entity_id: Any, + affiliated_entity_ids: list[str], +) -> bool: + """Hide an edge when either endpoint is outside the caller's ABAC bag.""" + affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} + parent_visible = ( + parent_visibility_code == "public" + or str(parent_corporate_entity_id) in affiliated + ) + child_visible = ( + child_visibility_code == "public" + or str(child_corporate_entity_id) in affiliated + ) + return parent_visible and child_visible + + +async def fetch_reconstructed_edges( + conn: asyncpg.Connection, + analysis_run_id: str, + affiliated_entity_ids: list[str], +) -> tuple[str | None, list[dict[str, Any]]]: + """Return the persisted digest and titled edges, or ``(None, [])``. + + Missing reconstruction tables mean this database has not applied + migration 0021 yet; treat that as no stored tree rather than 500. + Titles follow the same public-or-affiliated rule as ``visible_posts``. + """ + try: + header = await conn.fetchrow( + """ + select result_sha256 + from analysis_run_reconstruction + where analysis_run_id = $1 + """, + analysis_run_id, + ) + except asyncpg.UndefinedTableError: + return None, [] + if header is None: + return None, [] + rows = await conn.fetch( + """ + select + edge.parent_post_id, + parent_post.post_title as parent_post_title, + parent_post.visibility_code as parent_visibility_code, + parent_post.corporate_entity_id as parent_corporate_entity_id, + edge.child_post_id, + child_post.post_title as child_post_title, + child_post.visibility_code as child_visibility_code, + child_post.corporate_entity_id as child_corporate_entity_id, + edge.fused_score + from analysis_run_lineage_edge edge + join source_post parent_post on parent_post.post_id = edge.parent_post_id + join source_post child_post on child_post.post_id = edge.child_post_id + where edge.analysis_run_id = $1 + order by parent_post.post_title, child_post.post_title + """, + analysis_run_id, + ) + return header["result_sha256"], [ + { + "parent_post_id": str(row["parent_post_id"]), + "parent_post_title": row["parent_post_title"], + "child_post_id": str(row["child_post_id"]), + "child_post_title": row["child_post_title"], + "fused_score": float(row["fused_score"]), + } + for row in rows + if reconstructed_edge_is_visible( + parent_visibility_code=row["parent_visibility_code"], + parent_corporate_entity_id=row["parent_corporate_entity_id"], + child_visibility_code=row["child_visibility_code"], + child_corporate_entity_id=row["child_corporate_entity_id"], + affiliated_entity_ids=affiliated_entity_ids, + ) + ] + + +async def persist_snapshot_members( + conn: asyncpg.Connection, + snapshot_id: Any, + post_ids: list[str], +) -> None: + """Freeze authorized post ids on a new snapshot. Skip a legacy database.""" + if not post_ids: + return + try: + await conn.executemany( + """ + insert into analysis_source_snapshot_member + (analysis_source_snapshot_id, source_post_id) + values ($1, $2) + on conflict do nothing + """, + [(snapshot_id, post_id) for post_id in post_ids], + ) + except asyncpg.UndefinedTableError: + return + + async def fetch_visible_scope_posts( conn: asyncpg.Connection, scope_kind_code: str, @@ -574,6 +689,7 @@ async def create_pending_analysis_run( capture.document_count, capture.thread_count, ) + await persist_snapshot_members(conn, snapshot_id, post_ids) try: run_id = await conn.fetchval( """ diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py new file mode 100644 index 000000000..b129c9f77 --- /dev/null +++ b/backend/app/analysis_run_start.py @@ -0,0 +1,309 @@ +"""Start a Pending lineage reconstruction without inventing a TEPP score. + +ADR 0021. ``POST /api/analysis-runs/{id}/start`` transitions Pending to +Running, runs ThreadWeave on the authorized cutoff bag, persists +run-scoped edges, then stamps Succeeded. TEPP stays a wire client. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +import asyncpg + +from backend.app.analysis_run_ingestion import ( + AnalysisRunCreateError, + fetch_visible_analysis_run, +) +from backend.app.lineage_ingestion import records_from_source_posts +from lineageweave.lineage_persistence import lineage_edge_specs +from lineageweave.models import Edge + +_LINEAGE_KIND = "analysis_run_lineage" +_PENDING = "analysis_status_pending" +_RUNNING = "analysis_status_running" +_SUCCEEDED = "analysis_status_succeeded" + + +class AnalysisRunStartError(AnalysisRunCreateError): + """Fail-closed start: HTTP status plus a next-action detail string.""" + + +def reconstruction_result_digest(edges: list[Edge]) -> str: + """SHA-256 of the ordered parent choices. Never hashes a post body.""" + material = json.dumps( + [ + { + "child_post_id": edge.child_id, + "fused_score": round(float(edge.fused_score), 6), + "parent_post_id": edge.parent_id, + } + for edge in sorted(edges, key=lambda item: (item.child_id, item.parent_id)) + ], + separators=(",", ":"), + sort_keys=True, + ) + return hashlib.sha256(material.encode()).hexdigest() + + +async def _cutoff_source_posts( + conn: asyncpg.Connection, + *, + corporate_entity_id: Any, + knowledge_cutoff: Any, + affiliated_entity_ids: list[str], +) -> list[asyncpg.Record]: + """ABAC-visible cutoff rows with the grouping keys reconstruct needs.""" + rows = await conn.fetch( + """ + select post_id, post_title, created_at, visibility_code, + corporate_entity_id, process_unit_id, + thread_group_key, secondary_grouping_key + from source_post + where corporate_entity_id = $1 and created_at <= $2 + order by created_at, post_title + """, + corporate_entity_id, + knowledge_cutoff, + ) + affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} + return [ + row + for row in rows + if row["visibility_code"] == "public" + or str(row["corporate_entity_id"]) in affiliated + ] + + +async def _append_status( + conn: asyncpg.Connection, + analysis_run_id: str, + status_ordinal: int, + status_code: str, + occurred_at: datetime, + failure_code: str | None = None, +) -> None: + """Append one legal lifecycle event. Failed rows carry a machine code.""" + await conn.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code) + values ($1, $2, $3, $4, $5) + """, + analysis_run_id, + status_ordinal, + status_code, + occurred_at, + failure_code, + ) + + +def start_write_conflict_error() -> AnalysisRunStartError: + """Next action when a concurrent start already wrote this run.""" + return AnalysisRunStartError( + 409, + "Open this run. Refresh to see the stored tree if start already finished.", + ) + + +def reconstruction_member_ids( + snapshot_member_ids: list[str], + cutoff_post_ids: list[str], +) -> list[str]: + """Prefer create-time membership over a later cutoff re-query. + + An empty member list means this database has not frozen the bag yet + (migration 0022 missing, or a legacy snapshot). Start then uses the + live cutoff query so those rows still reconstruct. + """ + if snapshot_member_ids: + return list(snapshot_member_ids) + return list(cutoff_post_ids) + + +async def _snapshot_member_posts( + conn: asyncpg.Connection, + snapshot_id: Any, +) -> list[asyncpg.Record]: + """Load frozen capture rows, or empty when the member table is absent.""" + try: + return list( + await conn.fetch( + """ + select post.post_id, post.post_title, post.created_at, + post.visibility_code, post.corporate_entity_id, + post.process_unit_id, post.thread_group_key, + post.secondary_grouping_key + from analysis_source_snapshot_member member + join source_post post on post.post_id = member.source_post_id + where member.analysis_source_snapshot_id = $1 + order by post.created_at, post.post_title + """, + snapshot_id, + ) + ) + except asyncpg.UndefinedTableError: + return [] + + +async def _next_status_ordinal( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> int: + """Return the next contiguous status ordinal for this run.""" + current_max = await conn.fetchval( + """ + select coalesce(max(status_ordinal), 0) + from analysis_run_status_event + where analysis_run_id = $1 + """, + analysis_run_id, + ) + return int(current_max) + 1 + + +async def start_pending_analysis_run( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + account_id: str, + affiliated_entity_ids: list[str], +) -> dict[str, Any]: + """Run ThreadWeave on a visible Pending lineage row. + + TEPP is rejected so this path cannot invent a theta. A Succeeded + retry returns the stored reconstruction. Hidden runs 404. The run + row is locked before Running so a double-click is 409 or a replay, + never a 500. + """ + try: + UUID(analysis_run_id) + except ValueError as exc: + raise AnalysisRunStartError(404, "This analysis run is not visible.") from exc + + current = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account_id, + affiliated_entity_ids, + ) + if current is None: + raise AnalysisRunStartError(404, "This analysis run is not visible.") + if current["run_kind_code"] != _LINEAGE_KIND: + raise AnalysisRunStartError( + 422, + "Connect a TEPP transport from a Failed TEPP row. " + "This start path does not invent a measurement.", + ) + if current["status_code"] == _SUCCEEDED: + return current + if current["status_code"] != _PENDING: + raise AnalysisRunStartError( + 409, + "Open this run. Start is only for a Pending lineage reconstruction.", + ) + + locked = await conn.fetchrow( + """ + select run.analysis_run_id, run.knowledge_cutoff, + run.analysis_source_snapshot_id, scope.corporate_entity_id + from analysis_run run + join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id + where run.analysis_run_id = $1 + for update of run + """, + analysis_run_id, + ) + locked_status = await conn.fetchval( + """ + select status_code + from analysis_run_current_status + where analysis_run_id = $1 + """, + analysis_run_id, + ) + if locked_status == _SUCCEEDED: + replayed = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account_id, + affiliated_entity_ids, + ) + if replayed is None: + raise AnalysisRunStartError(404, "This analysis run is not visible.") + return replayed + if locked_status != _PENDING: + raise AnalysisRunStartError( + 409, + "Open this run. Start is only for a Pending lineage reconstruction.", + ) + + now = datetime.now(timezone.utc) + running_ordinal = await _next_status_ordinal(conn, analysis_run_id) + try: + await _append_status(conn, analysis_run_id, running_ordinal, _RUNNING, now) + member_rows = await _snapshot_member_posts( + conn, + locked["analysis_source_snapshot_id"], + ) + if member_rows: + rows = member_rows + else: + rows = await _cutoff_source_posts( + conn, + corporate_entity_id=locked["corporate_entity_id"], + knowledge_cutoff=locked["knowledge_cutoff"], + affiliated_entity_ids=affiliated_entity_ids, + ) + edges = lineage_edge_specs(records_from_source_posts(rows)) + digest = reconstruction_result_digest(edges) + finished = datetime.now(timezone.utc) + if finished < now: + finished = now + await conn.execute( + """ + insert into analysis_run_reconstruction + (analysis_run_id, result_sha256, edge_count, reconstructed_at) + values ($1, $2, $3, $4) + """, + analysis_run_id, + digest, + len(edges), + finished, + ) + for edge in edges: + await conn.execute( + """ + insert into analysis_run_lineage_edge + (analysis_run_id, child_post_id, parent_post_id, + fused_score, reconstructed_at) + values ($1, $2, $3, $4, $5) + """, + analysis_run_id, + edge.child_id, + edge.parent_id, + edge.fused_score, + finished, + ) + await _append_status( + conn, + analysis_run_id, + running_ordinal + 1, + _SUCCEEDED, + finished, + ) + except asyncpg.UniqueViolationError as exc: + raise start_write_conflict_error() from exc + started = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account_id, + affiliated_entity_ids, + ) + if started is None: + raise AnalysisRunStartError(404, "This analysis run is not visible.") + return started diff --git a/backend/app/main.py b/backend/app/main.py index adb7a20a8..3cb5770f4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,6 +72,10 @@ fetch_visible_analysis_run, fetch_visible_analysis_runs, ) +from backend.app.analysis_run_start import ( + AnalysisRunStartError, + start_pending_analysis_run, +) from backend.app.activity_stream import ( create_valkey_client, get_valkey, @@ -1211,8 +1215,8 @@ 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. + Reconstruction starts from ``POST /api/analysis-runs/{id}/start``. + This write records Pending only and does not invent a TEPP score. """ run_kind_code: str = "analysis_run_lineage" @@ -1253,6 +1257,32 @@ async def create_analysis_run( return created +@app.post("/api/analysis-runs/{analysis_run_id}/start") +async def start_analysis_run( + analysis_run_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Start ThreadWeave on a visible Pending lineage run. + + post_read is enough. Hidden runs 404. TEPP is 422 so this path + cannot invent a theta. A Succeeded retry returns the stored tree. + """ + _require_post_read(account) + async with pool.acquire() as conn: + async with conn.transaction(): + try: + started = await start_pending_analysis_run( + conn, + analysis_run_id=analysis_run_id, + account_id=account.user_account_id, + affiliated_entity_ids=list(account.corporate_entity_ids), + ) + except AnalysisRunStartError as exc: + raise HTTPException(exc.status_code, exc.detail) from exc + return started + + @app.get("/api/analysis-runs/{analysis_run_id}") async def read_analysis_run( analysis_run_id: str, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3b74c22a3..5e9d60327 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -33,6 +33,12 @@ _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" +_RECONSTRUCTION_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0021_analysis_run_reconstruction.sql" +) +_SNAPSHOT_MEMBER_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0022_analysis_source_snapshot_member.sql" +) def _postgres_available() -> bool: @@ -117,6 +123,8 @@ def seeded_db(demo_analyst_token): cur.execute(_MIGRATION_PATH.read_text()) cur.execute(_REGISTRY_MIGRATION.read_text()) cur.execute(_RETENTION_MIGRATION.read_text()) + cur.execute(_RECONSTRUCTION_MIGRATION.read_text()) + cur.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -575,6 +583,101 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( assert unauthenticated.status_code == 401 +def test_start_analysis_run_recovers_the_a100_fork( + client, demo_analyst_token, seeded_db +) -> None: + """Starting a Pending lineage run persists the designed fixture tree.""" + from scripts.seed_demo_data import insert_fixture_source_posts + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) " + "values ('voc_type', 'vom', 'Voice of Market') " + "on conflict (lookup_code) do nothing" + ) + cur.execute( + "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) " + "select corporate_entity_id, 'TEST-PU-START', 'Start reconstruction' " + "from source_post where post_id = %s returning process_unit_id", + (seeded_db["own_private_post_id"],), + ) + process_unit_id = cur.fetchone()[0] + cur.execute( + "select author_account_id, corporate_entity_id from source_post where post_id = %s", + (seeded_db["own_private_post_id"],), + ) + author_id, corp_id = cur.fetchone() + insert_fixture_source_posts(cur, author_id, corp_id, process_unit_id) + finally: + admin_conn.close() + + 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"], + "knowledge_cutoff": "2026-02-15T00:00:00Z", + "idempotency_key": "buyer-start-2026-w07", + }, + ) + assert created.status_code == 201, created.text + run_id = created.json()["analysis_run_id"] + assert created.json()["status_label"] == "Pending" + + started = client.post( + f"/api/analysis-runs/{run_id}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert started.status_code == 200, started.text + body = started.json() + assert body["status_label"] == "Succeeded" + assert all(event["status_label"] != "Failed" for event in body["status_history"]) + assert body["reconstruction_result_sha256"] + children = { + edge["child_post_title"] + for edge in body["reconstructed_edges"] + if edge["parent_post_title"] == "Pricing renegotiation follow-up" + } + assert "Pricing renegotiation: revised quote sent" in children + assert "Delivery schedule question raised" in children + assert "theta" not in str(body).lower() + + replay = client.post( + f"/api/analysis-runs/{run_id}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert replay.status_code == 200 + assert replay.json()["reconstruction_result_sha256"] == body["reconstruction_result_sha256"] + + tepp = 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"], + "knowledge_cutoff": "2026-02-15T00:00:00Z", + "idempotency_key": "buyer-start-tepp-2026-w07", + }, + ) + assert tepp.status_code == 201 + refused = client.post( + f"/api/analysis-runs/{tepp.json()['analysis_run_id']}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert refused.status_code == 422 + assert "invent a measurement" in refused.json()["detail"] + + hidden = client.post( + f"/api/analysis-runs/{seeded_db['hidden_run_id']}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert hidden.status_code == 404 + + 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 diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index ce2f0e6b5..71e9fc733 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -26,6 +26,8 @@ COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/1 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 +COPY migrations/0021_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/22-analysis-run-reconstruction.sql +COPY migrations/0022_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/23-analysis-source-snapshot-member.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/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index d13994bc5..afc601897 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -239,8 +239,11 @@ Acceptance requires: 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. + `POST /api/analysis-runs` now records that Pending write (ADR 0017). + `POST /api/analysis-runs/{id}/start` now reconstructs a Pending + lineage cutoff bag in-process from frozen snapshot membership + (ADR 0021). A durable outbox / Valkey worker 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. diff --git a/docs/adr/0017-authorized-analysis-run-create.md b/docs/adr/0017-authorized-analysis-run-create.md index e3a535a18..520986484 100644 --- a/docs/adr/0017-authorized-analysis-run-create.md +++ b/docs/adr/0017-authorized-analysis-run-create.md @@ -22,7 +22,8 @@ still owns reconstruction and live TEPP execution. 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`, +- The write inserts snapshot, aggregate counts, frozen + `analysis_source_snapshot_member` ids (ADR 0021), `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 diff --git a/docs/adr/0021-authorized-analysis-run-start.md b/docs/adr/0021-authorized-analysis-run-start.md new file mode 100644 index 000000000..f6b2d4f44 --- /dev/null +++ b/docs/adr/0021-authorized-analysis-run-start.md @@ -0,0 +1,113 @@ +# ADR 0021 — Operators start a pending lineage reconstruction + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0013 registry; ADR 0014 authorized read; ADR 0016 cutoff +posts; ADR 0017 authorized create; ADR 0020 granted retention purge +**Refs:** Issue #79 (Milestone 2 parent); ADR 0013 follow-up 3 (in-process +start; durable outbox remains later) + +## Context + +ADR 0017 let an operator record a Pending analysis run. The home button +said “Request a lineage reconstruction,” then the row stayed Pending. +Seed still owned the only Succeeded Demo Corp tree. A buyer cannot +treat a request they cannot start as a product. + +ADR 0019 binds `cataloged_team_id` / `cataloged_corporate_entity_id`. +ADR 0020 is the granted retention purge. Those numbers must not be +reused. Open #142 / #152 used 0019 / 0020 / v0.87.0 on a pre-#141 +base and are not the landing vehicle. + +ADR 0013 follow-up 3 asked for a PostgreSQL outbox and Valkey worker. +That durable delivery path is still later. This slice starts +reconstruction in the authorized request so the operator can see the +cutoff tree immediately. A crash after Running and before Succeeded +rolls the transaction back to Pending. + +A granted purge (ADR 0020) must still empty a run that already stored +edges. Migration `0021` therefore replaces +`purge_analysis_run_registry` so it deletes reconstruction rows and +snapshot members before the 0018 registry tables. + +## Decision + +`POST /api/analysis-runs/{id}/start` requires `post_read` and, in one +transaction: + +1. loads the authorized run (hidden scopes 404); +2. rejects non-lineage kinds so TEPP cannot invent a theta; +3. replays a Succeeded run; +4. accepts only Pending lineage; +5. locks the run row, re-reads status, appends Running, runs + `lineage_edge_specs` / ThreadWeave on the frozen + `analysis_source_snapshot_member` bag (or the live cutoff query when + membership was never persisted), persists + `analysis_run_reconstruction` plus `analysis_run_lineage_edge`, then + appends Succeeded. A concurrent start is 409 with a refresh next + action, not a 500. + +```mermaid +sequenceDiagram + participant Operator + participant API + participant ThreadWeave + participant Registry + Operator->>API: POST /api/analysis-runs/{id}/start + API->>Registry: lock visible Pending lineage run + alt TEPP or other kind + API-->>Operator: 422 connect the measurement service + else already Succeeded + Registry-->>API: stored edges + API-->>Operator: 200 replay + else Pending lineage + Registry->>Registry: Running + API->>ThreadWeave: reconstruct cutoff records + ThreadWeave-->>API: parent choices + Registry->>Registry: reconstruction + edges + Succeeded + API-->>Operator: 200 titled edges + end +``` + +Rules: + +- Edges are run-scoped. This write does not replace live + `post_lineage_edge` (the Event Lineage panel stays a later rebuild). +- The digest hashes parent id, child id, and rounded fused score — never + a post body, DSN, or image. +- Empty cutoff bags Succeed with zero edges. +- Failed TEPP remains a `tepp_client` transport problem. +- A granted purge deletes reconstruction and snapshot-member rows + before `analysis_run` / `analysis_source_snapshot`. + +The home detail adds **Start reconstruction** on a Pending lineage row +and lists titled parent→child edges after Succeeded. The Result digest +prefix is audible next to Code and Config; hover it to verify the +parent-choice hash. Edge titles stay public-or-affiliated. + +## Consequences + +Demo Analyst can request a run, start it, and confirm the designed A-100 +fork (revised quote and delivery question under the pricing follow-up) +without a seed-only Succeeded row. The durable outbox / Valkey worker +and live TEPP transport remain later slices. Do not stamp Succeeded +from a missing reconstruct library, and do not invent a theta. + +## 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. (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/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index c776053b1..bb3582f5e 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -1,7 +1,8 @@ # 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. +**Scope:** Migrations 0018, 0020, 0021, and 0022; ADR 0013 / 0017 / 0020 / 0021; +rollback; and real-PostgreSQL contract tests. ## Standards mapped to implementation @@ -14,7 +15,8 @@ | 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. | +| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows or implementation-specific payloads. | `GET` / `POST /api/analysis-runs` and `POST /api/analysis-runs/{id}/start` return labels, clocks, aggregates, and titled reconstruction edges — never source SQL or a provider body. | +| ThreadWeave tree assembly | Persist the same parent choices the library reconstructs on the cutoff bag. | `start_pending_analysis_run` calls `lineage_edge_specs` on frozen `analysis_source_snapshot_member` rows (or the live cutoff query when membership is absent); tests require the designed A-100 fork through `records_from_source_posts` (revised quote + delivery question under the pricing follow-up). | ## Temporal reasoning @@ -76,7 +78,8 @@ provenance, retention, and immutable evidence rather than blanket masking. | 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. | +| 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 0022, 0021, 0020, then 0018 rollback. | +| Start recovers the designed tree | `POST /api/analysis-runs/{id}/start` on a Pending lineage run persists the A-100 fork (revised quote and delivery question under the pricing follow-up) and refuses TEPP with 422. | ## APA 7th references diff --git a/frontend/package.json b/frontend/package.json index 0d43d9fa2..4c66c7205 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.87.0", + "version": "0.88.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fd8a15146..91b2bbf8f 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -282,6 +282,61 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-lineage-pending/start") && method === "POST") { + return Promise.resolve( + jsonResponse({ + 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_succeeded", + status_label: "Succeeded", + 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" }], + reconstructed_edges: [ + { + parent_post_id: "post-follow-up", + parent_post_title: "Pricing renegotiation follow-up", + child_post_id: "post-quote", + child_post_title: "Pricing renegotiation: revised quote sent", + fused_score: 0.72, + }, + { + parent_post_id: "post-follow-up", + parent_post_title: "Pricing renegotiation follow-up", + child_post_id: "post-delivery", + child_post_title: "Delivery schedule question raised", + fused_score: 0.68, + }, + ], + reconstruction_result_sha256: "aa".repeat(32), + status_history: [ + { + 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: "analysis_status_succeeded", + status_label: "Succeeded", + occurred_at: "2026-01-12T12:37:00Z", + }, + ], + }), + ); + } if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { return Promise.resolve( jsonResponse({ @@ -343,6 +398,7 @@ describe("App, authenticated", () => { requested_at: "2026-01-12T12:35:00Z", source_counts: [], visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + reconstructed_edges: [], status_history: [ { status_ordinal: 1, @@ -1810,7 +1866,7 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); expect( screen.getAllByText( - "Open this run to confirm which posts it will use. Reconstruction has not started yet.", + "Open this run, then start reconstruction. Reconstruction has not started yet.", ), ).toHaveLength(2); const postCall = fetchMock.mock.calls.find( @@ -1824,6 +1880,34 @@ describe("App, authenticated", () => { ); }); + it("starts reconstruction and shows the designed A-100 fork", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click( + await screen.findByRole("button", { name: "Request a lineage reconstruction" }), + ); + await userEvent.click(await screen.findByRole("button", { name: "Start reconstruction" })); + expect( + await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Pricing renegotiation: revised quote sent follows Pricing renegotiation follow-up", + ), + ).toBeInTheDocument(); + expect( + screen.getByText("Delivery schedule question raised follows Pricing renegotiation follow-up"), + ).toBeInTheDocument(); + const digests = screen.getByLabelText("Analysis run reproducibility digests"); + expect(digests).toHaveTextContent("Result aaaaaaaaaaaa"); + expect(screen.getByTitle("aa".repeat(32))).toHaveTextContent("Result aaaaaaaaaaaa"); + const startCall = fetchMock.mock.calls.find((call) => + String(call[0]).endsWith("/api/analysis-runs/run-demo-lineage-pending/start"), + ); + expect(startCall?.[1]?.method).toBe("POST"); + }); + 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 07088e9d4..6ec62700c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { askPostChat, BackendError, createAnalysisRun, + startAnalysisRun, createPostTicket, deriveCommitment, evaluatePost, @@ -1459,7 +1460,7 @@ function analysisRunNextAction(run: AnalysisRun): string | null { 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."; + return "Open this run, then start reconstruction. 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": @@ -1583,11 +1584,23 @@ function analysisRunLivePostButtonLabel(postTitle: string): string { function AnalysisRunReproducibilityDigests({ codeRevisionSha, configurationSha256, + reconstructionResultSha256, }: { codeRevisionSha?: string; configurationSha256?: string; + reconstructionResultSha256?: string; }) { - if (!codeRevisionSha && !configurationSha256) { + const parts: { label: string; digest: string }[] = []; + if (codeRevisionSha) { + parts.push({ label: "Code", digest: codeRevisionSha }); + } + if (configurationSha256) { + parts.push({ label: "Config", digest: configurationSha256 }); + } + if (reconstructionResultSha256) { + parts.push({ label: "Result", digest: reconstructionResultSha256 }); + } + if (parts.length === 0) { return null; } return ( @@ -1596,15 +1609,12 @@ function AnalysisRunReproducibilityDigests({ Hover a prefix to read the full digest for verification.{" "} - {codeRevisionSha ? ( - {`Code ${analysisRunDigestPrefix(codeRevisionSha)}`} - ) : null} - {codeRevisionSha && configurationSha256 ? " · " : null} - {configurationSha256 ? ( - - {`Config ${analysisRunDigestPrefix(configurationSha256)}`} + {parts.map((part, index) => ( + + {index > 0 ? " · " : null} + {`${part.label} ${analysisRunDigestPrefix(part.digest)}`} - ) : null} + ))}

); @@ -1621,6 +1631,7 @@ function AnalysisRunsPanel({ const [selected, setSelected] = useState(null); const [error, setError] = useState(null); const [requesting, setRequesting] = useState(false); + const [starting, setStarting] = useState(false); useEffect(() => { fetchAnalysisRuns(accessToken) @@ -1646,6 +1657,22 @@ function AnalysisRunsPanel({ } } + async function handleStartReconstruction() { + if (!selected) return; + setError(null); + setStarting(true); + try { + const started = await startAnalysisRun(accessToken, selected.analysis_run_id); + const listed = await fetchAnalysisRuns(accessToken); + setRuns(listed.analysis_runs); + setSelected(started); + } catch (err) { + setError(err instanceof BackendError ? err.message : String(err)); + } finally { + setStarting(false); + } + } + async function handleOpen(runId: string) { setError(null); try { @@ -1725,7 +1752,28 @@ function AnalysisRunsPanel({ + {selected.run_kind_code === "analysis_run_lineage" && + selected.status_code === "analysis_status_pending" && ( + + )} + {selected.reconstructed_edges && selected.reconstructed_edges.length > 0 && ( +
    + {selected.reconstructed_edges.map((edge) => ( +
  • + {edge.child_post_title} follows {edge.parent_post_title} +
  • + ))} +
+ )}
    {selected.source_counts.map((count) => (
  • diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3385d5179..0213c3fcd 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -536,6 +536,14 @@ export interface AnalysisRunStatusEvent { failure_code?: string; } +export interface AnalysisRunReconstructedEdge { + parent_post_id: string; + parent_post_title: string; + child_post_id: string; + child_post_title: string; + fused_score: number; +} + export interface AnalysisRun { analysis_run_id: string; run_kind_code: AnalysisRunKindCode; @@ -550,6 +558,8 @@ export interface AnalysisRun { source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; visible_posts?: { post_id: string; post_title: string }[]; + reconstructed_edges?: AnalysisRunReconstructedEdge[]; + reconstruction_result_sha256?: string; code_revision_sha?: string; configuration_sha256?: string; } @@ -579,3 +589,12 @@ export function createAnalysisRun( body: JSON.stringify(request), }); } + +export function startAnalysisRun( + accessToken: string, + analysisRunId: string, +): Promise { + return backendFetch(`/api/analysis-runs/${analysisRunId}/start`, accessToken, { + method: "POST", + }); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1950c39f8..036dca1fa 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.87.0" +__version__ = "0.88.0" diff --git a/migrations/0021_analysis_run_reconstruction.sql b/migrations/0021_analysis_run_reconstruction.sql new file mode 100644 index 000000000..c043af4fa --- /dev/null +++ b/migrations/0021_analysis_run_reconstruction.sql @@ -0,0 +1,222 @@ +-- Run-scoped lineage reconstruction result (ADR 0021). +-- +-- A Pending analysis run may later persist the ThreadWeave parent +-- choices for its cutoff bag. Edges belong to the run, not the live +-- Event Lineage panel. No post body, DSN, or fabricated measurement +-- is stored. +-- +-- ADR 0019 is the R&R catalog-id bind. ADR 0020 is the granted +-- retention purge. Do not reuse those numbers. +-- +-- This migration also replaces purge_analysis_run_registry so a +-- granted purge deletes reconstruction rows (and snapshot members +-- when migration 0022 is present) before the 0018 registry tables. + +begin; + +create table if not exists analysis_run_reconstruction ( + analysis_run_id uuid primary key + references analysis_run (analysis_run_id), + result_sha256 text not null, + edge_count integer not null, + reconstructed_at timestamptz not null, + recorded_at timestamptz not null default clock_timestamp(), + constraint analysis_run_reconstruction_digest_check + check (result_sha256 ~ '^[0-9a-f]{64}$'), + constraint analysis_run_reconstruction_edge_count_check + check (edge_count >= 0), + constraint analysis_run_reconstruction_time_check + check (reconstructed_at <= recorded_at) +); + +comment on table analysis_run_reconstruction is + 'One immutable reconstruction digest per analysis run; never a post body ' + 'or a fabricated psychometric score.'; + +create table if not exists analysis_run_lineage_edge ( + analysis_run_id uuid not null + references analysis_run_reconstruction (analysis_run_id), + child_post_id uuid not null + references source_post (post_id), + parent_post_id uuid not null + references source_post (post_id), + fused_score double precision not null, + reconstructed_at timestamptz not null, + primary key (analysis_run_id, child_post_id), + constraint analysis_run_lineage_edge_distinct_check + check (child_post_id <> parent_post_id), + constraint analysis_run_lineage_edge_score_check + check (fused_score >= 0 and fused_score <= 1) +); + +comment on table analysis_run_lineage_edge is + 'One reconstructed parent choice per child post inside one analysis run.'; + +create or replace function reject_analysis_run_reconstruction_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_reconstruction_is_immutable'; +end +$$; + +comment on function reject_analysis_run_reconstruction_update() is + 'Rejects mutation of a persisted reconstruction digest.'; + +drop trigger if exists analysis_run_reconstruction_update_reject + on analysis_run_reconstruction; +create trigger analysis_run_reconstruction_update_reject +before update or delete on analysis_run_reconstruction +for each row execute function reject_analysis_run_reconstruction_update(); + +create or replace function reject_analysis_run_lineage_edge_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_lineage_edge_is_immutable'; +end +$$; + +comment on function reject_analysis_run_lineage_edge_update() is + 'Rejects mutation of a persisted run-scoped lineage edge.'; + +drop trigger if exists analysis_run_lineage_edge_update_reject + on analysis_run_lineage_edge; +create trigger analysis_run_lineage_edge_update_reject +before update or delete on analysis_run_lineage_edge +for each row execute function reject_analysis_run_lineage_edge_update(); + +create or replace function purge_analysis_run_registry(approval_token text) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + run_count bigint; + snapshot_count bigint; + has_reconstruction boolean; + has_snapshot_member boolean; +begin + if not exists ( + select 1 + from analysis_run_retention_grant + where database_role_name = session_user + and revoked_at is null + ) then + raise exception 'analysis_run_retention_not_granted'; + end if; + + if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then + raise exception 'analysis_run_retention_not_admin'; + end if; + + if approval_token is distinct from 'approved-retention-purge' then + raise exception 'analysis_run_retention_not_approved'; + end if; + + select count(*) into run_count from analysis_run; + select count(*) into snapshot_count from analysis_source_snapshot; + has_reconstruction := to_regclass('public.analysis_run_reconstruction') is not null; + has_snapshot_member := to_regclass('public.analysis_source_snapshot_member') is not null; + + alter table analysis_run_status_event + disable trigger analysis_run_status_event_delete_reject; + alter table analysis_run_scope + disable trigger analysis_run_scope_mutation_reject; + alter table analysis_run + disable trigger analysis_run_mutation_reject; + if has_reconstruction then + alter table analysis_run_lineage_edge + disable trigger analysis_run_lineage_edge_update_reject; + alter table analysis_run_reconstruction + disable trigger analysis_run_reconstruction_update_reject; + end if; + if has_snapshot_member then + alter table analysis_source_snapshot_member + disable trigger analysis_source_snapshot_member_update_reject; + end if; + + begin + if has_reconstruction then + delete from analysis_run_lineage_edge; + delete from analysis_run_reconstruction; + end if; + delete from analysis_run_status_event; + delete from analysis_run_scope; + delete from analysis_run; + delete from analysis_source_count; + if has_snapshot_member then + delete from analysis_source_snapshot_member; + end if; + delete from analysis_source_snapshot; + exception + when others then + if has_snapshot_member then + alter table analysis_source_snapshot_member + enable trigger analysis_source_snapshot_member_update_reject; + end if; + if has_reconstruction then + alter table analysis_run_reconstruction + enable trigger analysis_run_reconstruction_update_reject; + alter table analysis_run_lineage_edge + enable trigger analysis_run_lineage_edge_update_reject; + end if; + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + raise; + end; + + if has_snapshot_member then + alter table analysis_source_snapshot_member + enable trigger analysis_source_snapshot_member_update_reject; + end if; + if has_reconstruction then + alter table analysis_run_reconstruction + enable trigger analysis_run_reconstruction_update_reject; + alter table analysis_run_lineage_edge + enable trigger analysis_run_lineage_edge_update_reject; + end if; + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + + insert into analysis_run_retention_event ( + purged_run_count, + purged_snapshot_count, + approval_token_digest, + invoking_session_role, + invoking_current_role, + client_network_address + ) values ( + run_count, + snapshot_count, + encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'), + session_user, + current_user, + inet_client_addr() + ); +end +$$; + +comment on function purge_analysis_run_registry(text) is + 'Empties immutable registry and reconstruction relations after an ' + 'unrevoked role grant, analysis_run_retention_admin membership, and ' + 'the documented approval token. Next action: export ' + 'analysis_run_retention_event, delete it, then roll back 0022, 0021, ' + '0020, and 0018.'; + +revoke all on function purge_analysis_run_registry(text) from public; +grant execute on function purge_analysis_run_registry(text) + to analysis_run_retention_admin; + +commit; diff --git a/migrations/0022_analysis_source_snapshot_member.sql b/migrations/0022_analysis_source_snapshot_member.sql new file mode 100644 index 000000000..518bbf33b --- /dev/null +++ b/migrations/0022_analysis_source_snapshot_member.sql @@ -0,0 +1,42 @@ +-- Create-time cutoff membership for an analysis source snapshot (ADR 0021). +-- +-- The snapshot digest already hashes authorized post ids. This relation +-- stores those ids so start reconstructs the same bag, not a later +-- backfill that shares the cutoff clock. No post body is stored. +-- +-- ADR 0020 remains the granted retention purge. purge_analysis_run_registry +-- from migration 0021 already deletes this table when it exists. + +begin; + +create table if not exists analysis_source_snapshot_member ( + analysis_source_snapshot_id uuid not null + references analysis_source_snapshot (analysis_source_snapshot_id), + source_post_id uuid not null + references source_post (post_id), + primary key (analysis_source_snapshot_id, source_post_id) +); + +comment on table analysis_source_snapshot_member is + 'Authorized post ids frozen at snapshot capture; start reconstructs ' + 'these rows and never a later backfill.'; + +create or replace function reject_analysis_source_snapshot_member_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_source_snapshot_member_is_immutable'; +end +$$; + +comment on function reject_analysis_source_snapshot_member_update() is + 'Rejects mutation of frozen snapshot membership.'; + +drop trigger if exists analysis_source_snapshot_member_update_reject + on analysis_source_snapshot_member; +create trigger analysis_source_snapshot_member_update_reject +before update or delete on analysis_source_snapshot_member +for each row execute function reject_analysis_source_snapshot_member_update(); + +commit; diff --git a/migrations/rollback/0021_analysis_run_reconstruction.sql b/migrations/rollback/0021_analysis_run_reconstruction.sql new file mode 100644 index 000000000..594c022eb --- /dev/null +++ b/migrations/rollback/0021_analysis_run_reconstruction.sql @@ -0,0 +1,38 @@ +-- Fail-closed rollback for migration 0021. +-- +-- Reconstruction evidence must be exported, then emptied with +-- select purge_analysis_run_registry('approved-retention-purge') +-- (ADR 0020 / 0021), before these objects can be removed. + +begin; + +do $$ +declare + relation_name text; + relation_has_rows boolean; +begin + foreach relation_name in array array[ + 'analysis_run_lineage_edge', + 'analysis_run_reconstruction' + ] loop + if to_regclass('public.' || relation_name) is not null then + execute format('select exists (select 1 from %I)', relation_name) + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_run_reconstruction_not_empty'; + end if; + end if; + end loop; +end +$$; + +drop trigger if exists analysis_run_lineage_edge_update_reject + on analysis_run_lineage_edge; +drop trigger if exists analysis_run_reconstruction_update_reject + on analysis_run_reconstruction; +drop function if exists reject_analysis_run_lineage_edge_update(); +drop function if exists reject_analysis_run_reconstruction_update(); +drop table if exists analysis_run_lineage_edge; +drop table if exists analysis_run_reconstruction; + +commit; diff --git a/migrations/rollback/0022_analysis_source_snapshot_member.sql b/migrations/rollback/0022_analysis_source_snapshot_member.sql new file mode 100644 index 000000000..fda76f1af --- /dev/null +++ b/migrations/rollback/0022_analysis_source_snapshot_member.sql @@ -0,0 +1,28 @@ +-- Fail-closed rollback for migration 0022. +-- +-- Snapshot membership must be exported, then emptied with +-- select purge_analysis_run_registry('approved-retention-purge') +-- (ADR 0020 / 0021), before these objects can be removed. + +begin; + +do $$ +declare + relation_has_rows boolean; +begin + if to_regclass('public.analysis_source_snapshot_member') is not null then + execute 'select exists (select 1 from analysis_source_snapshot_member)' + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_source_snapshot_member_not_empty'; + end if; + end if; +end +$$; + +drop trigger if exists analysis_source_snapshot_member_update_reject + on analysis_source_snapshot_member; +drop function if exists reject_analysis_source_snapshot_member_update(); +drop table if exists analysis_source_snapshot_member; + +commit; diff --git a/pyproject.toml b/pyproject.toml index ecfe24877..5a4aa12bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.87.0" +version = "0.88.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 2f3c66c45..8cb1f0ea1 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -122,6 +122,8 @@ def seed( cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) cur.execute((migrations / "0019_role_catalog_identity.sql").read_text()) cur.execute((migrations / "0020_analysis_run_retention_purge.sql").read_text()) + cur.execute((migrations / "0021_analysis_run_reconstruction.sql").read_text()) + cur.execute((migrations / "0022_analysis_source_snapshot_member.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -1283,6 +1285,35 @@ def _ensure_demo_source_counts(cur, snapshot_id) -> None: ) +def _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id) -> None: + """Freeze Demo Corp post ids on the shared snapshot when the table exists.""" + cur.execute( + "select 1 from information_schema.tables " + "where table_schema = 'public' " + "and table_name = 'analysis_source_snapshot_member'" + ) + if cur.fetchone() is None: + return + cur.execute( + "select 1 from analysis_source_snapshot_member " + "where analysis_source_snapshot_id = %s limit 1", + (snapshot_id,), + ) + if cur.fetchone() is not None: + return + cur.execute( + """ + insert into analysis_source_snapshot_member + (analysis_source_snapshot_id, source_post_id) + select %s, post_id from source_post + where corporate_entity_id = %s + and created_at <= '2026-01-12T00:00:00Z' + on conflict do nothing + """, + (snapshot_id, corporate_entity_id), + ) + + def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: """Insert one Demo-Corp lineage run so Analysis runs is not empty. @@ -1292,6 +1323,7 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - """ snapshot_id = _ensure_demo_source_snapshot(cur) _ensure_demo_source_counts(cur, snapshot_id) + _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id) cur.execute( """ select analysis_run_id from analysis_run @@ -1387,6 +1419,7 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No """ snapshot_id = _ensure_demo_source_snapshot(cur) _ensure_demo_source_counts(cur, snapshot_id) + _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id) cur.execute( """ select analysis_run_id from analysis_run diff --git a/tests/test_analysis_run_reconstruction_schema.py b/tests/test_analysis_run_reconstruction_schema.py new file mode 100644 index 000000000..ba685a3f4 --- /dev/null +++ b/tests/test_analysis_run_reconstruction_schema.py @@ -0,0 +1,161 @@ +"""Static and optional PostgreSQL contracts for run-scoped reconstruction.""" + +from __future__ import annotations + +import os +import re +import uuid +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" +_REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" +_RETENTION_MIGRATION = _ROOT / "migrations" / "0020_analysis_run_retention_purge.sql" +_RECONSTRUCTION_MIGRATION = _ROOT / "migrations" / "0021_analysis_run_reconstruction.sql" +_RECONSTRUCTION_ROLLBACK = ( + _ROOT / "migrations" / "rollback" / "0021_analysis_run_reconstruction.sql" +) +_SNAPSHOT_MEMBER_MIGRATION = ( + _ROOT / "migrations" / "0022_analysis_source_snapshot_member.sql" +) +_SNAPSHOT_MEMBER_ROLLBACK = ( + _ROOT / "migrations" / "rollback" / "0022_analysis_source_snapshot_member.sql" +) +_POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile" +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_REQUIRED_TABLES = { + "analysis_run_reconstruction", + "analysis_run_lineage_edge", +} + + +def test_reconstruction_migration_is_normalized_and_wired() -> None: + """Static contract: 3NF names, no payload JSON, Dockerfile copy, rollback.""" + migration = _RECONSTRUCTION_MIGRATION.read_text(encoding="utf-8") + rollback = _RECONSTRUCTION_ROLLBACK.read_text(encoding="utf-8") + dockerfile = _POSTGRES_IMAGE.read_text(encoding="utf-8") + seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8") + created_tables = set( + re.findall(r"create table if not exists\s+([a-z0-9_]+)", migration, re.I) + ) + assert _REQUIRED_TABLES <= created_tables + assert "jsonb" not in migration.casefold() + assert "metadata_payload" not in migration + assert "theta" not in migration.casefold() + assert "0021_analysis_run_reconstruction.sql" in dockerfile + assert "0022_analysis_source_snapshot_member.sql" in dockerfile + assert seed.index("0020_analysis_run_retention_purge.sql") < seed.index( + "0021_analysis_run_reconstruction.sql" + ) + assert seed.index("0021_analysis_run_reconstruction.sql") < seed.index( + "0022_analysis_source_snapshot_member.sql" + ) + assert "analysis_run_reconstruction_not_empty" in rollback + assert "reject_analysis_run_reconstruction_update" in migration + assert "reject_analysis_run_lineage_edge_update" in migration + assert "purge_analysis_run_registry" in migration + assert "analysis_run_lineage_edge" in migration + member_migration = _SNAPSHOT_MEMBER_MIGRATION.read_text(encoding="utf-8") + member_rollback = _SNAPSHOT_MEMBER_ROLLBACK.read_text(encoding="utf-8") + assert "analysis_source_snapshot_member" in member_migration + assert "jsonb" not in member_migration.casefold() + assert "theta" not in member_migration.casefold() + assert "analysis_source_snapshot_member_not_empty" in member_rollback + assert "reject_analysis_source_snapshot_member_update" in member_migration + for object_name in re.findall( + r"create table if not exists\s+([a-z0-9_]+)", + member_migration, + re.I, + ): + assert len(object_name.split("_")) >= 2, object_name + + object_patterns = ( + r"create table if not exists\s+([a-z0-9_]+)", + r"create or replace function\s+([a-z0-9_]+)", + r"create trigger\s+([a-z0-9_]+)", + ) + for pattern in object_patterns: + for object_name in re.findall(pattern, migration, re.I): + assert len(object_name.split("_")) >= 2, object_name + + +def _postgres_available() -> bool: + """Return whether the configured administrator DSN is reachable.""" + try: + import psycopg2 + + psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close() + return True + except Exception: + return False + + +def _database_dsn(database_name: str) -> str: + """Replace only the database path while preserving DSN query options.""" + parsed = urlsplit(_ADMIN_DSN) + return urlunsplit(parsed._replace(path=f"/{database_name}")) + + +@pytest.fixture +def reconstruction_db(): + """Yield a throwaway registry+reconstruction database.""" + if not _postgres_available(): + pytest.skip("a reachable PostgreSQL administrator DSN is required") + import psycopg2 + + database_name = f"lineageweave_recon_{uuid.uuid4().hex[:12]}" + admin = psycopg2.connect(_ADMIN_DSN) + admin.autocommit = True + try: + with admin.cursor() as cursor: + cursor.execute(f'create database "{database_name}"') + finally: + admin.close() + conn = psycopg2.connect(_database_dsn(database_name)) + conn.autocommit = True + try: + with conn.cursor() as cursor: + cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RECONSTRUCTION_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text(encoding="utf-8")) + yield conn + finally: + conn.close() + admin = psycopg2.connect(_ADMIN_DSN) + admin.autocommit = True + try: + with admin.cursor() as cursor: + cursor.execute( + "select pg_terminate_backend(pid) from pg_stat_activity " + "where datname = %s and pid <> pg_backend_pid()", + (database_name,), + ) + cursor.execute(f'drop database "{database_name}"') + finally: + admin.close() + + +def test_empty_reconstruction_rollback_is_replayable(reconstruction_db) -> None: + """An empty reconstruction schema can be rolled back and removed.""" + with reconstruction_db.cursor() as cursor: + cursor.execute( + "select table_name from information_schema.tables " + "where table_schema = 'public' and table_name = any(%s)", + (list(_REQUIRED_TABLES),), + ) + assert {row[0] for row in cursor.fetchall()} == _REQUIRED_TABLES + cursor.execute(_RECONSTRUCTION_ROLLBACK.read_text(encoding="utf-8")) + cursor.execute( + "select table_name from information_schema.tables " + "where table_schema = 'public' and table_name = any(%s)", + (list(_REQUIRED_TABLES),), + ) + assert cursor.fetchall() == [] + cursor.execute(_RECONSTRUCTION_ROLLBACK.read_text(encoding="utf-8")) diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py new file mode 100644 index 000000000..f2b6e53ab --- /dev/null +++ b/tests/test_analysis_run_start.py @@ -0,0 +1,100 @@ +"""Start-reconstruction contracts: digest stability and designed-tree fidelity.""" + +from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible +from backend.app.analysis_run_start import ( + AnalysisRunStartError, + reconstruction_member_ids, + reconstruction_result_digest, + start_write_conflict_error, +) +from backend.app.lineage_ingestion import records_from_source_posts +from lineageweave.fixtures import sample_records +from lineageweave.lineage_persistence import lineage_edge_specs + + +def test_reconstruction_digest_is_stable_and_ignores_edge_order() -> None: + """The same parent choices hash the same way regardless of insert order.""" + edges = lineage_edge_specs(sample_records()) + reversed_edges = list(reversed(edges)) + assert reconstruction_result_digest(edges) == reconstruction_result_digest(reversed_edges) + assert reconstruction_result_digest([]) == reconstruction_result_digest([]) + assert reconstruction_result_digest(edges) != reconstruction_result_digest([]) + + +def test_start_uses_the_same_parent_choices_as_library_reconstruct() -> None: + """The product start path must recover the designed A-100 fork. + + fixtures.sample_records() is the synthetic gold tree: rec-002 is the + branch point for the revised quote and the delivery question. A start + that dropped an edge or invented a parent would fail this check. + """ + edges = lineage_edge_specs(sample_records()) + children = { + edge.child_id for edge in edges if edge.parent_id == "rec-002" + } + assert children >= {"rec-003", "rec-004"} + assert all(0.0 <= edge.fused_score <= 1.0 for edge in edges) + assert "theta" not in reconstruction_result_digest(edges) + + +def test_start_wiring_recovers_a100_from_source_post_rows() -> None: + """CI must exercise records_from_source_posts, not only library reconstruct.""" + rows = [ + { + "post_id": record.record_id, + "post_title": record.label, + "created_at": record.occurred_at, + "thread_group_key": record.group_key, + "secondary_grouping_key": record.secondary_key, + "process_unit_id": None, + "corporate_entity_id": "corp-demo", + } + for record in sample_records() + ] + edges = lineage_edge_specs(records_from_source_posts(rows)) + children = {edge.child_id for edge in edges if edge.parent_id == "rec-002"} + assert children >= {"rec-003", "rec-004"} + assert reconstruction_result_digest(edges) == reconstruction_result_digest( + lineage_edge_specs(sample_records()) + ) + + +def test_snapshot_members_exclude_a_later_backfill() -> None: + """Start reconstructs the create-time bag, not a later cutoff re-query.""" + captured = ["rec-001", "rec-002", "rec-003", "rec-004"] + cutoff_with_backfill = [*captured, "rec-backfill"] + assert reconstruction_member_ids(captured, cutoff_with_backfill) == captured + assert reconstruction_member_ids([], cutoff_with_backfill) == cutoff_with_backfill + + +def test_reconstructed_edge_hides_unaffiliated_private_titles() -> None: + """Edge titles use the same public-or-affiliated rule as cutoff posts.""" + affiliated = ["corp-demo"] + assert reconstructed_edge_is_visible( + parent_visibility_code="public", + parent_corporate_entity_id="corp-other", + child_visibility_code="public", + child_corporate_entity_id="corp-other", + affiliated_entity_ids=affiliated, + ) + assert not reconstructed_edge_is_visible( + parent_visibility_code="private", + parent_corporate_entity_id="corp-other", + child_visibility_code="public", + child_corporate_entity_id="corp-demo", + affiliated_entity_ids=affiliated, + ) + + +def test_start_error_carries_a_next_action() -> None: + """Operators get a next action, not an internal exception name.""" + error = AnalysisRunStartError( + 422, + "Connect a TEPP transport from a Failed TEPP row. " + "This start path does not invent a measurement.", + ) + assert error.status_code == 422 + assert "invent a measurement" in error.detail + conflict = start_write_conflict_error() + assert conflict.status_code == 409 + assert "Refresh to see the stored tree" in conflict.detail diff --git a/uv.lock b/uv.lock index 6915a3531..03e15b410 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.87.0" +version = "0.88.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 04a4bf9ff6fa4fc54b24d3ba10949484d684d028 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:56:43 +0000 Subject: [PATCH 5/6] fix: purge reconstruction rows on the granted retention path (v0.88.1) After ADR 0021 start, a granted purge hit foreign keys on analysis_run_reconstruction and analysis_source_snapshot_member. Migration 0023 (ADR 0022) deletes those rows first, then the 0018 registry tables. Authorization stays conjunctive. Next action: start a Pending lineage run, then empty the registry with the documented grant + admin + phrase path. Co-authored-by: Seongho Bae --- AGENTS.md | 4 +- .../0.88.1-retention-purge-reconstruction.md | 8 + CHANGELOG.md | 10 ++ CLAUDE.md | 3 +- backend/tests/test_api.py | 6 + docker/postgres-init/Dockerfile | 1 + .../0022-retention-purge-reconstruction.md | 61 ++++++++ frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- ...sis_run_retention_purge_reconstruction.sql | 145 ++++++++++++++++++ ...sis_run_retention_purge_reconstruction.sql | 102 ++++++++++++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_analysis_run_registry_schema.py | 11 ++ uv.lock | 2 +- 15 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/0.88.1-retention-purge-reconstruction.md create mode 100644 docs/adr/0022-retention-purge-reconstruction.md create mode 100644 migrations/0023_analysis_run_retention_purge_reconstruction.sql create mode 100644 migrations/rollback/0023_analysis_run_retention_purge_reconstruction.sql diff --git a/AGENTS.md b/AGENTS.md index cebc790a9..41edff643 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,9 @@ pnpm run lint && pnpm run test && pnpm run build 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. +expose purge on a public HTTP route. After a start, migration 0023 +(ADR 0022 / v0.88.1) makes that purge delete reconstruction and +snapshot-member rows first. `POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 / diff --git a/CHANGELOG.d/0.88.1-retention-purge-reconstruction.md b/CHANGELOG.d/0.88.1-retention-purge-reconstruction.md new file mode 100644 index 000000000..7be92cc3e --- /dev/null +++ b/CHANGELOG.d/0.88.1-retention-purge-reconstruction.md @@ -0,0 +1,8 @@ +# 0.88.1 purge reconstruction with the granted path + +After starting a Pending lineage run, empty the registry the same way: +insert an unrevoked `analysis_run_retention_grant`, grant +`analysis_run_retention_admin`, then +`select purge_analysis_run_registry('approved-retention-purge')`. +Export the retention event, then roll back 0023, 0022, 0021, 0020, and +0018. diff --git a/CHANGELOG.md b/CHANGELOG.md index 42239b071..c59ff77aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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.88.1] - 2026-08-16 + +### Fixed + +- A granted `purge_analysis_run_registry` now deletes reconstruction + edges and frozen snapshot members before the 0018 registry tables + (ADR 0022). After you start a Pending lineage run, the documented + grant + admin + phrase path still empties the registry. Then export + `analysis_run_retention_event` and roll back 0023 through 0018. + ## [0.88.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 265915abd..f8f1048b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,8 @@ To empty a run-bearing registry, insert an unrevoked `GRANT analysis_run_retention_admin` (ADR 0020). Then `select purge_analysis_run_registry('approved-retention-purge')`, export `analysis_run_retention_event`, delete those rows, and roll -back 0020 then 0018. The published phrase is not a secret. Do not +back 0023, 0022, 0021, 0020, then 0018 when reconstruction rows exist +(ADR 0022). 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. diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index ef147e99f..0dcccbdf9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -39,6 +39,11 @@ _SNAPSHOT_MEMBER_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0022_analysis_source_snapshot_member.sql" ) +_PURGE_RECONSTRUCTION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0023_analysis_run_retention_purge_reconstruction.sql" +) def _postgres_available() -> bool: @@ -125,6 +130,7 @@ def seeded_db(demo_analyst_token): cur.execute(_RETENTION_MIGRATION.read_text()) cur.execute(_RECONSTRUCTION_MIGRATION.read_text()) cur.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text()) + cur.execute(_PURGE_RECONSTRUCTION_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index 71e9fc733..d7602aeba 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -28,6 +28,7 @@ COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-ro COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.sql COPY migrations/0021_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/22-analysis-run-reconstruction.sql COPY migrations/0022_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/23-analysis-source-snapshot-member.sql +COPY migrations/0023_analysis_run_retention_purge_reconstruction.sql /docker-entrypoint-initdb.d/24-analysis-run-retention-purge-reconstruction.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/adr/0022-retention-purge-reconstruction.md b/docs/adr/0022-retention-purge-reconstruction.md new file mode 100644 index 000000000..62a8f27aa --- /dev/null +++ b/docs/adr/0022-retention-purge-reconstruction.md @@ -0,0 +1,61 @@ +# ADR 0022 — Granted purge also empties reconstruction evidence + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0020 granted retention purge; ADR 0021 authorized start + +## Context + +ADR 0020 added `purge_analysis_run_registry` so operators can empty a +run-bearing registry without a superuser `DISABLE TRIGGER`. ADR 0021 +then persisted `analysis_run_reconstruction`, `analysis_run_lineage_edge`, +and `analysis_source_snapshot_member`. Those rows reference +`analysis_run` and `analysis_source_snapshot`. + +After the first start, the 0020 function hits a foreign-key failure. +The documented operator path (grant + admin + published phrase) no +longer empties the registry. That is not a supported product path +(ISO 15489-1:2016 disposition; NIST SP 800-92 protected audit records). + +## Decision + +Migration `0023_analysis_run_retention_purge_reconstruction.sql` +replaces `purge_analysis_run_registry` so that, after the same +conjunctive authorization, it: + +1. disables reconstruction and snapshot-member immutability triggers + when those tables exist; +2. deletes `analysis_run_lineage_edge` then `analysis_run_reconstruction`; +3. deletes the 0018 registry rows; +4. deletes `analysis_source_snapshot_member`; +5. deletes `analysis_source_snapshot`; +6. re-enables every trigger it disabled; +7. writes one `analysis_run_retention_event`. + +Authorization, `REVOKE ALL … FROM PUBLIC`, and the published phrase +do not change. Rollback 0023 restores the 0020 function body. + +## Consequences + +Operators who started a Pending lineage run can still empty the +registry through the documented grant path, then roll back 0023, 0022, +0021, 0020, and 0018. A raw `DELETE` of reconstruction rows stays +rejected. Do not expose purge on a public HTTP route. + +## References — APA 7th + +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 + +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/frontend/package.json b/frontend/package.json index 4c66c7205..16983140e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.88.0", + "version": "0.88.1", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 036dca1fa..e46585d99 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.88.0" +__version__ = "0.88.1" diff --git a/migrations/0023_analysis_run_retention_purge_reconstruction.sql b/migrations/0023_analysis_run_retention_purge_reconstruction.sql new file mode 100644 index 000000000..ae11173d6 --- /dev/null +++ b/migrations/0023_analysis_run_retention_purge_reconstruction.sql @@ -0,0 +1,145 @@ +-- Granted purge must empty reconstruction evidence (ADR 0022). +-- +-- Landed 0021 / 0022 add immutable reconstruction and snapshot-member +-- rows that reference analysis_run / analysis_source_snapshot. The +-- 0020 purge deleted only the 0018 registry tables, so a granted empty +-- after start failed on foreign keys. Replace the function so the +-- documented operator path still works. +-- +-- Authorization stays conjunctive (ADR 0020): unrevoked grant, admin +-- membership, then the published phrase. PUBLIC still has no EXECUTE. + +begin; + +create or replace function purge_analysis_run_registry(approval_token text) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + run_count bigint; + snapshot_count bigint; + has_reconstruction boolean; + has_snapshot_member boolean; +begin + if not exists ( + select 1 + from analysis_run_retention_grant + where database_role_name = session_user + and revoked_at is null + ) then + raise exception 'analysis_run_retention_not_granted'; + end if; + + if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then + raise exception 'analysis_run_retention_not_admin'; + end if; + + if approval_token is distinct from 'approved-retention-purge' then + raise exception 'analysis_run_retention_not_approved'; + end if; + + select count(*) into run_count from analysis_run; + select count(*) into snapshot_count from analysis_source_snapshot; + has_reconstruction := to_regclass('public.analysis_run_reconstruction') is not null; + has_snapshot_member := to_regclass('public.analysis_source_snapshot_member') is not null; + + alter table analysis_run_status_event + disable trigger analysis_run_status_event_delete_reject; + alter table analysis_run_scope + disable trigger analysis_run_scope_mutation_reject; + alter table analysis_run + disable trigger analysis_run_mutation_reject; + if has_reconstruction then + alter table analysis_run_lineage_edge + disable trigger analysis_run_lineage_edge_update_reject; + alter table analysis_run_reconstruction + disable trigger analysis_run_reconstruction_update_reject; + end if; + if has_snapshot_member then + alter table analysis_source_snapshot_member + disable trigger analysis_source_snapshot_member_update_reject; + end if; + + begin + if has_reconstruction then + delete from analysis_run_lineage_edge; + delete from analysis_run_reconstruction; + end if; + delete from analysis_run_status_event; + delete from analysis_run_scope; + delete from analysis_run; + delete from analysis_source_count; + if has_snapshot_member then + delete from analysis_source_snapshot_member; + end if; + delete from analysis_source_snapshot; + exception + when others then + if has_snapshot_member then + alter table analysis_source_snapshot_member + enable trigger analysis_source_snapshot_member_update_reject; + end if; + if has_reconstruction then + alter table analysis_run_reconstruction + enable trigger analysis_run_reconstruction_update_reject; + alter table analysis_run_lineage_edge + enable trigger analysis_run_lineage_edge_update_reject; + end if; + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + raise; + end; + + if has_snapshot_member then + alter table analysis_source_snapshot_member + enable trigger analysis_source_snapshot_member_update_reject; + end if; + if has_reconstruction then + alter table analysis_run_reconstruction + enable trigger analysis_run_reconstruction_update_reject; + alter table analysis_run_lineage_edge + enable trigger analysis_run_lineage_edge_update_reject; + end if; + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + + insert into analysis_run_retention_event ( + purged_run_count, + purged_snapshot_count, + approval_token_digest, + invoking_session_role, + invoking_current_role, + client_network_address + ) values ( + run_count, + snapshot_count, + encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'), + session_user, + current_user, + inet_client_addr() + ); +end +$$; + +comment on function purge_analysis_run_registry(text) is + 'Empties immutable registry, reconstruction, and snapshot-member ' + 'relations after an unrevoked role grant, analysis_run_retention_admin ' + 'membership, and the documented approval token. Next action: export ' + 'analysis_run_retention_event, delete it, then roll back 0023, 0022, ' + '0021, 0020, and 0018.'; + +revoke all on function purge_analysis_run_registry(text) from public; +grant execute on function purge_analysis_run_registry(text) + to analysis_run_retention_admin; + +commit; diff --git a/migrations/rollback/0023_analysis_run_retention_purge_reconstruction.sql b/migrations/rollback/0023_analysis_run_retention_purge_reconstruction.sql new file mode 100644 index 000000000..9a6a78576 --- /dev/null +++ b/migrations/rollback/0023_analysis_run_retention_purge_reconstruction.sql @@ -0,0 +1,102 @@ +-- Rollback for migration 0023 restores the 0020 purge body. +-- +-- After this script, a granted purge no longer deletes reconstruction +-- or snapshot-member rows. Export and empty those tables with the 0023 +-- function before rolling back 0022 and 0021. + +begin; + +-- Recreate the 0020 function text. Source of truth remains +-- migrations/0020_analysis_run_retention_purge.sql; this copy exists +-- only so 0023 can be reversed without re-running 0020's DDL. + +create or replace function purge_analysis_run_registry(approval_token text) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + run_count bigint; + snapshot_count bigint; +begin + if not exists ( + select 1 + from analysis_run_retention_grant + where database_role_name = session_user + and revoked_at is null + ) then + raise exception 'analysis_run_retention_not_granted'; + end if; + + if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then + raise exception 'analysis_run_retention_not_admin'; + end if; + + if approval_token is distinct from 'approved-retention-purge' then + raise exception 'analysis_run_retention_not_approved'; + end if; + + select count(*) into run_count from analysis_run; + select count(*) into snapshot_count from analysis_source_snapshot; + + alter table analysis_run_status_event + disable trigger analysis_run_status_event_delete_reject; + alter table analysis_run_scope + disable trigger analysis_run_scope_mutation_reject; + alter table analysis_run + disable trigger analysis_run_mutation_reject; + + begin + delete from analysis_run_status_event; + delete from analysis_run_scope; + delete from analysis_run; + delete from analysis_source_count; + delete from analysis_source_snapshot; + exception + when others then + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + raise; + end; + + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + + insert into analysis_run_retention_event ( + purged_run_count, + purged_snapshot_count, + approval_token_digest, + invoking_session_role, + invoking_current_role, + client_network_address + ) values ( + run_count, + snapshot_count, + encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'), + session_user, + current_user, + inet_client_addr() + ); +end +$$; + +comment on function purge_analysis_run_registry(text) is + 'Empties immutable registry relations after an unrevoked role grant, ' + 'analysis_run_retention_admin membership, and the documented approval ' + 'token; records one analysis_run_retention_event. Next action: export ' + 'that event, delete it, then roll back 0020 and 0018.'; + +revoke all on function purge_analysis_run_registry(text) from public; +grant execute on function purge_analysis_run_registry(text) + to analysis_run_retention_admin; + +commit; diff --git a/pyproject.toml b/pyproject.toml index 5a4aa12bc..b24e4f8e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.88.0" +version = "0.88.1" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 8cb1f0ea1..c11696bb8 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -124,6 +124,7 @@ def seed( cur.execute((migrations / "0020_analysis_run_retention_purge.sql").read_text()) cur.execute((migrations / "0021_analysis_run_reconstruction.sql").read_text()) cur.execute((migrations / "0022_analysis_source_snapshot_member.sql").read_text()) + cur.execute((migrations / "0023_analysis_run_retention_purge_reconstruction.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 6041d3090..215144332 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -278,6 +278,7 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert "0020_analysis_run_retention_purge.sql" in dockerfile assert "0021_analysis_run_reconstruction.sql" in dockerfile assert "0022_analysis_source_snapshot_member.sql" in dockerfile + assert "0023_analysis_run_retention_purge_reconstruction.sql" in dockerfile seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8") assert seed.index("0019_role_catalog_identity.sql") < seed.index( "0020_analysis_run_retention_purge.sql" @@ -288,6 +289,16 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert seed.index("0021_analysis_run_reconstruction.sql") < seed.index( "0022_analysis_source_snapshot_member.sql" ) + assert seed.index("0022_analysis_source_snapshot_member.sql") < seed.index( + "0023_analysis_run_retention_purge_reconstruction.sql" + ) + purge_reconstruction = ( + _ROOT / "migrations" / "0023_analysis_run_retention_purge_reconstruction.sql" + ).read_text(encoding="utf-8") + assert "delete from analysis_run_lineage_edge" in purge_reconstruction + assert "delete from analysis_run_reconstruction" in purge_reconstruction + assert "delete from analysis_source_snapshot_member" in purge_reconstruction + assert "revoke all" in purge_reconstruction.casefold() assert "analysis_run_registry_not_empty" in rollback retention = _RETENTION_MIGRATION.read_text(encoding="utf-8") retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8") diff --git a/uv.lock b/uv.lock index 6915a3531..526cf4741 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.87.0" +version = "0.88.1" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 1ae8ded98e2724f6964e447218aca6d789f76a8a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:57:33 +0000 Subject: [PATCH 6/6] chore: take v0.88.2 so the home-fixture slice can keep 0.88.1 #186 already claims 0.88.1 for the seeded period-report home row. This purge-after-start fix stays ADR 0022 / migration 0023. Co-authored-by: Seongho Bae --- AGENTS.md | 2 +- ...construction.md => 0.88.2-retention-purge-reconstruction.md} | 2 +- CHANGELOG.md | 2 +- frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename CHANGELOG.d/{0.88.1-retention-purge-reconstruction.md => 0.88.2-retention-purge-reconstruction.md} (85%) diff --git a/AGENTS.md b/AGENTS.md index 41edff643..b532d47c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ 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. After a start, migration 0023 -(ADR 0022 / v0.88.1) makes that purge delete reconstruction and +(ADR 0022 / v0.88.2) makes that purge delete reconstruction and snapshot-member rows first. `POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage diff --git a/CHANGELOG.d/0.88.1-retention-purge-reconstruction.md b/CHANGELOG.d/0.88.2-retention-purge-reconstruction.md similarity index 85% rename from CHANGELOG.d/0.88.1-retention-purge-reconstruction.md rename to CHANGELOG.d/0.88.2-retention-purge-reconstruction.md index 7be92cc3e..625fa9fe5 100644 --- a/CHANGELOG.d/0.88.1-retention-purge-reconstruction.md +++ b/CHANGELOG.d/0.88.2-retention-purge-reconstruction.md @@ -1,4 +1,4 @@ -# 0.88.1 purge reconstruction with the granted path +# 0.88.2 purge reconstruction with the granted path After starting a Pending lineage run, empty the registry the same way: insert an unrevoked `analysis_run_retention_grant`, grant diff --git a/CHANGELOG.md b/CHANGELOG.md index c59ff77aa..eeb2eb85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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.88.1] - 2026-08-16 +## [0.88.2] - 2026-08-16 ### Fixed diff --git a/frontend/package.json b/frontend/package.json index 16983140e..dfac39ab2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.88.1", + "version": "0.88.2", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index e46585d99..562b6f8b0 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.88.1" +__version__ = "0.88.2" diff --git a/pyproject.toml b/pyproject.toml index b24e4f8e3..a0860a802 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.88.1" +version = "0.88.2" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 526cf4741..b38c7c7eb 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.88.1" +version = "0.88.2" source = { virtual = "." } dependencies = [ { name = "certifi" },